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 5c2d10151d..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: 26ad870856ce948e1ebb736a07004e41af2a17be -2026-09-15-guided-plugin-installation.zh.md: 36e76c8ace0ae990ede14436b65fa00752f86bbe +2026-09-15-guided-plugin-installation.md: 68bd519b7cb4342c42761dfaef53940c83ceb2d8 +2026-09-15-guided-plugin-installation.zh.md: f3cbefedeb151d37078b55140635b69516fafed2 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 26ad870856..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,9 @@ The install dialog put a spec straight into `pnpm add` and showed pnpm's termina **A failed or cancelled installation restores the profile files.** `installBundle` snapshots `package.json` and `pnpm-lock.yaml` before pnpm runs and puts them back when pnpm fails, when the run is cancelled, or when the package pnpm added declares no bundle patch. This reverses, for installations only, the [manager's decision](2026-09-14-current-profile-plugin-management.md) to retain partial changes: an installation that did not produce a usable bundle must not leave a dependency the page cannot show. Removals keep that decision. Downloaded files can stay under `node_modules` and the pnpm store. -**A failed run is classified where the facts are.** `classifyInstallFailure` reads how the run ended and what pnpm printed — its `ERR_PNPM_*` codes and Node's errno names — into `packageResult.kind`. The client shows the kind as one line and folds pnpm's output behind the details. Parsing the log is confined to this one function with a fixture-driven test. +**A failed run is classified where the facts are.** `classifyInstallFailure` reads how the run ended and what pnpm printed — its `ERR_PNPM_*` codes and Node's errno names — into `packageResult.kind`. A run the manager terminated is classified `timeout` whatever exit status the signal left behind, so installation restores the files and removal reports failure instead of reading it as a success ([run bounds](../bug-fix/2026-09-23-bounded-pnpm-runs.md)). The client shows the kind as one line and folds pnpm's output behind the details. Parsing the log is confined to this one function with a fixture-driven test. + +**GitHub connection failures have a short budget before installation.** The Host runs a read-only `git ls-remote` check with the profile's Git and proxy configuration and a default `githubConnectionTimeoutMs` of 5000 ms. Credential helpers and prompts are disabled for this check, so authentication cannot consume the connection budget. Only network failures and timeouts prevent pnpm from starting; pnpm still owns authentication, HTTPS-to-SSH fallback, ref resolution and the full installation. The budget does not limit downloads or builds. The existing failure kind and `failedAt: 'spec-host'` let the Client show a connection error or timeout and offer a domestic registry for a replacement package spec. Cancellation and manager disposal terminate the check and its descendants before settling. **Cancellation is the manager's, not the signal's.** The dialog generates a request id for each run; the Host streams the run's output and phases under it, and `cancelInstall` answers only after pnpm exited and the files are back. The dialog waits for that answer before offering the spec again; an aborted RPC or a lost connection is not a confirmation. Only the check takes a trailing `AbortSignal`: going back or closing drops a registry lookup, whose settlement nothing waits for. @@ -36,8 +38,10 @@ The install dialog put a spec straight into `pnpm add` and showed pnpm's termina ## Consequences -`inspect`, `cancelInstall`, and the `plugin-manager/changed`, `plugin-manager/install-log`, and `plugin-manager/install-state` events join the manager's Remote; `listBundles` carries titles, rows, and overrides; `ChangeResult` gains `cancelled`, `bundle`, and `packageResult.kind`; the config gains `pnpmCommand` and `inspectTimeoutMs`. The dialog is four screens over one subject card. +`inspect`, `cancelInstall`, and the `plugin-manager/changed`, `plugin-manager/install-log`, and `plugin-manager/install-state` events join the manager's Remote; `listBundles` carries titles, rows, and overrides; `ChangeResult` gains `cancelled`, `bundle`, and `packageResult.kind`; the config gains `pnpmCommand`, `inspectTimeoutMs` and `githubConnectionTimeoutMs`. The dialog is four screens over one subject card. ## Testing `packages/boot/plugin-manager/tests/install-spec.spec.ts` pins the spec forms and the failure classifier's inputs; `manager.spec.ts` drives `inspect` against a stubbed registry lookup and a real directory, streams a run, stops one and checks the restored files, and checks the change events; `operations.spec.ts` covers the registry lookup. `packages/client/ui-plugin-manager/tests` cover the store's phases, Host-confirmed cancellation, post-install enabling, toasts, and the page's four screens; `apps/web/tests/plugin-manager.e2e.ts` refuses an installed name, a missing path, and a bad name through the real Host and switches a bundle and one of its rows live, and `plugin-install-cancel.e2e.ts` stops a real child from the dialog, checks the restored files, and installs on the second try, and `plugin-install-approve.e2e.ts` leaves a script undecided through the fake pnpm, allows it from the dialog, and installs on the retry. + +`github-connection.spec.ts` uses real Git against owned loopback transports to cover immediate failure, timeout, cancellation and noninteractive authentication. `manager.spec.ts` also installs a local repository through real Git and pnpm for HTTPS, GitHub shorthand and SSH specs, isolating pnpm's HTTPS probe through an owned proxy and verifying its SSH fallback. `plugin-install-github.e2e.ts` checks the connection error and timeout dialogs, child cleanup, and switching to a mirror without retrying the old GitHub address. 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 36e76c8ace..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,9 @@ Status: implemented **失败或被取消的安装恢复 profile 文件。** `installBundle` 在 pnpm 运行前快照 `package.json` 与 `pnpm-lock.yaml`,在 pnpm 失败、运行被取消、或 pnpm 装入的包没有声明组合包 patch 时把它们放回去。这只针对安装反转了[管理器的决定](2026-09-14-current-profile-plugin-management.zh.md)中"保留部分改动"的部分:没有产出可用组合包的安装,不能留下一个页面无法显示的依赖。删除仍沿用那个决定。已下载文件可能留在 `node_modules` 与 pnpm 缓存中。 -**失败在事实所在处分类。** `classifyInstallFailure` 依据运行的结束方式和 pnpm 的输出——它的 `ERR_PNPM_*` 码和 Node 的 errno 名——给出 `packageResult.kind`。客户端把 kind 显示成一句话,把 pnpm 输出折叠进详情。对日志的解析只存在于这一个函数,用夹具驱动的测试钉住。 +**失败在事实所在处分类。** `classifyInstallFailure` 依据运行的结束方式和 pnpm 的输出——它的 `ERR_PNPM_*` 码和 Node 的 errno 名——给出 `packageResult.kind`。被管理器终止的运行,不论信号留下什么退出状态都归类为 `timeout`,因此安装会还原文件,删除则报告失败而不会读成成功([运行上界](../bug-fix/2026-09-23-bounded-pnpm-runs.zh.md))。客户端把 kind 显示成一句话,把 pnpm 输出折叠进详情。对日志的解析只存在于这一个函数,用夹具驱动的测试钉住。 + +**GitHub 连接失败在安装前有单独的短时限。** 宿主通过只读的 `git ls-remote` 检查连接,沿用 profile 的 Git 与代理配置,`githubConnectionTimeoutMs` 默认为 5000 毫秒。这次检查禁用凭据助手和认证提示,避免认证过程占用连接预算。只有网络失败与超时会阻止 pnpm 启动;认证、HTTPS 到 SSH 的回退、ref 解析及完整安装仍由 pnpm 负责。时限不约束下载或构建。已有的失败类型与 `failedAt: 'spec-host'` 让客户端显示连接失败或超时,并提供国内镜像供用户输入另一个包名。取消安装或销毁管理器会先终止检查及其子进程,再结束操作。 **取消属于管理器,不属于信号。** 对话框为每次运行生成一个 request id;宿主在它之下流式转发运行的输出与阶段,`cancelInstall` 只在 pnpm 退出且文件恢复后才答复。对话框等到这个答复才把 spec 重新交回;中止的 RPC 或断开的连接都不算确认。只有检查末尾接受 `AbortSignal`:返回编辑或关闭会丢掉一次注册表查询,没有人等它的结果。 @@ -36,8 +38,10 @@ Status: implemented ## 后果 -`inspect`、`cancelInstall` 以及 `plugin-manager/changed`、`plugin-manager/install-log`、`plugin-manager/install-state` 事件加入管理器的 Remote;`listBundles` 携带标题、行与覆盖项;`ChangeResult` 增加 `cancelled`、`bundle` 与 `packageResult.kind`;配置增加 `pnpmCommand` 与 `inspectTimeoutMs`。对话框是围绕同一张主题卡的四个画面。 +`inspect`、`cancelInstall` 以及 `plugin-manager/changed`、`plugin-manager/install-log`、`plugin-manager/install-state` 事件加入管理器的 Remote;`listBundles` 携带标题、行与覆盖项;`ChangeResult` 增加 `cancelled`、`bundle` 与 `packageResult.kind`;配置增加 `pnpmCommand`、`inspectTimeoutMs` 与 `githubConnectionTimeoutMs`。对话框是围绕同一张主题卡的四个画面。 ## 测试 `packages/boot/plugin-manager/tests/install-spec.spec.ts` 钉住 spec 形式与失败分类器的输入;`manager.spec.ts` 用桩住的注册表查询和真实目录驱动 `inspect`,流式转发一次运行,停下一次运行并检查恢复后的文件,并检查变更事件;`operations.spec.ts` 覆盖注册表查询。`packages/client/ui-plugin-manager/tests` 覆盖 store 的阶段、经宿主确认的取消、装后启用、toast 与页面的四个画面;`apps/web/tests/plugin-manager.e2e.ts` 经真实宿主拒绝已装名字、不存在的路径和坏名字,并实时切换一个组合包及其中一行,`plugin-install-cancel.e2e.ts` 从对话框停下一个真实子进程、检查恢复后的文件,并在第二次尝试时装成,`plugin-install-approve.e2e.ts` 用假 pnpm 把脚本留作未决、从对话框允许后在重试中装上。 + +`github-connection.spec.ts` 用真实 Git 和测试自有的本机网络端点覆盖立即失败、超时、取消与非交互认证。`manager.spec.ts` 还通过真实 Git 与 pnpm 安装本地仓库,覆盖 HTTPS、GitHub 简写和 SSH 地址,用自有代理隔离 pnpm 的 HTTPS 请求并验证其 SSH 回退。`plugin-install-github.e2e.ts` 检查连接失败及超时弹窗、子进程清理,以及切换镜像时不重试原 GitHub 地址。 diff --git a/.agents/notes/implemented/architecture/2026-09-18-chat-navigation-performance.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-18-chat-navigation-performance.i18n.yaml index 143d9ccffe..257088effa 100644 --- a/.agents/notes/implemented/architecture/2026-09-18-chat-navigation-performance.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-18-chat-navigation-performance.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-18-chat-navigation-performance.md -2026-09-18-chat-navigation-performance.md: d990c0cb9a351ea3b3224ce5feec912c176a5fc9 -2026-09-18-chat-navigation-performance.zh.md: 1a330d3b6bf6357199eb09ffc1cdb434cda1c1af +2026-09-18-chat-navigation-performance.md: d82c68098fc422abb181ed4e5e1ca41189cd0951 +2026-09-18-chat-navigation-performance.zh.md: 9c2f81a51ce53660aabf2dd1e729d02e8028b7b6 diff --git a/.agents/notes/implemented/architecture/2026-09-18-chat-navigation-performance.md b/.agents/notes/implemented/architecture/2026-09-18-chat-navigation-performance.md index d990c0cb9a..d82c68098f 100644 --- a/.agents/notes/implemented/architecture/2026-09-18-chat-navigation-performance.md +++ b/.agents/notes/implemented/architecture/2026-09-18-chat-navigation-performance.md @@ -22,6 +22,8 @@ The rail uses ResizeObserver-delivered viewport dimensions and fixed item sizes. [ChatReading](../../../../packages/client/ui-chat/src/client/chat/use-chat-reading.ts) owns follow-tail policy, semantic restoration, and sampled reader movement. [ChatNavigation](../../../../packages/client/ui-chat/src/client/chat/use-chat-navigation.ts) owns loaded or unloaded jumps, paging anchors, replacement tasks, and fallback. [useChatScroll](../../../../packages/client/ui-chat/src/client/chat/use-chat-scroll.ts) supplies committed inputs and coordinates these owners. Timer and animation-frame completion invoke the owners directly instead of incrementing React tick state. The [pinned-delivery rule](../../../../packages/client/ui-chat/README.md#scroll-ownership) remains independently necessary: small reader gestures cannot be erased by intervening layout growth. +Shared nested-follow mechanics and control placement are recorded in the [Chat scroll and footer decision](../bug-fix/2026-09-22-chat-scroll-follow-and-footer-geometry.md); sampled outer reading and historical navigation retain the owners above. + ### One publication per historical jump [Session.loadThrough](../../../../packages/api/session-controller/src/client/sessions/session.ts) retains accepted pages and a private paging cursor until its shared target is covered or loading ends. It reverses the page list and flattens it once, then publishes one ordered prepend. Live events continue through their normal publication path. A later page failure publishes the successful prefix once; stream or window replacement discards obsolete buffered pages. Single-page `loadOlder` remains immediate, and repeated jump requests retain the existing lowest-target policy. diff --git a/.agents/notes/implemented/architecture/2026-09-18-chat-navigation-performance.zh.md b/.agents/notes/implemented/architecture/2026-09-18-chat-navigation-performance.zh.md index 1a330d3b6b..9c2f81a51c 100644 --- a/.agents/notes/implemented/architecture/2026-09-18-chat-navigation-performance.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-18-chat-navigation-performance.zh.md @@ -22,6 +22,8 @@ Status: implemented [ChatReading](../../../../packages/client/ui-chat/src/client/chat/use-chat-reading.ts) 负责跟随底部、语义恢复和读者移动采样。[ChatNavigation](../../../../packages/client/ui-chat/src/client/chat/use-chat-navigation.ts) 负责已加载或未加载轮次跳转、分页锚点、任务替换和回退。[useChatScroll](../../../../packages/client/ui-chat/src/client/chat/use-chat-scroll.ts) 提供已提交输入并协调各对象。定时器与动画帧完成时直接调用负责对象,不再递增 React tick 状态。[贴底事件规则](../../../../packages/client/ui-chat/README.zh.md#scroll-ownership) 仍有独立必要性:期间发生的布局增长不能抵消读者的小幅操作。 +共用的嵌套跟随机制与控件定位见 [Chat 滚动与页脚决策](../bug-fix/2026-09-22-chat-scroll-follow-and-footer-geometry.zh.md);外层采样阅读与历史导航仍由上述对象负责。 + ### 每次历史跳转统一发布 [Session.loadThrough](../../../../packages/api/session-controller/src/client/sessions/session.ts) 在共享目标被覆盖或加载结束前,保留已接受的页面和私有分页游标。它只反转一次页面列表并展开一次,然后发布一次有序前插。实时事件继续走正常发布路径。后续页面失败时,已成功取得的部分统一发布一次;流或窗口被替换时丢弃失效的暂存页面。单页 `loadOlder` 仍立即发布,重复跳转请求保留既有的最低目标策略。 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-21-conversation-build-groups.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-21-conversation-build-groups.i18n.yaml index 8e9540156a..1bd7d91641 100644 --- a/.agents/notes/implemented/architecture/2026-09-21-conversation-build-groups.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-21-conversation-build-groups.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-21-conversation-build-groups.md -2026-09-21-conversation-build-groups.md: 7c02de8dd0aaf842690806ea67ddaf5d4561fc4e -2026-09-21-conversation-build-groups.zh.md: 4f2b331596cacdd63d902c9db73931d65ac589ad +2026-09-21-conversation-build-groups.md: bccb48b0e40a076bbcb59aaca9e9ef6208e9469d +2026-09-21-conversation-build-groups.zh.md: 5d2ec9e684c75b3db3568c4f046129431295f736 diff --git a/.agents/notes/implemented/architecture/2026-09-21-conversation-build-groups.md b/.agents/notes/implemented/architecture/2026-09-21-conversation-build-groups.md index 7c02de8dd0..bccb48b0e4 100644 --- a/.agents/notes/implemented/architecture/2026-09-21-conversation-build-groups.md +++ b/.agents/notes/implemented/architecture/2026-09-21-conversation-build-groups.md @@ -35,6 +35,7 @@ The grouping foundation provides a separate, Node-input `ConversationGroupDefini | Record | Relationship | |---|---| | [Business Node assembly](2026-08-09-client-conversation-node-assembly.md) | Retains event matching, Contexts, Locations, one business Node per Context, and target Builders; grouping adds another input category. | +| [Chat scroll and footer](../bug-fix/2026-09-22-chat-scroll-follow-and-footer-geometry.md) | Owns clipping and independent nested following without changing Group Definition membership. | Grouping preserves the independent Node-assembly decisions. Cross-View navigation and `toolCallFocus` remain separate responsibilities; Group references carry no View handles or resource-navigation policy. @@ -106,7 +107,7 @@ Grouping never replays raw events. Node Definitions handle replay first; groupin | [group-store.ts](../../../../packages/client/ui-conversation/src/client/conversation/group-store.ts) | Atomic reference validation, keyed sources, array reuse, and local publication. | | [chat-snapshot-builder.ts](../../../../packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts) | Record projected Node deltas, target positions, and changed Turn orders; provide indexed readers and defer source notification. No process grouping class. | | [ChatView.tsx](../../../../packages/client/ui-chat/src/client/chat/ChatView.tsx) | Read optional root entries and switch between node/group; fallback to existing Node order. | -| [ChatGroupSeat.tsx](../../../../packages/client/ui-chat/src/client/chat/ChatGroupSeat.tsx) | Stable group parent, member-only subscription, nested Node seats. | +| [ChatGroupSeat.tsx](../../../../packages/client/ui-chat/src/client/chat/ChatGroupSeat.tsx) | Stable group parent, group-local subscriptions, nested Node seats. | | [ChatNodeSeat.tsx](../../../../packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx) | Existing Node sources/renderers, groupPart forwarding, distinct part anchors, Store-replacement rebind. | | [slots.ts](../../../../packages/client/ui-chat/src/client/contract/slots.ts) and [apply.ts](../../../../packages/client/ui-chat/src/client/apply.ts) | Existing keyed-hook injection for Group sources; no Slot-engine change. | diff --git a/.agents/notes/implemented/architecture/2026-09-21-conversation-build-groups.zh.md b/.agents/notes/implemented/architecture/2026-09-21-conversation-build-groups.zh.md index 4f2b331596..5d2ec9e684 100644 --- a/.agents/notes/implemented/architecture/2026-09-21-conversation-build-groups.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-21-conversation-build-groups.zh.md @@ -35,6 +35,7 @@ Chat Builder 汇总整个目标的节点及索引。在其中固定创建 Chat | 记录 | 关系 | |---|---| | [业务节点组装](2026-08-09-client-conversation-node-assembly.zh.md) | 保留事件匹配、Context、Location、每个 Context 一个业务 Node 及目标 Builder,分组增加另一种输入类别。 | +| [Chat 滚动与页脚](../bug-fix/2026-09-22-chat-scroll-follow-and-footer-geometry.zh.md) | 负责裁剪与独立的嵌套跟随,不改变 Group Definition 成员关系。 | 分组保留独立的节点组装决策。跨 View 导航与 `toolCallFocus` 仍是独立职责,Group 引用不携带 View 句柄或资源导航策略。 @@ -106,7 +107,7 @@ GroupStore 按 GroupKey 索引记录和来源,不反复查找数组。等价 | [group-store.ts](../../../../packages/client/ui-conversation/src/client/conversation/group-store.ts) | 原子引用校验、按键来源、数组复用及局部发布。 | | [chat-snapshot-builder.ts](../../../../packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts) | 记录投影后节点增量、目标位置及变化轮次顺序,提供索引读取器并推迟来源通知,不持有过程分组类。 | | [ChatView.tsx](../../../../packages/client/ui-chat/src/client/chat/ChatView.tsx) | 读取可选根引用并切换 node/group,无分组时使用既有 Node 顺序。 | -| [ChatGroupSeat.tsx](../../../../packages/client/ui-chat/src/client/chat/ChatGroupSeat.tsx) | 稳定组父级、仅成员订阅及嵌套 Node 容器。 | +| [ChatGroupSeat.tsx](../../../../packages/client/ui-chat/src/client/chat/ChatGroupSeat.tsx) | 稳定组父级、组内订阅及嵌套 Node 容器。 | | [ChatNodeSeat.tsx](../../../../packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx) | 既有 Node 来源及渲染器、groupPart 传递、独立部分锚点及 Store 替换时重绑定。 | | [slots.ts](../../../../packages/client/ui-chat/src/client/contract/slots.ts) 与 [apply.ts](../../../../packages/client/ui-chat/src/client/apply.ts) | 使用现有按键钩子注入 Group 来源,不改 Slot 引擎。 | 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-08-17-durable-web-queue-recovery.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-17-durable-web-queue-recovery.i18n.yaml index 56aaada1b7..5c76962fef 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-17-durable-web-queue-recovery.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-17-durable-web-queue-recovery.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-17-durable-web-queue-recovery.md -2026-08-17-durable-web-queue-recovery.md: 5fbfdf0e62c1034fbea138016cc2979489de474f -2026-08-17-durable-web-queue-recovery.zh.md: 2aaffd6bc571aab449d9429758fdf45bb4dd8d4c +2026-08-17-durable-web-queue-recovery.md: 0f18cd7a2b9491d6a8bdfa960409d97a5338f95d +2026-08-17-durable-web-queue-recovery.zh.md: 476c1b2fe88156ba8c34e281ff1d1e5663c7913c diff --git a/.agents/notes/implemented/bug-fix/2026-08-17-durable-web-queue-recovery.md b/.agents/notes/implemented/bug-fix/2026-08-17-durable-web-queue-recovery.md index 5fbfdf0e62..0f18cd7a2b 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-17-durable-web-queue-recovery.md +++ b/.agents/notes/implemented/bug-fix/2026-08-17-durable-web-queue-recovery.md @@ -49,3 +49,5 @@ Client coverage pins generic Inbox projection delivery, reconnect invalidation f Pending Queue and steering input recover after Host process restart without resuming an Agent. Live Inbox reads, cold history, reconnect, and projection caching use the same domain-owned fold and registry state. Operations on a restored row do resume its ordinary Agent, preserving preset composition and ownership checks. Clients receive the raw two-list Inbox value and decide which messages their surface presents. The projection state version invalidates cached rows whenever its serialized state or fold semantics change. + +The [input echo admission decision](2026-09-22-input-echo-admission-ownership.md) owns local Chat/Dock handoff and suppression of delayed Inbox rows without replacing this recovery model. diff --git a/.agents/notes/implemented/bug-fix/2026-08-17-durable-web-queue-recovery.zh.md b/.agents/notes/implemented/bug-fix/2026-08-17-durable-web-queue-recovery.zh.md index 2aaffd6bc5..476c1b2fe8 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-17-durable-web-queue-recovery.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-17-durable-web-queue-recovery.zh.md @@ -49,3 +49,5 @@ Host 投影覆盖会读取包含待处理输入的已分离持久 Session,在 待处理 Queue 与 steering 输入可在 Host 进程重启后恢复,而无需恢复 Agent。live Inbox 读取、冷历史、重连与投影缓存使用同一份领域拥有的折叠与注册表状态。操作恢复出的行时会恢复其普通 Agent,从而保留 preset 组合与所有权检查。 客户端接收原始的两列表 Inbox 值,并自行决定界面呈现哪些消息。投影的状态版本会在其序列化状态或折叠语义变化时使缓存行失效。 + +[输入回显入档决策](2026-09-22-input-echo-admission-ownership.zh.md)负责本地 Chat/Dock 接管和迟到 Inbox 行的排除,不替换这里的恢复模型。 diff --git a/.agents/notes/implemented/bug-fix/2026-09-22-chat-scroll-follow-and-footer-geometry.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-09-22-chat-scroll-follow-and-footer-geometry.i18n.yaml new file mode 100644 index 0000000000..ceb40894c4 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-22-chat-scroll-follow-and-footer-geometry.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-22-chat-scroll-follow-and-footer-geometry.md +2026-09-22-chat-scroll-follow-and-footer-geometry.md: 56cd1681eef23162df2595a138545ade25e34212 +2026-09-22-chat-scroll-follow-and-footer-geometry.zh.md: 9979688d52373ae7a64a3f4fc22b932b43091b34 diff --git a/.agents/notes/implemented/bug-fix/2026-09-22-chat-scroll-follow-and-footer-geometry.md b/.agents/notes/implemented/bug-fix/2026-09-22-chat-scroll-follow-and-footer-geometry.md new file mode 100644 index 0000000000..56cd1681ee --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-22-chat-scroll-follow-and-footer-geometry.md @@ -0,0 +1,54 @@ +# Agent Note: Separate Chat clipping and independent follow intent + +Status: implemented + +English | [中文](2026-09-22-chat-scroll-follow-and-footer-geometry.zh.md) + +## Problem + +Transcript overflow must remain clipped without trapping floating controls or changing the scroll ancestor of sticky content. An `overflow-y: hidden` ancestor becomes the nearest scroll container even when readers scroll a different outer element. It prevents Markdown code banners and expanded compaction headers from sticking to the visible conversation scrollport; floating controls inside that ancestor also use transcript geometry instead of viewport geometry. + +A height-capped process group also has a separate reading position. Outer bottom-follow cannot expose newly appended content inside a group whose own scroll offset stays unchanged. Treating every non-bottom scroll event as reader input does not support native smooth following: the browser emits those same events while an animation approaches its target. + +## Decision + +The transcript, floating controls, and scroll intent retain separate ownership. The [scroll-state table](../../../../packages/client/ui-chat/README.md#scroll-ownership) and [group display rules](../../../../packages/client/ui-chat/src/client/conversation-nodes/README.md#display-modes) define the current product behavior. + +| Area | Implementation | Reason | +|---|---|---| +| Transcript clipping | `ChatView` uses `overflow-x: visible; overflow-y: clip` on `.root` inside an unclipped frame. | Constrain vertical overflow without introducing a scroll container between sticky content and its actual scrollport. | +| Floating controls | TurnNavigator and the back-to-bottom button are siblings of the clipped transcript. | Shared Conversations use the outer scrollport for sticky positioning; standalone Chat uses its frame for absolute positioning. | +| Follow intent | `useScrollFollow` retains one independent controller per scrollport. | Reuse thresholds, position sampling, and native scrolling without sharing one follow flag. | +| Group lifecycle | `useProcessScroll` owns its existing body/content observer, directional fades, and one-shot opening position. | Group growth remains observable after the body reaches its cap; opening performs no immediate geometry read. | + +The clipped root owns no scroll offset. Shared Conversation scrolling, standalone Chat scrolling, capped process groups, and terminal sections retain their existing scrollports. The horizontal axis stays `visible`: pairing vertical `clip` with horizontal `auto` or `hidden` computes the vertical overflow back to `hidden` and restores the unwanted scroll container. + +An unclosed group opens at its bottom; a closed group opens at its top. The group’s `closed` value, not the whole Turn’s status or an all-node search, decides that initial position. A later close does not reset an already-open reader. Browser find keeps its own reveal position, and display-mode changes preserve mounted members. + +Native smooth growth keeps an outstanding target separate from the current position. Animation progress retains follow intent. Input interrupts native motion before the browser applies reader movement; the resulting actual displacement determines whether following stops or resumes. Further growth retains the issued target until `scrollend`. Arrival at that target, including shrink clamping, allows one request to the latest floor; an off-target stop releases following so focus and find keep their landing. Reduced motion and explicit opening positions use immediate scrolling. Outer following keeps its existing immediate behavior and sampling policy. + +The back-to-bottom button depends only on outer follow intent and restores only that intent. Inner scrolling affects the outer controller only when scroll chaining actually moves the outer scrollport. Group height growth can resize the outer transcript, but the controllers do not invoke each other’s follow actions. Paging and semantic restoration remain with the existing outer reading/navigation owners. + +## Alternatives considered + +**Remove transcript clipping.** That would abandon the overflow constraint. Vertical `clip` retains clipping without creating a scroll container; the unclipped sibling frame keeps floating controls outside the clipped area without a Portal or document-level coordinate synchronization. + +**Keep `overflow-y: hidden` and move only floating controls.** That protects the controls but leaves sticky content inside the unwanted scroll container. Changing the root to `clip` also preserves the scroll ancestor of code and compaction headers without moving those headers out of their content. + +**Share one follow flag, or duplicate the complete controllers.** One flag would pull a group away from a reader merely because the outer transcript follows. Two complete implementations would duplicate animation attribution and threshold rules. Sharing the small controller retains independent state without moving paging, Turn navigation, or semantic memory into it. + +**Only replace `scrollTop` assignment with smooth `scrollTo`.** Intermediate animation offsets would appear to be reader movement and disable following. Native motion needs an explicit target and input interruption; a custom animation-frame interpolator is unnecessary. + +## Verification + +[Controller tests](../../../../packages/client/ui-chat/tests/scroll-follow.client.spec.ts) cover independent thresholds, animation progress, target reuse, input interruption, reduced motion, and immediate positioning. [Chat component tests](../../../../packages/client/ui-chat/tests/chat-view.client.spec.tsx) cover group opening, pause/resume, closing while open, and browser-find preservation. [Viewport tests](../../../../packages/client/ui-chat/tests/chat-viewport.client.spec.ts) retain the outer anchor and attribution behavior. + +[Browser scroll cases](../../../../apps/web/tests/chat-scroll-contract.e2e.ts) exercise floating-control geometry, nested following, paging, streaming, and real wheel input through the shipped Web composition. + +[Seeded-history cases](../../../../apps/web/tests/seeded-history.e2e.ts) check an expanded compaction header and its nested code banner against the actual conversation scrollport, including their stacking and copy-button access. + +## Consequences + +The shared controller stores intent without scheduling React renders; callers still publish visible state changes, and group edge fades update only when their booleans change. Follow operations read the affected scrollport, not its Node collection. Native scrolling still performs browser layout and paint work. These changes establish behavior and ownership, not a measured latency or memory improvement. + +The [navigation decision](../architecture/2026-09-18-chat-navigation-performance.md) still owns sampled reading, known landings, and batched historical jumps. The [grouping decision](../architecture/2026-09-21-conversation-build-groups.md) still owns Definition-based membership and stable rendering identities. Extracting scroll-follow mechanics does not supersede either decision. diff --git a/.agents/notes/implemented/bug-fix/2026-09-22-chat-scroll-follow-and-footer-geometry.zh.md b/.agents/notes/implemented/bug-fix/2026-09-22-chat-scroll-follow-and-footer-geometry.zh.md new file mode 100644 index 0000000000..9979688d52 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-22-chat-scroll-follow-and-footer-geometry.zh.md @@ -0,0 +1,54 @@ +# Agent Note: 分离 Chat 裁剪与独立滚动跟随意图 + +Status: implemented + +[English](2026-09-22-chat-scroll-follow-and-footer-geometry.md) | 中文 + +## 问题 + +正文溢出必须保持裁剪,同时不能限制悬浮控件或改变 sticky 内容的滚动祖先。即使读者实际滚动的是外层元素,`overflow-y: hidden` 祖先也会成为最近的滚动容器。这会阻止 Markdown 代码块头栏和展开的压缩摘要头栏吸附在可见会话滚动区域;位于该祖先内的悬浮控件也会参照正文几何,而非视口几何。 + +限高过程组也有独立阅读位置。若组内偏移不变,外层跟随底部无法展示组内新增内容。把每个未到底的滚动事件都当成读者输入,也无法支持原生平滑跟随:浏览器在动画接近目标期间同样会发出这些事件。 + +## 决策 + +正文、悬浮控件及滚动意图分别由独立对象负责。[滚动状态表](../../../../packages/client/ui-chat/README.zh.md#scroll-ownership)与[分组展示规则](../../../../packages/client/ui-chat/src/client/conversation-nodes/README.zh.md#display-modes)定义当前产品行为。 + +| 范围 | 实现 | 原因 | +|---|---|---| +| 正文裁剪 | `ChatView` 在无裁剪的外框内,对 `.root` 使用 `overflow-x: visible; overflow-y: clip`。 | 限制纵向溢出,不在 sticky 内容与其实际滚动区域之间引入滚动容器。 | +| 悬浮控件 | TurnNavigator 与回到底部按钮位于正文裁剪层的同级。 | 共享 Conversation 相对外层滚动容器做 sticky 定位;独立 Chat 相对自身外框做 absolute 定位。 | +| 跟随意图 | `useScrollFollow` 为每个滚动区保留独立控制器。 | 共用阈值、位置采样及原生滚动,不共用一个跟随开关。 | +| 小组生命周期 | `useProcessScroll` 负责既有的正文与内容观察器、方向渐隐和一次性展开位置。 | 正文达到限高后,内容增长仍可被观察;展开时不立即读取几何。 | + +被裁剪的根节点不持有滚动偏移。共享 Conversation、独立 Chat、限高过程组和终端区域保留各自原有的滚动容器。横轴保持 `visible`:纵向 `clip` 如果与横向 `auto` 或 `hidden` 组合,纵向溢出的计算值会变回 `hidden`,重新引入不需要的滚动容器。 + +未关闭的小组展开到自己的底部,已关闭的小组展开到顶部。初始位置由组自身的 `closed` 决定,不取整个 Turn 的状态,也不遍历全部节点。组随后关闭不会重置已展开的阅读位置。浏览器查找保留自身的展开定位,模式切换保留已挂载成员。 + +原生平滑跟随将未完成的目标与当前位置分开记录。动画进度保留跟随意图。输入在浏览器处理读者移动前中断原生动画,再由实际位移决定是否暂停或恢复跟随。后续增长保留已发出的目标,直到 `scrollend`。到达该目标(包括内容缩短后的钳制位置)才允许再次请求最新底部;停在目标之外则释放跟随,让焦点和查找保留落点。减少动态效果与明确的展开定位使用即时滚动。外层跟随保留既有的即时行为和采样策略。 + +回到底部按钮只取决于外层跟随意图,也只恢复该意图。只有滚动传递确实移动外层滚动区域时,内层滚动才影响外层控制器。小组长高可以改变外层正文尺寸,但两个控制器不会互相调用跟随操作。分页和语义恢复仍由既有的外层阅读与导航对象负责。 + +## 考虑过的替代方案 + +**删除正文裁剪。** 这会放弃溢出约束。纵向 `clip` 保留裁剪而不创建滚动容器;无裁剪的同级外框让悬浮控件位于裁剪区域之外,不需要 Portal 或文档级坐标同步。 + +**保留 `overflow-y: hidden`,仅移动悬浮控件。** 这可以保护控件,但 sticky 内容仍位于不需要的滚动容器内。将根节点改为 `clip` 也能保留代码和压缩摘要头栏的滚动祖先,无需把这些头栏移出其正文。 + +**共用一个跟随开关,或复制完整控制器。** 共用开关会仅因外层跟随就把小组拉离读者位置。复制两套完整实现则会重复动画归因和阈值规则。共用小控制器可以保留独立状态,同时不把分页、轮次导航及语义记忆搬入其中。 + +**只把 `scrollTop` 赋值换成平滑 `scrollTo`。** 动画中间偏移会被误判为读者移动,导致跟随关闭。原生动画需要明确的目标与输入中断处理,无需自写动画帧插值器。 + +## 验证 + +[控制器测试](../../../../packages/client/ui-chat/tests/scroll-follow.client.spec.ts)覆盖独立阈值、动画进度、目标复用、输入中断、减少动态效果和即时定位。[Chat 组件测试](../../../../packages/client/ui-chat/tests/chat-view.client.spec.tsx)覆盖小组展开、暂停与恢复、展开期间关闭及浏览器查找位置保持。[视口测试](../../../../packages/client/ui-chat/tests/chat-viewport.client.spec.ts)保留外层锚点与归因行为。 + +[浏览器滚动用例](../../../../apps/web/tests/chat-scroll-contract.e2e.ts)通过正式 Web 装配验证悬浮控件几何、嵌套跟随、分页、流式内容与真实滚轮输入。 + +[历史回放用例](../../../../apps/web/tests/seeded-history.e2e.ts)相对实际会话滚动区域检查展开的压缩摘要头栏及其内部代码头栏,包括层叠关系和复制按钮的可访问性。 + +## 影响 + +共享控制器只保存意图,不自行调度 React 渲染;调用方仍发布可见状态变化,小组边缘渐隐仅在布尔值变化时更新。跟随操作读取受影响的滚动容器,而非节点集合。原生滚动仍有浏览器布局和绘制成本。这些改动确立行为与职责,不代表已测得延迟或内存收益。 + +[导航决策](../architecture/2026-09-18-chat-navigation-performance.zh.md)继续负责采样阅读、已知落点与聚合历史跳转。[分组决策](../architecture/2026-09-21-conversation-build-groups.zh.md)继续负责 Definition 成员关系与稳定渲染身份。提取滚动跟随机制不取代这两项决策。 diff --git a/.agents/notes/implemented/bug-fix/2026-09-22-input-echo-admission-ownership.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-09-22-input-echo-admission-ownership.i18n.yaml new file mode 100644 index 0000000000..6ba4b3a015 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-22-input-echo-admission-ownership.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-22-input-echo-admission-ownership.md +2026-09-22-input-echo-admission-ownership.md: 227857bd8168aa1d7f51b621737c2ed0ab572632 +2026-09-22-input-echo-admission-ownership.zh.md: 1d64de220a3c6d5c9357770175c79266b8f9f063 diff --git a/.agents/notes/implemented/bug-fix/2026-09-22-input-echo-admission-ownership.md b/.agents/notes/implemented/bug-fix/2026-09-22-input-echo-admission-ownership.md new file mode 100644 index 0000000000..227857bd81 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-22-input-echo-admission-ownership.md @@ -0,0 +1,54 @@ +# Agent Note: Input echoes retain display ownership through admission + +Status: implemented + +English | [中文](2026-09-22-input-echo-admission-ownership.zh.md) + +## Problem + +An idle submission can appear first as a local Chat echo, then as an Inbox row in QueueDock, and finally as a durable user message. Inbox projection and conversation history arrive independently: retiring the echo on acceptance leaves a gap before admission, while a delayed Inbox projection can duplicate an already-admitted message. Separately, turn/start precedes user/message, so an empty progress title can appear before the echo and exchange positions with it when the real input arrives. + +These transitions concern display ownership, not scrolling or composer geometry. A delay cannot identify which representation is authoritative, and moving every echo before the progress title would move running steering ahead of existing work. + +## Decision + +- A submission keeps its locally selected placement: idle input stays in Chat, running steering stays at the process tail, and explicitly queued input stays in QueueDock. Inbox acceptance does not change that placement. +- Chat hides an echo by matching the durable input's rpcId in the same render, and excludes stale next-step Inbox rows matched by admitted local Chat submissions. QueueDock excludes only Inbox rows matched by local transcript echoes; unrelated queued messages remain visible and actionable. +- Session retains an admitted transcript or steering submission's local suppression identity until the Inbox projection watermark reaches its claim sequence. Higher-sequence projection retention then prevents an older accepted row from reappearing after that identity retires. Claim tracking and cancellation use the existing per-submission receipt for both Inbox targets; no new SessionSnapshot field, Host event, or persisted format is needed. +- At an open, input-free progress control at the end of Chat, the first transcript echo precedes the control. Other echoes remain at the tail. Last-input Turn tracking shares the existing rpcId scan; it does not add a history traversal or predict a sequence of future Turns. +- Pending bubbles and durable seats share one keyed React list. Inserting a progress title preserves the pending bubble's mounted identity. Once admitted, the durable node owns ordering and ordinary Group segmentation. + +The [durable Inbox recovery decision](2026-08-17-durable-web-queue-recovery.md) still owns cold reads, reconstruction and projection transport. The [generic file upload decision](../feature/2026-08-26-generic-file-upload.md) still owns attachment preparation and draft recovery; retirement returns each submission's durable references once, in attachment order. + +## Alternatives considered + +**Delay QueueDock or echo removal by a fixed interval.** CPU throttling, transport batching and projection ordering can exceed any such interval. Time does not establish admission or confirm that stale queue state is gone. + +**Retain the echo without changing the other readers.** Chat-side Inbox deduplication can still remove the echo, and QueueDock can still display it. The Session lifetime, Chat handoff and Dock exclusion must agree. + +**Delete the suppression identity as soon as user/message arrives.** A later Inbox acceptance projection can resurrect a Dock or pending-steering row. The claim watermark supplies the required ordering evidence without assuming the streams publish together. + +**Move all pending inputs before the newest Turn control.** Running steering belongs after existing process content, and several ordinary submissions can target different Turns. Only the first local transcript echo receives the empty-control placement adjustment. + +**Add a Host correlation event or a client-side Turn scheduler.** Exact prediction across competing clients and reordered requests would widen the change beyond local optimistic presentation. Durable admission remains authoritative; optimistic ordering is deliberately approximate in those races. + +## Verification + +| Evidence | Covered behavior | +|---|---| +| [Session submissions](../../../../packages/api/session-controller/tests/session-pending-submissions.client.spec.ts) | Projection-first and history-first handoff, claim watermark, overlapping opening/queue/steering identities, attachment callbacks, cancellation, unadmitted endings, reconnect baselines and disposal. | +| [Projection store](../../../../packages/api/session-controller/tests/projection-store.client.spec.ts) | Missing/cached watermarks, monotonic sequence retention and clearing. | +| [Chat rendering](../../../../packages/client/ui-chat/tests/chat-view.client.spec.tsx) | Same-render rpcId replacement, stable echo mounting, title-before-input delivery in all three modes, steering remaining behind the title, historical empty Turns, and six local-versus-Host orderings. | +| [QueueDock](../../../../packages/client/ui-conversation/tests/queue-dock.client.spec.tsx) | Local transcript exclusion, unrelated rows/actions, and all six three-message acceptance orders followed by FIFO claims. | +| [Recorded Web replay](../../../../apps/web/tests/idle-submission-handoff.e2e.ts) | Real Web composition with FIFO-preserving delivery barriers: both Inbox/history arrival orders at 6× CPU throttling, an explicit turn/start-to-user/message gap, and complete three-Turn handoff for all six request orders. | +| [Steering Web replay](../../../../apps/web/tests/steering.e2e.ts) | Local shortcut steering remains unique when durable admission precedes its Inbox claim projection. | + +The single-input regression fails before the fix: Inbox acceptance moves the bubble into Dock, and a separately delivered Turn title precedes the echo. The corrected path retains one Chat representation at a stable vertical position. Multi-input replay verifies durable order and absence of duplicate or residual echoes; it does not assert that optimistic ordering predicts Host order. + +## Consequences + +- Steering without a locally tracked submission still follows the Inbox projection; a delayed projection can briefly duplicate its durable row. Local deduplication adds no history scan for other clients' submissions. +- Reconnect may discard receipt-confirmed optimistic echoes and display the new authoritative baseline. A claimed input outside that baseline can briefly have no bubble; reconnect does not promise DOM continuity. +- Submissions made before the local running update can all remain transcript echoes. If A, B and C are submitted locally in that order but B or C enters first, durable admission changes their visible order. Even when A enters first, remaining local echoes can differ from the Host queue order. +- Cross-client competition and request-preparation races can change the actual opening input. The client does not migrate an idle-classified echo into Dock merely because the Host queued it behind another Turn. +- The changes prevent representation handoff flicker on the ordinary online path without changing model input, Inbox scheduling, scroll-follow policy, or released Session data. The browser barriers establish behavior, not a general latency or throughput improvement. diff --git a/.agents/notes/implemented/bug-fix/2026-09-22-input-echo-admission-ownership.zh.md b/.agents/notes/implemented/bug-fix/2026-09-22-input-echo-admission-ownership.zh.md new file mode 100644 index 0000000000..1d64de220a --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-22-input-echo-admission-ownership.zh.md @@ -0,0 +1,54 @@ +# Agent Note: 输入回显在入档期间保持展示归属 + +Status: implemented + +[English](2026-09-22-input-echo-admission-ownership.md) | 中文 + +## 问题 + +空闲时提交的输入可能先显示为 Chat 本地回显,再变为 QueueDock 的 Inbox 行,最后才显示为持久用户消息。Inbox 投影与会话历史独立到达:受理时回收回显会在入档前留下空档,迟到的 Inbox 投影又可能重复显示已经入档的消息。此外,turn/start 先于 user/message,因此空过程标题可能先出现在回显前面,真实输入到达时再与它交换位置。 + +这些转换涉及展示归属,而非滚动或 composer 几何。延迟不能判断哪种表示已经成为权威,将所有回显移到过程标题前又会把运行中的 steering 移到已有工作之前。 + +## 决策 + +- 提交保留本地发送时选择的 placement:空闲输入留在 Chat,运行中的 steering 留在过程末尾,明确排队的输入留在 QueueDock。Inbox 受理不改变展示归属。 +- Chat 在同一次渲染中按持久输入的 rpcId 隐藏回显,并排除匹配已入档本地 Chat 提交的旧 next-step Inbox 行。QueueDock 只排除匹配本地 transcript 回显的 Inbox 行;其他排队消息仍可见且可操作。 +- Session 保留已入档 transcript 或 steering 提交的本地排除身份,直到 Inbox 投影水位达到其领取序号。随后,较高序号投影优先的规则阻止旧受理行在该身份回收后重新出现。领取跟踪和取消共用已有逐提交 receipt,并覆盖两种 Inbox target;不新增 SessionSnapshot 字段、Host 事件或持久格式。 +- Chat 末尾为进行中且没有真实输入的过程控制行时,第一条 transcript 回显显示在控制行前,其他回显留在末尾。最后输入的轮次信息复用现有 rpcId 扫描,不新增历史遍历,也不预测未来轮次序列。 +- 待处理气泡与持久节点容器共用一个 keyed React 列表。插入过程标题保留回显气泡的挂载身份。入档后,由持久节点决定排序及普通 Group 切分。 + +[持久 Inbox 恢复决策](2026-08-17-durable-web-queue-recovery.zh.md)仍负责冷读取、重建与投影传输。[通用文件上传决策](../feature/2026-08-26-generic-file-upload.zh.md)仍负责附件准备及草稿恢复;回收按附件顺序返回各提交的持久引用,且只回调一次。 + +## 考虑过的替代方案 + +**固定延迟 QueueDock 或回显回收。** CPU 降速、传输批处理与投影到达顺序都可能超过该间隔。时间不能证明入档,也不能确认过期队列状态已经消失。 + +**只延长回显生命周期,不调整其他读取方。** Chat 侧按 Inbox 去重仍会移除回显,QueueDock 仍可能展示同一消息。Session 生命周期、Chat 接管和 Dock 排除必须一致。 + +**user/message 到达后立即删除排除身份。** 较晚的 Inbox 受理投影会复活 Dock 行或待处理 steering 行。领取水位提供必要的排序依据,不要求两条流同时发布。 + +**把全部待处理输入移到最新 Turn 控制行前。** 运行中的 steering 属于已有过程内容之后,多条普通提交也可能属于不同轮次。只有第一条本地 transcript 回显参与空控制行前的定位调整。 + +**新增 Host 关联事件或客户端 Turn 调度器。** 精确预测跨客户端竞争和请求乱序会超出本地乐观展示的范围。持久入档仍是权威;这些竞争场景中的乐观排序有意保持近似。 + +## 验证 + +| 证据 | 覆盖行为 | +|---|---| +| [Session 提交](../../../../packages/api/session-controller/tests/session-pending-submissions.client.spec.ts) | 投影先到与历史先到的接管、领取水位、开场/排队/steering 身份交错、附件回调、取消、未入档结束、重连基线及销毁。 | +| [投影存储](../../../../packages/api/session-controller/tests/projection-store.client.spec.ts) | 缺失或缓存水位、单调序号保留及清理。 | +| [Chat 渲染](../../../../packages/client/ui-chat/tests/chat-view.client.spec.tsx) | 同帧 rpcId 替换、回显稳定挂载、三种模式下标题先于输入到达、steering 留在标题之后、历史空轮次及六种本地与 Host 排列。 | +| [QueueDock](../../../../packages/client/ui-conversation/tests/queue-dock.client.spec.tsx) | 本地 transcript 排除、其他行及操作、三条消息的全部六种受理顺序及后续 FIFO 领取。 | +| [录制 Web 回放](../../../../apps/web/tests/idle-submission-handoff.e2e.ts) | 真实 Web 装配配合保留 FIFO 的投递屏障:6× CPU 降速下的 Inbox/历史两种到达顺序、明确的 turn/start 至 user/message 空档,以及六种请求排列的完整三轮接管。 | +| [Steering Web 回放](../../../../apps/web/tests/steering.e2e.ts) | 本地快捷键 steering 的持久入档先于 Inbox 领取投影到达时,仍只显示一份。 | + +单条输入回归在修复前失败:Inbox 受理将气泡移入 Dock,单独交付的 Turn 标题又会出现在回显之前。修复后的路径保留唯一的 Chat 表示,且纵向位置稳定。多输入回放验证持久顺序及无重复、无残留回显,不断言乐观排列可以预测 Host 顺序。 + +## 后果 + +- 没有本地提交身份的 steering 仍按 Inbox 投影显示;投影迟到时可能短暂与持久节点重复。本地去重不为其他客户端的提交增加历史扫描。 +- 重连可撤销已有接收回执的乐观回显,并展示新的权威基线。已领取但不在该基线中的输入可能短暂无气泡;重连不保证 DOM 连续性。 +- 本地 running 更新前连续提交的消息可能都留为 transcript 回显。若本地按 A、B、C 提交,但 B 或 C 先入档,持久接管会调整可见顺序。即使 A 先入档,其余本地回显也可能与 Host 队列顺序不同。 +- 跨客户端竞争和请求准备的先后差异可能改变实际开场输入。客户端不会仅因 Host 将输入排在另一轮后面,就把已按空闲分类的回显迁入 Dock。 +- 这些改动消除普通在线路径中表示切换带来的抖动,不改变模型输入、Inbox 调度、滚动跟随规则或已发布 Session 数据。浏览器屏障证明行为,不代表普遍的延迟或吞吐提升。 diff --git a/.agents/notes/implemented/bug-fix/2026-09-22-windows-open-through-shell-resolution.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-09-22-windows-open-through-shell-resolution.i18n.yaml new file mode 100644 index 0000000000..420cb47036 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-22-windows-open-through-shell-resolution.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-22-windows-open-through-shell-resolution.md +2026-09-22-windows-open-through-shell-resolution.md: 7bfe559e7f26b96fa37d3bbed44f0403d4e51b59 +2026-09-22-windows-open-through-shell-resolution.zh.md: ff33455da1653f9d24f9ddcc21c1594e8875bb5e diff --git a/.agents/notes/implemented/bug-fix/2026-09-22-windows-open-through-shell-resolution.md b/.agents/notes/implemented/bug-fix/2026-09-22-windows-open-through-shell-resolution.md new file mode 100644 index 0000000000..7bfe559e7f --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-22-windows-open-through-shell-resolution.md @@ -0,0 +1,43 @@ +# Agent Note: Windows opens go through explorer.exe + +Status: implemented + +English | [中文](2026-09-22-windows-open-through-shell-resolution.zh.md) + +## Problem + +The Windows desktop client's settings sheet offers **Open configuration file**, which prepares the active profile's `cordis.patch.yml` and hands the path to the native text-document opener (issue #4428). On the reporting machine the gesture opened nothing and reported nothing. The opener ran `powershell.exe -NoProfile -Command "Invoke-Item -LiteralPath ''"`, so an association resolved inside the host process decided the outcome, and `Invoke-Item` exits 0 when that resolution finds no application. + +That machine records the `.yml` default only in `HKCU\Software\Microsoft\Windows\CurrentVersion\Explorer\FileExts\.yml\UserChoiceLatest` (progid `VSCode.yml`), with no `UserChoice` record and no `HKCU\Software\Classes\.yml` default. Explorer opens VS Code on a double-click of the same file. Run from the host process, `Invoke-Item`, `cmd /c start`, the `Shell.Application` default verb, and `AssocQueryString` all reported no application for that extension, and `cmd /c start` displayed the shell's own application chooser instead of opening one. `Invoke-Item` still exited 0, so `SettingsController.openSettingsDocument` answered `{ opened: true }` and the sheet rendered no error. + +## Decision + +`openWindowsPath` hands the path to `explorer.exe` as one argv element, and `revealNativePath` reaches the same shell through the shared `runExplorer` helper. Both encode the target as a file URI with its commas and equals signs percent-encoded, because Explorer parses its own command line and splits fields at both — a raw path containing either opens a different target without reporting it. Nothing else is escaped: Explorer rejects percent-encoded non-ASCII in a file URI and falls back to Documents, so those characters reach it literally. Explorer performs the default-application resolution a double-click performs, so the shell's answer — including a default recorded only in the newer per-user record — selects the application, and a machine with no handler at all gets Explorer's own "How do you want to open this file?" chooser instead of silence. + +`runExplorer` holds the one Explorer invocation rule for both operations: exit code 1 is the handoff Explorer returns after passing the request to the desktop process already running, and it resolves the caller's promise; every other failure and every cancellation still rejects. The helper replaced the PowerShell command string, and with it `powershellLiteral` and its single-quote doubling: a path crosses the process boundary as an argv element, which removes the shell-layer escaping rather than the encoding Explorer's own parser requires. `explorerTarget` owns that one encoding step for both intents. + +Windows still names no browser for HTML and SVG, so `openInBrowser` continues to decline the platform and the default, association, and text-editor intents all reach the same Explorer handoff. + +## Alternatives considered + +**Keep `Invoke-Item`.** Rejected. It asks a different resolver than the shell does, so a user whose default Explorer honors can still get a silent no-op, and its zero exit code removes the error the sheet would otherwise render. + +**Resolve the default inside the host process.** Rejected. Reading `UserChoiceLatest`, then `UserChoice`, then the classes default, and launching the winning progid's `shell\open\command` reimplements an order Windows owns, including the `UserChoice` hash Windows validates; the shell's own answer is the definition of the application a double-click uses. + +**`cmd /c start `.** Rejected. It needs a command shell for the same resolution Explorer already performs, and it reported no application on the machine that reproduced the defect. + +**Fall back to a fixed editor when no application resolves.** Rejected. A Windows installation always ships a usable editor, but choosing it in the opener would override the user's association and turn a missing association into a silently wrong application. + +## Consequences + +The settings sheet's open gesture and the artifact and workspace opens share one Windows behavior: the application a double-click would launch. A machine whose recorded default Explorer honors but `AssocQueryString` does not — the reported state — now opens the configured editor. + +The opener can no longer distinguish "the shell opened it" from "the shell declined": Explorer reports 0 or 1 for both, so the Remote still answers `{ opened: true }`, and a machine with no handler shows the shell's chooser as its only feedback. Nothing here verifies that a window appeared. + +An interactive desktop session is a precondition. In a non-interactive Windows session (a service, or a scheduled task without an interactive logon) `explorer.exe` invokes no association and still exits 1, so that exit code does not tell this case apart from a delegated open: an SSH session 0 on Windows 11 ARM64 build 26200 measured exit code 1 after about a second, where an `Invoke-Item` command resolved the association in-process. A Host started that way loses the capability and pays the wait, and a caller's abort signal is what bounds it. No fallback for that session is implemented. + +Directories take the same path. The rejected alternative recorded in [the open-in-app note](../feature/2026-08-25-promote-open-anywhere-plugin.md) was a detached, credential-scrubbed `explorer.exe ` spawn, which is not what this handoff does: the `explorer.exe` child inherits the host environment and no detached process is created, while the application or folder window Explorer delegates to starts from the desktop session's environment rather than the host's. + +## Verification + +`path-opener.spec.ts` pins the encoded file URI Explorer receives for both intents, the accepted delegate exit code 1 for an open and a reveal, and the preserved failure and cancellation. `resolver.spec.ts` pins the open-in-app route's Windows command. Native desktop verification stays on Windows, per the package's test policy. On Windows 11 ARM64 build 26200 an independent pass drove the module's own `openNativeAssociatedPath`, `openNativePath`, and `openNativeTextFile` from a sha256-matched copy beside an association probe that recorded every argument the shell passed: each comma-bearing target arrived complete as `%2C`, a plain file opened, and a directory named for a comma opened as a window rooted there. The negative control — the same file as a file URI whose comma is left raw — invoked no association and opened the user's Documents folder, and still exited 1. Exit 1 therefore records the delegation rather than the outcome, so accepting it cannot turn a wrong target into a success; the encoding is what keeps the path whole. Nothing here observes a window. A later pass on the same build read the shell's own window and selection state instead of pixels: the encoded `/select,` target selected the named file; an unencoded `=` opened Documents instead of the file; and a target whose non-ASCII characters stay percent-encoded opened Documents where the literal characters opened the file. diff --git a/.agents/notes/implemented/bug-fix/2026-09-22-windows-open-through-shell-resolution.zh.md b/.agents/notes/implemented/bug-fix/2026-09-22-windows-open-through-shell-resolution.zh.md new file mode 100644 index 0000000000..ff33455da1 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-22-windows-open-through-shell-resolution.zh.md @@ -0,0 +1,43 @@ +# Agent Note: Windows 打开经 explorer.exe 完成 + +Status: implemented + +[English](2026-09-22-windows-open-through-shell-resolution.md) | 中文 + +## 问题 + +Windows 桌面客户端设置页的「打开配置文件」会准备当前 profile 的 `cordis.patch.yml`,并把该路径交给原生文本编辑器打开器(issue #4428)。在报告者机器上,这个动作既没有打开窗口,也没有任何提示。打开器执行的是 `powershell.exe -NoProfile -Command "Invoke-Item -LiteralPath ''"`,因此由宿主进程内的关联解析决定结果,而 `Invoke-Item` 在解析不到应用时同样以退出码 0 结束。 + +该机器的 `.yml` 默认值只记录在 `HKCU\Software\Microsoft\Windows\CurrentVersion\Explorer\FileExts\.yml\UserChoiceLatest`(progid 为 `VSCode.yml`),既没有 `UserChoice` 记录,也没有 `HKCU\Software\Classes\.yml` 默认值。对同一个文件双击,资源管理器会打开 VS Code。而在宿主进程中执行时,`Invoke-Item`、`cmd /c start`、`Shell.Application` 的默认动词和 `AssocQueryString` 都报告该扩展名没有应用,`cmd /c start` 则弹出 shell 自己的应用选择框而不是打开某个应用。`Invoke-Item` 仍然返回 0,于是 `SettingsController.openSettingsDocument` 回答 `{ opened: true }`,设置页不渲染任何错误。 + +## 决策 + +`openWindowsPath` 将路径作为**一个** argv 元素交给 `explorer.exe`,`revealNativePath` 经共享的 `runExplorer` 帮助函数走到同一个 shell。两者都把目标编码成文件 URI 并对**逗号与等号**做百分号编码,因为资源管理器自己解析命令行并以这两个字符分段——含其中任一字符的原始路径都会打开另一个目标且不作任何提示。其余字符不做转义:资源管理器会拒绝 file URI 里百分号编码的非 ASCII 并回落到「文档」,因此这些字符以字面形式交给它。资源管理器执行的是双击所执行的默认应用解析,因此由 shell 的答案选择应用——包括只记录在较新的按用户记录里的默认值;而完全没有处理程序的机器会得到资源管理器自己的「你要如何打开这个文件?」选择框,而不是毫无反应。 + +`runExplorer` 为两种操作持有唯一一条 Explorer 调用规则:退出码 1 是 Explorer 把请求转交给已在运行的桌面进程后返回的交接码,它照常兑现调用方的 Promise;其余失败和取消仍然报错。该帮助函数取代了 PowerShell 命令字符串,同时删掉了 `powershellLiteral` 及其单引号翻倍逻辑:路径以 argv 元素跨进程边界,去掉的是 shell 层转义,而不是资源管理器自身解析所需的编码;该编码由 `explorerTarget` 为两种意图统一持有。 + +Windows 对 HTML 与 SVG 仍然无法命名浏览器,因此 `openInBrowser` 继续拒绝该平台,默认意图、关联意图与文本编辑器意图都走同一个 Explorer 交接。 + +## 考虑过的替代方案 + +**继续使用 `Invoke-Item`。** 已否决。它询问的解析器与 shell 不同,因此即使某个用户的默认值能被资源管理器识别,仍会得到静默的无效操作;而且它的零退出码正好抹掉了设置页本应渲染的错误。 + +**在宿主进程内自行解析默认值。** 已否决。依次读取 `UserChoiceLatest`、`UserChoice` 和 classes 默认值,再启动胜出 progid 的 `shell\open\command`,等于重新实现一套 Windows 自己拥有的顺序,其中还包括 Windows 会校验的 `UserChoice` 哈希;而双击所用应用的定义就是 shell 自己的答案。 + +**使用 `cmd /c start `。** 已否决。它为资源管理器本已执行的同一套解析引入一个命令 shell,并且在复现该缺陷的机器上同样报告没有应用。 + +**解析不到应用时回退到固定编辑器。** 已否决。Windows 安装总是带有可用的编辑器,但在打开器里选择它等于覆盖用户的关联,并把「缺少关联」变成「静默地打开错误应用」。 + +## 后果 + +设置页的打开动作与产物、工作区的打开动作共享同一个 Windows 行为:双击会启动的那个应用。对于「资源管理器认、`AssocQueryString` 不认」这一被报告的状态,现在会打开已配置的编辑器。 + +打开器再也无法区分「shell 打开了」与「shell 拒绝了」:两种情况 Explorer 都返回 0 或 1,因此 Remote 依旧回答 `{ opened: true }`,完全没有处理程序的机器只能看到 shell 的选择框。此处不校验窗口是否真的出现。 + +交互桌面会话现在是前提。在非交互 Windows 会话(服务,或没有交互登录的计划任务)中,`explorer.exe` 不调用任何关联,且同样以退出码 1 返回,因此该退出码无法与「已委派」区分:Windows 11 ARM64 build 26200 的 SSH session 0 实测约 1 秒后返回退出码 1,而同一会话里 `Invoke-Item` 命令能在进程内解析关联。这样启动的 Host 会失去能力且要付出等待,只有调用方的 abort 信号能给这段等待封顶。该会话下的兜底尚未实现。 + +目录走同一条路径。[open-in-app 记录](../feature/2026-08-25-promote-open-anywhere-plugin.zh.md)中被否决的方案是 detached 且经过凭据清洗的 `explorer.exe ` 派生进程,那不是本交接的做法:`explorer.exe` 子进程继承宿主环境,也不创建 detached 进程;而它转交给桌面会话启动的应用或文件夹窗口,继承的是桌面会话环境而不是宿主进程环境。 + +## 验证 + +`path-opener.spec.ts` 固定两种意图交给 Explorer 的编码文件 URI、打开与选中都可接受的交接退出码 1,以及仍然报错的普通失败与取消。`resolver.spec.ts` 固定 open-in-app 路由在 Windows 上的命令。按该包测试策略,原生桌面验证留在 Windows。在 Windows 11 ARM64 build 26200 上,一次独立验证用 sha256 与源码一致的副本直接驱动模块自身的 `openNativeAssociatedPath`、`openNativePath`、`openNativeTextFile`,旁边挂一个记录 shell 实收参数的探针关联:每个含逗号的目标都以 `%2C` 完整到达、普通文件正常打开、名为 `dir,comma` 的目录打开为根目录正确的窗口。反证——同一个文件以逗号未编码的 file URI 交给 `explorer.exe`——没有触发任何关联、打开了用户的「文档」,而且**同样返回退出码 1**。因此退出码 1 记录的是委派而非结果,接受它不会把错误目标变成成功;真正保住路径完整的是那段编码。此处不观测窗口是否出现。同一 build 的后续一轮改为直接读取 shell 自己的窗口与选中状态:编码后的 `/select,` 目标选中了目标文件;未编码的 `=` 会打开「文档」而不是文件;非 ASCII 字符保持百分号编码时同样打开「文档」,改为字面字符则正常打开。 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/.agents/notes/implemented/feature/2026-08-05-agent-teams.i18n.yaml b/.agents/notes/implemented/feature/2026-08-05-agent-teams.i18n.yaml index a4f2c8968a..4eb9df8cb0 100644 --- a/.agents/notes/implemented/feature/2026-08-05-agent-teams.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-05-agent-teams.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-05-agent-teams.md -2026-08-05-agent-teams.md: 45c7e599e03bb26dc9224c74a6d3bc0ab8a74dcc -2026-08-05-agent-teams.zh.md: dbb2931bdc40da5ddf2c8091dd8c401029e0445f +2026-08-05-agent-teams.md: 6333e511ba1e71823cfa55c27ba46b2665d1ef15 +2026-08-05-agent-teams.zh.md: 3fed1302146406f71d63c7ff2df8f031a91c9804 diff --git a/.agents/notes/implemented/feature/2026-08-05-agent-teams.md b/.agents/notes/implemented/feature/2026-08-05-agent-teams.md index 45c7e599e0..6333e511ba 100644 --- a/.agents/notes/implemented/feature/2026-08-05-agent-teams.md +++ b/.agents/notes/implemented/feature/2026-08-05-agent-teams.md @@ -54,6 +54,14 @@ All members use the same cwd and observe writes immediately. The policy tells me Worktree isolation is not a harness runtime behavior. A deployment or prompt may arrange separate worktrees, but the Team domain does not infer branches, merge changes, or silently change cwd. This preserves the existing same-world subagent and sandbox contracts. +## Web projection + +The Web panel reads the Lead Session's `agentTeam` wire projection because the existing projection stream keeps browser state current without a separate Team read API. Both Lead and teammate conversations select values from the shared cross-Session store. The panel owns no projection reads: Session loading, cached list values, and control baselines supply the data. + +The wire view carries durable roster phases, non-deleted task views with shared readiness and overlap derivation, and `failure` beside the last valid state. Live activity comes from Session status. Model selection has its own durable projection; the panel displays the shared store's available values without opening member conversations. A missing cached value remains absent until Session loading or a live update supplies it. + +Publication requires `apply` to return a new state object per applied Team event and replace only the touched collection. Mailbox-only events retain the view reference and publish nothing. The existing carrier sends each changed complete view to every connected browser; the package README records that cost. + ## Alternatives considered **Extend direct-child subagent tools with peer ids.** Rejected because parent/child authority and Team peer membership are different domains. Adding peer access to the continuation seam would weaken its exact-parent authorization and still leave roster and tasks without a persistence owner. diff --git a/.agents/notes/implemented/feature/2026-08-05-agent-teams.zh.md b/.agents/notes/implemented/feature/2026-08-05-agent-teams.zh.md index dbb2931bdc..3fed130214 100644 --- a/.agents/notes/implemented/feature/2026-08-05-agent-teams.zh.md +++ b/.agents/notes/implemented/feature/2026-08-05-agent-teams.zh.md @@ -54,6 +54,14 @@ Peer 通讯使用 Lead 日志 mailbox。投递前先追加并 flush `team/messag Worktree isolation 不是 harness runtime 行为。deployment 或 prompt 可以安排独立 worktree,但 Team 领域不会推断 branch、merge 变更或静默改变 cwd。这样保留既有 same-world subagent 与 sandbox 契约。 +## Web projection + +Web panel 读取 Lead Session 的 `agentTeam` 传输投影,因为现有投影流能持续更新浏览器状态,无需独立的 Team 读取 API。Lead 与 teammate 会话均从共享的跨 Session store 选择值。面板不负责投影读取:Session 加载、列表缓存值与控制基线提供数据。 + +传输视图携带持久 roster phase、使用共享就绪与重叠派生逻辑的未删除任务视图,以及最后有效状态旁的 `failure`。实时活动来自 Session 状态。模型选择有自己的持久投影;面板展示共享 store 中可用的值,不打开成员会话。缺少的缓存值保持缺失,直到 Session 加载或实时更新提供它。 + +发布要求 `apply` 对每个已应用的 Team 事件返回新的 state 对象,并只替换被触及的集合。仅邮箱的事件保留视图引用,不发布 frame。现有传输层将每个变化后的完整视图发送给所有已连接浏览器;包 README 记录了这项开销。 + ## Alternatives considered **用 peer id 扩展 direct-child subagent tool。** 拒绝,因为 parent/child 权限与 Team peer membership 是不同领域。向 continuation seam 增加 peer access 会削弱 exact-parent authorization,仍无法为 roster 与 task 提供持久 owner。 diff --git a/.agents/notes/implemented/feature/2026-08-25-promote-open-anywhere-plugin.i18n.yaml b/.agents/notes/implemented/feature/2026-08-25-promote-open-anywhere-plugin.i18n.yaml index 2f5a36785b..a806e17e4e 100644 --- a/.agents/notes/implemented/feature/2026-08-25-promote-open-anywhere-plugin.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-25-promote-open-anywhere-plugin.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-25-promote-open-anywhere-plugin.md -2026-08-25-promote-open-anywhere-plugin.md: 83888cb548046cd8e023cd2b7c87f123cda0ed0f -2026-08-25-promote-open-anywhere-plugin.zh.md: 4ef476ce7043d2dcee05dec9f604849741ce5e96 +2026-08-25-promote-open-anywhere-plugin.md: e5a435d24c2400b68fad8cccb094b176ad83f590 +2026-08-25-promote-open-anywhere-plugin.zh.md: 108091f3e7c2736063d67bc6b2d7fe0c039b35db diff --git a/.agents/notes/implemented/feature/2026-08-25-promote-open-anywhere-plugin.md b/.agents/notes/implemented/feature/2026-08-25-promote-open-anywhere-plugin.md index 83888cb548..e5a435d24c 100644 --- a/.agents/notes/implemented/feature/2026-08-25-promote-open-anywhere-plugin.md +++ b/.agents/notes/implemented/feature/2026-08-25-promote-open-anywhere-plugin.md @@ -21,7 +21,7 @@ The feature's first-party owners are `@deepseek-ai/dsh-host-open-in-app` at `pac - **Resolution sources are platform-honest and cheap.** macOS checks the known application directories (`/Applications`, `~/Applications`) for the entry's bundle spellings and launches `open -a `; Xcode follows `xcode-select -p`. Windows reads `App Paths`, then Uninstall records kept only when they prove an executable on disk, then known paths and the newest versioned install directory where an application uses one — one batched `reg.exe query` per root per pass. GitHub Desktop resolves its versioned executable together with the packaged `cli.js` and invokes the supported `github open ` behavior without a command shell. CLI names resolve in-process through `ctx.subprocess.resolveExecutable()` (PATH/PATHEXT stat, no shell, no `which`/`where.exe` children); Linux GUI entries whose CLI is off PATH fall back to their XDG desktop entry's verified `TryExec`/`Exec`, while `xdg-open` is offered only when the host announces a display server. The remaining host commands (`xcode-select`, `reg.exe`, icon extraction) run through `@deepseek-ai/dsh-native-command` (argv, never a shell). - **Three independent deadlines replace the single `commandTimeoutMs`**: `probeTimeoutMs` (resolution commands), `iconTimeoutMs` (icon extraction commands), and `launchWatchMs` (the early-failure watch window), so tuning one operation never changes another's response time — the shipped bundle keeps conservative 10 s command deadlines (timeouts are failure bounds, not latency budgets) with a 1 s watch window, which is what bounds how long the open route and the button's busy dress hold a successful launch. Launches spawn detached with a credential-scrubbed environment (`scrubbedParentEnv` from `dsh-subprocess`) plus explicit adapter entries; Windows GUI launchers remain visible unless an adapter explicitly hides its CLI process because that process opens the GUI separately. A launcher still running when the watch window closes counts as launched and is never killed or awaited (kitty and the JetBrains IDEs stay in the foreground for their window lifetime). - **Icons extract on every platform with a host source.** macOS converts the resolved bundle's `.icns` to a 128px PNG (`plutil` + `sips`); Windows extracts the resolved executable's associated icon as a 32px PNG through a generated PowerShell script run with positional `-File` args (no command-line path parsing); Linux follows the spec's desktop entry `Icon=` into the hicolor theme and pixmaps directories (PNG or SVG, filesystem only). Any failure answers 404 and the browser keeps its generic glyph. -- **The launch catalog is a data table with per-platform entries** (`OPEN_IN_APP_CATALOG`): each application id declares, per platform (`darwin`/`win32`/`linux`), a chain of launcher sources tried in order — `fixed` (ships with the OS), `app` (known-directory bundle spellings), `xcode` (`xcode-select -p`), `cli` (in-process PATH resolution), `file` (first existing candidate under `${VAR}`/`~/` expansion), `scan` (newest versioned install directory), `app-paths` and `install-record` (the Windows registry tiers), `github-desktop` (the versioned executable plus packaged CLI), and `desktop` (Linux XDG desktop entries) — plus a launcher argv template (a `{path}` token carries the directory in place, otherwise it is appended), optional environment and Windows visibility policy, and an optional fallback launcher (Xcode's `open -a ` behind `xed`). The whitelist follows Codex's "Open In" target list: editors and IDEs (VS Code, VS Code Insiders, Cursor, Windsurf, Zed, Sublime Text, Xcode, Android Studio, seven JetBrains IDEs), the promoted plugin's Git GUIs, terminals (Terminal, iTerm2, Ghostty, Warp, kitty, Windows Terminal, Git Bash, GNOME Terminal, Konsole), and per-platform file managers. File managers and platform terminals are separate ids (`finder`/`explorer`/`filemanager`) rather than one id with per-platform labels, because labels are static browser dictionary entries and only one of them probes as available per host. The file-manager entries launch through `dsh-native-command`'s path opener itself (`shell-open`, the OS shell's open verb with the full parent environment, not a detached scrubbed spawn), because a direct `explorer.exe ` spawn does not reliably raise a window. Closed unions end in `assertNever`. +- **The launch catalog is a data table with per-platform entries** (`OPEN_IN_APP_CATALOG`): each application id declares, per platform (`darwin`/`win32`/`linux`), a chain of launcher sources tried in order — `fixed` (ships with the OS), `app` (known-directory bundle spellings), `xcode` (`xcode-select -p`), `cli` (in-process PATH resolution), `file` (first existing candidate under `${VAR}`/`~/` expansion), `scan` (newest versioned install directory), `app-paths` and `install-record` (the Windows registry tiers), `github-desktop` (the versioned executable plus packaged CLI), and `desktop` (Linux XDG desktop entries) — plus a launcher argv template (a `{path}` token carries the directory in place, otherwise it is appended), optional environment and Windows visibility policy, and an optional fallback launcher (Xcode's `open -a ` behind `xed`). The whitelist follows Codex's "Open In" target list: editors and IDEs (VS Code, VS Code Insiders, Cursor, Windsurf, Zed, Sublime Text, Xcode, Android Studio, seven JetBrains IDEs), the promoted plugin's Git GUIs, terminals (Terminal, iTerm2, Ghostty, Warp, kitty, Windows Terminal, Git Bash, GNOME Terminal, Konsole), and per-platform file managers. File managers and platform terminals are separate ids (`finder`/`explorer`/`filemanager`) rather than one id with per-platform labels, because labels are static browser dictionary entries and only one of them probes as available per host. The file-manager entries launch through `dsh-native-command`'s path opener itself (`shell-open`, which hands the directory to the OS shell with the full parent environment, not a detached scrubbed spawn), because a detached, credential-scrubbed `explorer.exe ` spawn does not reliably raise a window; the opener's own `explorer.exe` handoff does ([Windows open resolution](../bug-fix/2026-09-22-windows-open-through-shell-resolution.md)). Closed unions end in `assertNever`. - **Every route runs behind the composition connection service's trust fence** (`requestRejection`: the Host/Origin fence defeating DNS rebinding and cross-site calls, plus browser authentication), the same guard the API gateway applies to its WebSocket upgrade; the mechanism's one home is the `src/index.ts` module comment. On top of that fence the open route validates the body at the wire: an `application/json` media type (exact essence, not a substring match), a 64 KiB bound with a drained 413, string field types, only probed-available catalog ids, and an absolute path naming an existing directory. - **The DSH-version gate is deleted.** It existed because the plugin rode release-to-release against an interface it did not own; a first-party package is versioned with the repository, so the gate, its `sessionStorage`/`localStorage` trust ledger, and the argv-walking version probe have no referent. - **The last choice persists through `createSnapshotStore(..., { persist })`** (`dsh.open-in-app.choice`), replacing hand-rolled `localStorage` access. A fresh store has no platform-specific choice; the component uses the first available host entry until the user chooses one. diff --git a/.agents/notes/implemented/feature/2026-08-25-promote-open-anywhere-plugin.zh.md b/.agents/notes/implemented/feature/2026-08-25-promote-open-anywhere-plugin.zh.md index 4ef476ce70..108091f3e7 100644 --- a/.agents/notes/implemented/feature/2026-08-25-promote-open-anywhere-plugin.zh.md +++ b/.agents/notes/implemented/feature/2026-08-25-promote-open-anywhere-plugin.zh.md @@ -21,7 +21,7 @@ Status: implemented - **解析来源按平台务实且廉价。** macOS 在已知应用目录(`/Applications`、`~/Applications`)查条目的 bundle 拼写,启动 `open -a <解析出的 bundle>`;Xcode 跟随 `xcode-select -p`。Windows 依次读 `App Paths`、只在能证明磁盘可执行文件时采用的 Uninstall 记录、已知路径,以及采用版本化安装目录的应用中最新的目录——每趟每根一条批量 `reg.exe query`。GitHub Desktop 会同时解析版本化可执行文件与随包提供的 `cli.js`,不经命令 shell 调用受支持的 `github open ` 行为。CLI 名称经 `ctx.subprocess.resolveExecutable()` 进程内解析(PATH/PATHEXT stat,无 shell、无 `which`/`where.exe` 子进程);CLI 不在 PATH 上的 Linux GUI 条目回退到其 XDG desktop 条目验证过的 `TryExec`/`Exec`,且只有主机声明了 display server 时才提供 `xdg-open`。其余主机命令(`xcode-select`、`reg.exe`、图标提取)经 `@deepseek-ai/dsh-native-command`(argv,绝不走 shell)执行。 - **三个独立期限取代单一 `commandTimeoutMs`**:`probeTimeoutMs`(解析命令)、`iconTimeoutMs`(图标提取命令)、`launchWatchMs`(早期失败看护窗口),调整一种操作的超时不再改变其他操作的响应时间——随发行 bundle 保守地保留 10 秒命令期限(超时是失败上界而非延迟预算),看护窗口 1 秒,它才是约束 open 路由与按钮忙碌态挂起一次成功启动时长的量。启动以清理过凭据的环境(`dsh-subprocess` 的 `scrubbedParentEnv`)叠加适配器显式环境后 detached 派生;Windows GUI launcher 默认保持可见,只有负责另行打开 GUI 的 CLI 适配器会显式隐藏自己的进程。看护窗口关闭时仍在运行的启动器计为已启动,绝不会被杀死或等待(kitty 与 JetBrains IDE 在整个窗口生命周期内保持前台)。 - **有主机来源的平台都提取图标。** macOS 把解析出的 bundle 的 `.icns` 转 128px PNG(`plutil` + `sips`);Windows 用生成的 PowerShell 脚本以位置式 `-File` 参数(路径不经命令行解析)提取解析出的可执行文件的关联图标为 32px PNG;Linux 沿 spec 的 desktop 条目 `Icon=` 查 hicolor 主题与 pixmaps 目录(PNG 或 SVG,纯文件系统)。任何失败应答 404,浏览器保持通用占位图形。 -- **启动目录是按平台声明条目的数据表**(`OPEN_IN_APP_CATALOG`):每个应用 id 按平台(`darwin`/`win32`/`linux`)声明一条按序尝试的启动器来源链——`fixed`(随系统内置)、`app`(已知目录的 bundle 拼写)、`xcode`(`xcode-select -p`)、`cli`(进程内 PATH 解析)、`file`(`${VAR}`/`~/` 展开后第一个存在的候选文件)、`scan`(带版本号安装目录取最新)、`app-paths` 与 `install-record`(Windows 注册表两层)、`github-desktop`(版本化可执行文件与随包 CLI)、`desktop`(Linux XDG desktop 条目)——加启动器 argv 模板(`{path}` token 原位携带目录,否则目录追加在末尾)、可选环境与 Windows 可见性策略,以及可选回退启动器(`xed` 之后的 `open -a `)。白名单对齐 Codex 的 "Open In" 目标列表:编辑器与 IDE(VS Code、VS Code Insiders、Cursor、Windsurf、Zed、Sublime Text、Xcode、Android Studio、七个 JetBrains IDE)、转正插件原有的 Git GUI、终端(Terminal、iTerm2、Ghostty、Warp、kitty、Windows Terminal、Git Bash、GNOME Terminal、Konsole)与各平台文件管理器。文件管理器与平台终端使用独立 id(`finder`/`explorer`/`filemanager`),而非一个 id 配平台标签,因为标签是静态浏览器词典条目,且每台主机只会探测到其中一个。文件管理器条目直接经 `dsh-native-command` 的路径打开器启动(`shell-open`,OS shell 的 open verb,携带完整父环境,而非 detached 的清理环境 spawn),因为直接 spawn `explorer.exe ` 不能可靠地弹出窗口。封闭 union 以 `assertNever` 收尾。 +- **启动目录是按平台声明条目的数据表**(`OPEN_IN_APP_CATALOG`):每个应用 id 按平台(`darwin`/`win32`/`linux`)声明一条按序尝试的启动器来源链——`fixed`(随系统内置)、`app`(已知目录的 bundle 拼写)、`xcode`(`xcode-select -p`)、`cli`(进程内 PATH 解析)、`file`(`${VAR}`/`~/` 展开后第一个存在的候选文件)、`scan`(带版本号安装目录取最新)、`app-paths` 与 `install-record`(Windows 注册表两层)、`github-desktop`(版本化可执行文件与随包 CLI)、`desktop`(Linux XDG desktop 条目)——加启动器 argv 模板(`{path}` token 原位携带目录,否则目录追加在末尾)、可选环境与 Windows 可见性策略,以及可选回退启动器(`xed` 之后的 `open -a `)。白名单对齐 Codex 的 "Open In" 目标列表:编辑器与 IDE(VS Code、VS Code Insiders、Cursor、Windsurf、Zed、Sublime Text、Xcode、Android Studio、七个 JetBrains IDE)、转正插件原有的 Git GUI、终端(Terminal、iTerm2、Ghostty、Warp、kitty、Windows Terminal、Git Bash、GNOME Terminal、Konsole)与各平台文件管理器。文件管理器与平台终端使用独立 id(`finder`/`explorer`/`filemanager`),而非一个 id 配平台标签,因为标签是静态浏览器词典条目,且每台主机只会探测到其中一个。文件管理器条目直接经 `dsh-native-command` 的路径打开器启动(`shell-open`,把目录交给 OS shell 并携带完整父环境,而非 detached 的清理环境 spawn),因为 detached 且经过凭据清洗的 `explorer.exe ` 派生进程不能可靠地弹出窗口;打开器自身的 `explorer.exe` 交接可以([Windows 打开解析](../bug-fix/2026-09-22-windows-open-through-shell-resolution.zh.md))。封闭 union 以 `assertNever` 收尾。 - **所有路由都运行在组合 connection 服务的信任栅栏之后**(`requestRejection`:挫败 DNS rebinding 与跨站调用的 Host/Origin 栅栏,加上浏览器认证),与 API gateway 施加在其 WebSocket upgrade 上的守卫相同;机制的唯一出处是 `src/index.ts` 的模块注释。在该栅栏之上,open 路由在 wire 边界校验请求体:`application/json` 媒体类型(精确 essence,而非子串匹配)、以排空后 413 的方式把 body 限制在 64 KiB、校验字段类型、只接受探测为可用的目录 id,并要求指向现存目录的绝对路径。 - **删除了 DSH 版本门禁。** 它存在是因为插件逐版本骑乘一个它不拥有的接口;第一方包与仓库同版本发布,门禁、它的 `sessionStorage`/`localStorage` 信任台账和 argv 遍历版本探测都失去了所指。 - **上次选择经 `createSnapshotStore(..., { persist })` 持久化**(`dsh.open-in-app.choice`),替代手写 `localStorage` 访问。新存储没有平台特定的初始选择;用户首次选择前,组件使用主机提供的第一个可用条目。 diff --git a/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.i18n.yaml b/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.i18n.yaml index d5f37e0569..6019a38f89 100644 --- a/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-26-generic-file-upload.md -2026-08-26-generic-file-upload.md: ddaf92a396ade59d65a81300531be9bce2aeeaba -2026-08-26-generic-file-upload.zh.md: 27a73536ec3632f40ee49a59d932406e77b5591d +2026-08-26-generic-file-upload.md: 196d048da9ffc1342cd62cb9c25674b7cecaffac +2026-08-26-generic-file-upload.zh.md: fce7cef76cf36060f55165d009c568480abb0f16 diff --git a/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.md b/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.md index ddaf92a396..196d048da9 100644 --- a/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.md +++ b/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.md @@ -52,3 +52,5 @@ Unit and integration coverage pins verbatim and streamed storage, name sanitizat ## Related The [Trajectory attachment presentation decision](../bug-fix/2026-09-15-trajectory-attachment-presentation.md) owns ledger counts and inspector attachment layouts. + +The [input echo admission decision](../bug-fix/2026-09-22-input-echo-admission-ownership.md) owns placement-specific retirement and delayed Inbox suppression for submissions with or without attachments. diff --git a/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.zh.md b/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.zh.md index 27a73536ec..fce7cef76c 100644 --- a/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.zh.md +++ b/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.zh.md @@ -52,3 +52,5 @@ Status: implemented ## 相关记录 [Trajectory 附件展示决定](../bug-fix/2026-09-15-trajectory-attachment-presentation.zh.md)负责列表计数与详情面板附件布局。 + +[输入回显入档决策](../bug-fix/2026-09-22-input-echo-admission-ownership.zh.md)负责有无附件提交的按展示归属回收和迟到 Inbox 排除。 diff --git a/.agents/notes/implemented/feature/2026-09-14-composer-selection-keyboard.i18n.yaml b/.agents/notes/implemented/feature/2026-09-14-composer-selection-keyboard.i18n.yaml index 3476259680..9c3ae26c74 100644 --- a/.agents/notes/implemented/feature/2026-09-14-composer-selection-keyboard.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-14-composer-selection-keyboard.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-14-composer-selection-keyboard.md -2026-09-14-composer-selection-keyboard.md: fb353a7072e7c2245aeeb22af9018edd9a6009f0 -2026-09-14-composer-selection-keyboard.zh.md: f51e5ca5096785ae6d059e3c8497292483490437 +2026-09-14-composer-selection-keyboard.md: 675e7239108604c51f4e9a82b98e164833ff9173 +2026-09-14-composer-selection-keyboard.zh.md: 2b296a609cdd76a407fd4ddcc5855eef2fcfa047 diff --git a/.agents/notes/implemented/feature/2026-09-14-composer-selection-keyboard.md b/.agents/notes/implemented/feature/2026-09-14-composer-selection-keyboard.md index fb353a7072..675e723910 100644 --- a/.agents/notes/implemented/feature/2026-09-14-composer-selection-keyboard.md +++ b/.agents/notes/implemented/feature/2026-09-14-composer-selection-keyboard.md @@ -20,6 +20,8 @@ The model seat's dropdown (`ModelSelect`, whose Effort row opens the reasoning-l `ModelSelect` keeps `↑`/`↓` as its focus walk over the rows of the shown pane (wrapping; a step taken while the trigger still holds focus enters at the near end). Tab settles: it activates the row the keyboard is on, and with focus still on the trigger it enters the menu at the row of the value in use instead. Escape and Shift+Tab leave a drilled pane first and otherwise close back to the trigger. A pane switch replaces the row that had focus, so each switch names where the keyboard lands: drilling focuses the row of the value in use (its checked row, or the first row when none is marked), and returning to the root pane focuses the cell that opened the pane left. Without that handoff the focus the unmounted row left on the page body sits outside the card's subtree, where its key handling never sees a keystroke. +Mouse presses on the `ModelSelect` trigger or portaled menu buttons prevent the browser’s default focus transfer: WebKit can otherwise blur the focused row and close the menu before `click`. Native `click` still owns selection and cancellation. Opening the menu or closing it through the trigger puts focus on the trigger. Selection requests return focus there before disabling the option rows, so a rejected request leaves keyboard navigation available. + `Menu`, the shared anchored dropdown behind the composer's permission seat, the preset chip, file cards, and the settings rows, carries the same pairing: while its list is open, Tab settles the focused row — from the anchor's button, Tab enters the list instead — and Escape or Shift+Tab close it and return focus to the anchor's first button. Only a keyboard already on the anchor or inside the list is intercepted, so Tab presses elsewhere on the page keep the browser's traversal even while a menu is open. The row's focus fill is the row's own indication: the browser's default ring would double it, and the fill is the same one the row shows under the pointer, so the visual language stays one per state. Selecting a row hands the keyboard back to the anchor unless the owner moved it itself — a card that focuses its own preview keeps it. The arrows, Home, and End walk the list whether or not `autoFocus` is set — that option now decides only whether opening focuses the first row — the row the keyboard is on carries the same fill a hovered row gets, and closing on Escape or Shift+Tab returns focus to the anchor whenever the menu held it. A dismissed menu stays dismissed: `InputTriggerController` records the identity of the hit the user closed — Escape or Shift+Tab, a pointer dismissal, or a settling pick — and a re-track of that same token with the same query leaves the menu closed, so restoring the caret after a dismissal (or a settled command closing the surface it opened) cannot revive it. A new query or another token re-arms it. @@ -42,7 +44,7 @@ The row contracts are unchanged. Producers already mark their current row `activ ## Verification -[Seat browser evidence](../../../../apps/web/tests/declared-reasoning.e2e.ts) drives a real Chromium against the shipped bundles: it opens the drilled level pane, walks it with `↑`, settles a level with Tab and asserts the saved effort, then reopens the pane and asserts the keyboard lands on that level rather than the top of the list before Shift+Tab walks back to the drilled cell and closes. [Seat keyboard tests](../../../../packages/client/ui-model-selection/tests/model-select.client.spec.tsx) cover the drilled-pane handoff onto the value in use (and onto the first row when no row is marked), the handoff back onto the cell, the `↑`/`↓` walk with both wraps, Tab settling a focused row and entering the menu from the trigger, Shift+Tab leaving a pane then closing, and Tab staying native while the menu is closed. [Popup view tests](../../../../packages/client/ui-commands/tests/popup-view.client.spec.tsx) cover Tab settling the parked highlight with consumption and refocus, Shift+Tab dismissing without settling, Tab consumed while rows load without leaving the card, the arrows staying distinct from the native left/right caret, and the wrap on `↑`/`↓`. [Trigger tests](../../../../packages/client/ui-input-trigger/tests/service.client.spec.ts) cover a dismissal sticking to its hit (Escape, a pointer dismissal, and a settling pick), the same hit re-tracking closed, and a changed query re-arming the menu. [Menu tests](../../../../packages/client/ui-primitives/tests/atoms.client.spec.tsx) cover the arrow walk without `autoFocus` (entering at the near end, wrapping, and stepping over a disabled row), Escape returning focus to the anchor, Tab settling the focused row, Shift+Tab closing back to the anchor, Tab entering the list from the anchor, and an unrelated Tab staying native while the list is open. [Popup controller tests](../../../../packages/client/ui-commands/tests/popup.client.spec.ts) cover the parked highlight, its top-row fallback without a marked row, the re-park inside a search retained across a retry, and the rebase to the top row on typing. The popup panel's Tab has unit coverage only: no Web scenario drives `/model` or `/permission` yet. +[Seat browser evidence](../../../../apps/web/tests/declared-reasoning.e2e.ts) drives real Chromium and WebKit against the shipped bundles: both verify native mouse selection, cancellation, focus after rejected requests, and keyboard navigation. Each opens the drilled level pane, walks it with `↑`, settles a level with Tab and asserts the saved effort, then reopens the pane and asserts the keyboard lands on that level rather than the top of the list before Shift+Tab walks back to the drilled cell and closes. [Seat keyboard tests](../../../../packages/client/ui-model-selection/tests/model-select.client.spec.tsx) cover the drilled-pane handoff onto the value in use (and onto the first row when no row is marked), the handoff back onto the cell, the `↑`/`↓` walk with both wraps, Tab settling a focused row and entering the menu from the trigger, Shift+Tab leaving a pane then closing, and Tab staying native while the menu is closed. [Popup view tests](../../../../packages/client/ui-commands/tests/popup-view.client.spec.tsx) cover Tab settling the parked highlight with consumption and refocus, Shift+Tab dismissing without settling, Tab consumed while rows load without leaving the card, the arrows staying distinct from the native left/right caret, and the wrap on `↑`/`↓`. [Trigger tests](../../../../packages/client/ui-input-trigger/tests/service.client.spec.ts) cover a dismissal sticking to its hit (Escape, a pointer dismissal, and a settling pick), the same hit re-tracking closed, and a changed query re-arming the menu. [Menu tests](../../../../packages/client/ui-primitives/tests/atoms.client.spec.tsx) cover the arrow walk without `autoFocus` (entering at the near end, wrapping, and stepping over a disabled row), Escape returning focus to the anchor, Tab settling the focused row, Shift+Tab closing back to the anchor, Tab entering the list from the anchor, and an unrelated Tab staying native while the list is open. [Popup controller tests](../../../../packages/client/ui-commands/tests/popup.client.spec.ts) cover the parked highlight, its top-row fallback without a marked row, the re-park inside a search retained across a retry, and the rebase to the top row on typing. The popup panel's Tab has unit coverage only: no Web scenario drives `/model` or `/permission` yet. ## Consequences diff --git a/.agents/notes/implemented/feature/2026-09-14-composer-selection-keyboard.zh.md b/.agents/notes/implemented/feature/2026-09-14-composer-selection-keyboard.zh.md index f51e5ca509..2b296a609c 100644 --- a/.agents/notes/implemented/feature/2026-09-14-composer-selection-keyboard.zh.md +++ b/.agents/notes/implemented/feature/2026-09-14-composer-selection-keyboard.zh.md @@ -20,6 +20,8 @@ composer 内有两个选择界面在打开期间持有焦点,两者都把 Tab `ModelSelect` 的 `↑`/`↓` 仍是在所显示面板行间移动焦点(循环;焦点仍在触发器上时从近端进入)。Tab 执行接受:它激活键盘所在的那一行;焦点仍在触发器上时,改为进入菜单并把焦点放在正在使用的那一行。Escape 与 Shift+Tab 都先退出已下钻的面板,否则关闭并回到触发器。面板切换会替换持有焦点的那一行,因此每次切换都指明键盘的落点:下钻落在正在使用的值那一行(其选中行;没有标记行时为首行),返回根面板则落在打开该面板的那个格子上。没有这一步,已卸载的行留在页面 body 上的焦点就位于卡片自身子树之外,挂在那里的按键处理永远收不到键盘事件。 +在 `ModelSelect` 触发按钮或 portal 菜单按钮上按下鼠标时,阻止浏览器的默认焦点转移:否则 WebKit 可能先让聚焦行失焦,并在 `click` 前关闭菜单。选择和取消仍由原生 `click` 决定。打开菜单或通过触发按钮关闭菜单时,焦点落在触发按钮上。选择请求在禁用选项行前将焦点交回该按钮,因此请求被拒绝后仍可使用键盘导航。 + `Menu`——权限档位、预设芯片、文件卡与设置行背后的共享锚定下拉——沿用同一组配对:列表打开期间,Tab 选定聚焦行(键盘在锚点按钮上时,Tab 改为进入列表),Escape 或 Shift+Tab 关闭并把焦点还给锚点的第一个按钮。只拦截已经位于锚点或列表内的键盘,因此页面其它位置按下的 Tab 即使在菜单打开时也仍归浏览器。行的焦点填充就是行自己的焦点提示:浏览器默认轮廓会与之叠加,而这一填充与指针悬停时用的是同一个,视觉语言因此一态一义。选定一行会把键盘还给锚点——除非拥有者自己移动了焦点(例如文件卡把自己的预览按钮设为焦点)。`↑`/`↓` 与 Home/End 无论是否设置 `autoFocus` 都在列表中走位——该选项如今只决定打开时是否聚焦首行——键盘所在的那一行使用与悬停行相同的填充(否则键盘走位看不出当前行),而 Escape 或 Shift+Tab 关闭时,只要键盘原本在菜单里就把焦点归还锚点。 被关闭的菜单保持关闭:`InputTriggerController` 记下用户关闭的那个命中(Escape/Shift+Tab、指针关闭或一次落定选定)的身份,同一 token 以同一查询重新 track 时菜单保持关闭,因此关闭后恢复光标、或已选定命令关闭它打开的界面,都无法把它召回来;新的查询或另一个 token 才会重新武装。 @@ -42,7 +44,7 @@ composer 内有两个选择界面在打开期间持有焦点,两者都把 Tab ## 验证 -[模型位浏览器证据](../../../../apps/web/tests/declared-reasoning.e2e.ts) 用真实 Chromium 驱动已发布的 bundle:打开已下钻的等级面板,用 `↑` 走位,用 Tab 选定一档并断言保存下来的推理等级,随后重新打开该面板、断言键盘落在那一档而不是列表顶部,再用 Shift+Tab 退回被下钻的格子并关闭。[模型位键盘测试](../../../../packages/client/ui-model-selection/tests/model-select.client.spec.tsx) 覆盖下钻交接落在正在使用的值上(没有标记行时落在首行)、交回给该格子、`↑`/`↓` 的双向循环走位、Tab 选定聚焦行以及从触发器进入菜单、Shift+Tab 先退层再关闭,以及菜单关闭时 Tab 保持原生。[弹窗视图测试](../../../../packages/client/ui-commands/tests/popup-view.client.spec.tsx) 覆盖 Tab 接受停放的高亮并完成消费与焦点归还、Shift+Tab 关闭且不接受、行仍在加载时 Tab 被消费且焦点不离开卡片、方向键与原生左右光标互不干扰,以及 `↑`/`↓` 的循环。[触发流水线测试](../../../../packages/client/ui-input-trigger/tests/service.client.spec.ts) 覆盖关闭粘在它自己的命中上(Escape、指针关闭、落定选定三种)、同一命中重新 track 时保持关闭,以及查询改变后菜单重新武装。[Menu 测试](../../../../packages/client/ui-primitives/tests/atoms.client.spec.tsx) 覆盖不带 `autoFocus` 的方向键走位(从近端进入、两端循环、跳过禁用行)、Escape 把焦点还给锚点、Tab 选定聚焦行、Shift+Tab 关闭并把焦点还给锚点、Tab 从锚点进入列表,以及列表打开期间无关位置的 Tab 保持原生。[弹窗控制器测试](../../../../packages/client/ui-commands/tests/popup.client.spec.ts) 覆盖高亮的停放、没有标记行时退回首行、重试时在保留的搜索内重新停放,以及输入时重定到首行。弹窗面板的 Tab 只有单元覆盖:目前还没有 Web 场景驱动 `/model` 或 `/permission`。 +[模型位浏览器证据](../../../../apps/web/tests/declared-reasoning.e2e.ts) 用真实 Chromium 与 WebKit 驱动已发布的 bundle:两者都验证原生鼠标选择、取消、请求被拒绝后的焦点以及键盘导航。每种引擎都会打开已下钻的等级面板,用 `↑` 走位,用 Tab 选定一档并断言保存下来的推理等级,随后重新打开该面板、断言键盘落在那一档而不是列表顶部,再用 Shift+Tab 退回被下钻的格子并关闭。[模型位键盘测试](../../../../packages/client/ui-model-selection/tests/model-select.client.spec.tsx) 覆盖下钻交接落在正在使用的值上(没有标记行时落在首行)、交回给该格子、`↑`/`↓` 的双向循环走位、Tab 选定聚焦行以及从触发器进入菜单、Shift+Tab 先退层再关闭,以及菜单关闭时 Tab 保持原生。[弹窗视图测试](../../../../packages/client/ui-commands/tests/popup-view.client.spec.tsx) 覆盖 Tab 接受停放的高亮并完成消费与焦点归还、Shift+Tab 关闭且不接受、行仍在加载时 Tab 被消费且焦点不离开卡片、方向键与原生左右光标互不干扰,以及 `↑`/`↓` 的循环。[触发流水线测试](../../../../packages/client/ui-input-trigger/tests/service.client.spec.ts) 覆盖关闭粘在它自己的命中上(Escape、指针关闭、落定选定三种)、同一命中重新 track 时保持关闭,以及查询改变后菜单重新武装。[Menu 测试](../../../../packages/client/ui-primitives/tests/atoms.client.spec.tsx) 覆盖不带 `autoFocus` 的方向键走位(从近端进入、两端循环、跳过禁用行)、Escape 把焦点还给锚点、Tab 选定聚焦行、Shift+Tab 关闭并把焦点还给锚点、Tab 从锚点进入列表,以及列表打开期间无关位置的 Tab 保持原生。[弹窗控制器测试](../../../../packages/client/ui-commands/tests/popup.client.spec.ts) 覆盖高亮的停放、没有标记行时退回首行、重试时在保留的搜索内重新停放,以及输入时重定到首行。弹窗面板的 Tab 只有单元覆盖:目前还没有 Web 场景驱动 `/model` 或 `/permission`。 ## 影响 diff --git a/.agents/notes/implemented/feature/2026-09-16-browser-excel-preview.i18n.yaml b/.agents/notes/implemented/feature/2026-09-16-browser-excel-preview.i18n.yaml index 85fe4f719e..5edfca99c1 100644 --- a/.agents/notes/implemented/feature/2026-09-16-browser-excel-preview.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-16-browser-excel-preview.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-16-browser-excel-preview.md -2026-09-16-browser-excel-preview.md: 15db888330b2a1a6d6d78dad46689962466d531d -2026-09-16-browser-excel-preview.zh.md: 6b3a6a617a0cd18dcd3da1682a31f26bd7e70386 +2026-09-16-browser-excel-preview.md: 8a4bbd5dc838bc79e61f9b412af43b912a8fe019 +2026-09-16-browser-excel-preview.zh.md: f04170b28fe4c6d267b12958f47a392638bfaadb diff --git a/.agents/notes/implemented/feature/2026-09-16-browser-excel-preview.md b/.agents/notes/implemented/feature/2026-09-16-browser-excel-preview.md index 15db888330..8a4bbd5dc8 100644 --- a/.agents/notes/implemented/feature/2026-09-16-browser-excel-preview.md +++ b/.agents/notes/implemented/feature/2026-09-16-browser-excel-preview.md @@ -24,7 +24,7 @@ The pinned FortuneSheet React dependency carries a [patch](../../../../patches/@ The read-only formula bar takes formulas from raw `cell.f` and writes formulas and cell text through `textContent`. FortuneSheet's formula formatter emits HTML spans containing document text, and its HTML helper passes formulas and strings starting with ``; vendored packages are rescoped ([mapping](docs/rescope.md)) and `private: true`. `@deepseek-ai/cordis` is a peerDependency (+ dev) of every harness package. +- Packages use `@deepseek-ai/dsh-`; vendor is [rescoped](docs/rescope.md) and `private: true`. Harness packages declare `@deepseek-ai/cordis` in `peerDependencies`/`devDependencies`. Workspace dependency sections use DSH `workspace:*`, vendor/native `workspace:~` ([rules](.agents/notes/implemented/process/2026-09-22-workspace-release-ranges.md)). - ESM everywhere (`"type": "module"`). Use package names across packages and `.ts` in local relative imports. Config subprocesses run built `lib/` under plain Node; source regressions use their declared launcher ([testing policy](docs/testing.md#test-subprocess-launch-modes)). The `dsh` CLI source launch runs through tsx's ESM-only hook (`node --import tsx/esm`); modules it reaches must stay ESM (no CJS-only exports) — Node's native TypeScript modes are unavailable across the engines range ([source-launch contract](.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.md)). Raw/Web `cordis.yml` bare plugins must appear in their resolver manifest's `dependencies`; `verify-cordis-config` enforces it. - **Registrations are effects**: every contribution goes through `ctx.effect()` / `ctx.on()`; a registry's `register()` returns the disposer. - **Runtime invariants assert owned relationships.** Publish `./invariant` only when independent observations can diverge. Otherwise omit its source and wiring and record why in its README; empty installers and checks of service presence, plugin metadata, effects, or fixed examples are invalid ([package invariant rules](packages/AGENTS.md)). diff --git a/apps/cli/package.json b/apps/cli/package.json index ed960a9b39..b433c2407f 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh", "description": "dsh CLI: profile launch, plugin management, and configuration inspection", - "version": "0.1.7-alpha.1", + "version": "0.1.7-alpha.2", "publishConfig": { "access": "public" }, @@ -20,127 +20,127 @@ ], "license": "MIT", "dependencies": { - "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-hmr": "workspace:^", - "@deepseek-ai/cordis-plugin-include": "workspace:^", - "@deepseek-ai/cordis-plugin-loader": "workspace:^", - "@deepseek-ai/cordis-plugin-timer": "workspace:^", - "@deepseek-ai/dsh-acp-app": "workspace:^", - "@deepseek-ai/dsh-agent-instructions": "workspace:^", - "@deepseek-ai/dsh-agent-tool-presentation": "workspace:^", - "@deepseek-ai/dsh-app-boot": "workspace:^", - "@deepseek-ai/dsh-base": "workspace:^", - "@deepseek-ai/dsh-client-ui-agent-preset": "workspace:^", - "@deepseek-ai/dsh-client-ui-cordis": "workspace:^", - "@deepseek-ai/dsh-cmdline": "workspace:^", - "@deepseek-ai/dsh-command-compact": "workspace:^", - "@deepseek-ai/dsh-command-goal": "workspace:^", - "@deepseek-ai/dsh-compaction-basic": "workspace:^", - "@deepseek-ai/dsh-compaction-tool-result-pruner": "workspace:^", - "@deepseek-ai/dsh-cordis-client-runner": "workspace:^", - "@deepseek-ai/dsh-experimental-agent-team-profile": "workspace:^", - "@deepseek-ai/dsh-fs-local": "workspace:^", - "@deepseek-ai/dsh-goal": "workspace:^", - "@deepseek-ai/dsh-goal-round-driver": "workspace:^", - "@deepseek-ai/dsh-headless": "workspace:^", - "@deepseek-ai/dsh-home-paths": "workspace:^", - "@deepseek-ai/dsh-hooks-claude-code": "workspace:^", - "@deepseek-ai/dsh-hooks-codex": "workspace:^", - "@deepseek-ai/dsh-jobs-local": "workspace:^", - "@deepseek-ai/dsh-launch-environment": "workspace:^", - "@deepseek-ai/dsh-mcp-client": "workspace:^", - "@deepseek-ai/dsh-persona": "workspace:^", - "@deepseek-ai/dsh-plan-mode": "workspace:^", - "@deepseek-ai/dsh-pwsh-local": "workspace:^", - "@deepseek-ai/dsh-pwsh-sandbox": "workspace:^", - "@deepseek-ai/dsh-schedule": "workspace:^", - "@deepseek-ai/dsh-sdk-app": "workspace:^", - "@deepseek-ai/dsh-sdk-minimal": "workspace:^", - "@deepseek-ai/dsh-session-projection": "workspace:^", - "@deepseek-ai/dsh-session-reference": "workspace:^", - "@deepseek-ai/dsh-skill": "workspace:^", - "@deepseek-ai/dsh-skill-filesystem": "workspace:^", - "@deepseek-ai/dsh-skill-office": "workspace:^", - "@deepseek-ai/dsh-tool-workspace-dependencies": "workspace:^", - "@deepseek-ai/dsh-terminal": "workspace:^", - "@deepseek-ai/dsh-terminal-bash": "workspace:^", - "@deepseek-ai/dsh-time-context": "workspace:^", - "@deepseek-ai/dsh-tmux-context": "workspace:^", - "@deepseek-ai/dsh-token-meter": "workspace:^", - "@deepseek-ai/dsh-tool-ask-user": "workspace:^", - "@deepseek-ai/dsh-tool-bash": "workspace:^", - "@deepseek-ai/dsh-tool-bash-persistent": "workspace:^", - "@deepseek-ai/dsh-tool-cordis": "workspace:^", - "@deepseek-ai/dsh-tool-present": "workspace:^", - "@deepseek-ai/dsh-tool-fs": "workspace:^", - "@deepseek-ai/dsh-tool-fs-search": "workspace:^", - "@deepseek-ai/dsh-tool-goal": "workspace:^", - "@deepseek-ai/dsh-tool-jobs": "workspace:^", - "@deepseek-ai/dsh-tool-pwsh": "workspace:^", - "@deepseek-ai/dsh-tool-pwsh-persistent": "workspace:^", - "@deepseek-ai/dsh-tool-ralph": "workspace:^", - "@deepseek-ai/dsh-tool-skill": "workspace:^", - "@deepseek-ai/dsh-tool-str-replace-editor": "workspace:^", - "@deepseek-ai/dsh-tool-subagent": "workspace:^", - "@deepseek-ai/dsh-tool-subagent-control": "workspace:^", - "@deepseek-ai/dsh-tool-todo": "workspace:^", - "@deepseek-ai/dsh-tool-web": "workspace:^", - "@deepseek-ai/dsh-tool-workflow": "workspace:^", - "@deepseek-ai/dsh-web-app": "workspace:^", - "@deepseek-ai/dsh-webhook": "workspace:^", - "@deepseek-ai/dsh-webhook-github": "workspace:^", - "@deepseek-ai/dsh-workflow-ptc": "workspace:^", - "@deepseek-ai/schemastery": "workspace:^", + "@deepseek-ai/cordis": "workspace:~", + "@deepseek-ai/dsh-hmr": "workspace:*", + "@deepseek-ai/cordis-plugin-include": "workspace:~", + "@deepseek-ai/cordis-plugin-loader": "workspace:~", + "@deepseek-ai/cordis-plugin-timer": "workspace:~", + "@deepseek-ai/dsh-acp-app": "workspace:*", + "@deepseek-ai/dsh-agent-instructions": "workspace:*", + "@deepseek-ai/dsh-agent-tool-presentation": "workspace:*", + "@deepseek-ai/dsh-app-boot": "workspace:*", + "@deepseek-ai/dsh-base": "workspace:*", + "@deepseek-ai/dsh-client-ui-agent-preset": "workspace:*", + "@deepseek-ai/dsh-client-ui-cordis": "workspace:*", + "@deepseek-ai/dsh-cmdline": "workspace:*", + "@deepseek-ai/dsh-command-compact": "workspace:*", + "@deepseek-ai/dsh-command-goal": "workspace:*", + "@deepseek-ai/dsh-compaction-basic": "workspace:*", + "@deepseek-ai/dsh-compaction-tool-result-pruner": "workspace:*", + "@deepseek-ai/dsh-cordis-client-runner": "workspace:*", + "@deepseek-ai/dsh-experimental-agent-team-profile": "workspace:*", + "@deepseek-ai/dsh-fs-local": "workspace:*", + "@deepseek-ai/dsh-goal": "workspace:*", + "@deepseek-ai/dsh-goal-round-driver": "workspace:*", + "@deepseek-ai/dsh-headless": "workspace:*", + "@deepseek-ai/dsh-home-paths": "workspace:*", + "@deepseek-ai/dsh-hooks-claude-code": "workspace:*", + "@deepseek-ai/dsh-hooks-codex": "workspace:*", + "@deepseek-ai/dsh-jobs-local": "workspace:*", + "@deepseek-ai/dsh-launch-environment": "workspace:*", + "@deepseek-ai/dsh-mcp-client": "workspace:*", + "@deepseek-ai/dsh-persona": "workspace:*", + "@deepseek-ai/dsh-plan-mode": "workspace:*", + "@deepseek-ai/dsh-pwsh-local": "workspace:*", + "@deepseek-ai/dsh-pwsh-sandbox": "workspace:*", + "@deepseek-ai/dsh-schedule": "workspace:*", + "@deepseek-ai/dsh-sdk-app": "workspace:*", + "@deepseek-ai/dsh-sdk-minimal": "workspace:*", + "@deepseek-ai/dsh-session-projection": "workspace:*", + "@deepseek-ai/dsh-session-reference": "workspace:*", + "@deepseek-ai/dsh-skill": "workspace:*", + "@deepseek-ai/dsh-skill-filesystem": "workspace:*", + "@deepseek-ai/dsh-skill-office": "workspace:*", + "@deepseek-ai/dsh-tool-workspace-dependencies": "workspace:*", + "@deepseek-ai/dsh-terminal": "workspace:*", + "@deepseek-ai/dsh-terminal-bash": "workspace:*", + "@deepseek-ai/dsh-time-context": "workspace:*", + "@deepseek-ai/dsh-tmux-context": "workspace:*", + "@deepseek-ai/dsh-token-meter": "workspace:*", + "@deepseek-ai/dsh-tool-ask-user": "workspace:*", + "@deepseek-ai/dsh-tool-bash": "workspace:*", + "@deepseek-ai/dsh-tool-bash-persistent": "workspace:*", + "@deepseek-ai/dsh-tool-cordis": "workspace:*", + "@deepseek-ai/dsh-tool-present": "workspace:*", + "@deepseek-ai/dsh-tool-fs": "workspace:*", + "@deepseek-ai/dsh-tool-fs-search": "workspace:*", + "@deepseek-ai/dsh-tool-goal": "workspace:*", + "@deepseek-ai/dsh-tool-jobs": "workspace:*", + "@deepseek-ai/dsh-tool-pwsh": "workspace:*", + "@deepseek-ai/dsh-tool-pwsh-persistent": "workspace:*", + "@deepseek-ai/dsh-tool-ralph": "workspace:*", + "@deepseek-ai/dsh-tool-skill": "workspace:*", + "@deepseek-ai/dsh-tool-str-replace-editor": "workspace:*", + "@deepseek-ai/dsh-tool-subagent": "workspace:*", + "@deepseek-ai/dsh-tool-subagent-control": "workspace:*", + "@deepseek-ai/dsh-tool-todo": "workspace:*", + "@deepseek-ai/dsh-tool-web": "workspace:*", + "@deepseek-ai/dsh-tool-workflow": "workspace:*", + "@deepseek-ai/dsh-web-app": "workspace:*", + "@deepseek-ai/dsh-webhook": "workspace:*", + "@deepseek-ai/dsh-webhook-github": "workspace:*", + "@deepseek-ai/dsh-workflow-ptc": "workspace:*", + "@deepseek-ai/schemastery": "workspace:~", "commander": "^15.0.0", "js-yaml": "^4.2.0", "node-addon-require-builtin": "^0.1.6", - "@deepseek-ai/dsh-http-proxy": "workspace:^", - "@deepseek-ai/dsh-mcp-resources": "workspace:^", - "@deepseek-ai/dsh-plugin-manager": "workspace:^", - "@deepseek-ai/dsh-atomic-write": "workspace:^", - "@deepseek-ai/dsh-agent-preset": "workspace:^", - "@deepseek-ai/dsh-experimental-voice-input-bundle": "workspace:^" + "@deepseek-ai/dsh-http-proxy": "workspace:*", + "@deepseek-ai/dsh-mcp-resources": "workspace:*", + "@deepseek-ai/dsh-plugin-manager": "workspace:*", + "@deepseek-ai/dsh-atomic-write": "workspace:*", + "@deepseek-ai/dsh-agent-preset": "workspace:*", + "@deepseek-ai/dsh-experimental-voice-input-bundle": "workspace:*" }, "devDependencies": { "@agentclientprotocol/sdk": "1.4.0", - "@deepseek-ai/dsh-acp": "workspace:^", - "@deepseek-ai/dsh-agent": "workspace:^", - "@deepseek-ai/dsh-agent-loop": "workspace:^", - "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^", - "@deepseek-ai/dsh-attachment-local": "workspace:^", - "@deepseek-ai/dsh-bash-local": "workspace:^", - "@deepseek-ai/dsh-credentials-local": "workspace:^", - "@deepseek-ai/dsh-deepseek-llm-api-extensions": "workspace:^", - "@deepseek-ai/dsh-experimental-agent-team": "workspace:^", - "@deepseek-ai/dsh-experimental-ptc-runtime-python": "workspace:^", - "@deepseek-ai/dsh-experimental-tool-agent-team": "workspace:^", - "@deepseek-ai/dsh-fs-observation-policy": "workspace:^", - "@deepseek-ai/dsh-fs-sandbox": "workspace:^", - "@deepseek-ai/dsh-host-frontend-static": "workspace:^", - "@deepseek-ai/dsh-host-webserver": "workspace:^", - "@deepseek-ai/dsh-llm": "workspace:^", - "@deepseek-ai/dsh-llm-deepseek": "workspace:^", - "@deepseek-ai/dsh-llm-mock-server": "workspace:^", - "@deepseek-ai/dsh-llm-pi-ai": "workspace:^", - "@deepseek-ai/dsh-llm-replay": "workspace:^", - "@deepseek-ai/dsh-loader-smoke": "workspace:^", - "@deepseek-ai/dsh-plugin-package-inventory-deepseek": "workspace:^", - "@deepseek-ai/dsh-sandbox-local": "workspace:^", - "@deepseek-ai/dsh-sandbox-policy": "workspace:^", - "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/dsh-session-checkpoint-policy": "workspace:^", - "@deepseek-ai/dsh-session-log-deepseek": "workspace:^", - "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", - "@deepseek-ai/dsh-session-query": "workspace:^", - "@deepseek-ai/dsh-shell-env": "workspace:^", - "@deepseek-ai/dsh-settings": "workspace:^", - "@deepseek-ai/dsh-subagent": "workspace:^", - "@deepseek-ai/dsh-subagent-fork-in-process": "workspace:^", - "@deepseek-ai/dsh-subagent-spawn-in-process": "workspace:^", - "@deepseek-ai/dsh-subprocess-local": "workspace:^", - "@deepseek-ai/dsh-system-prompt": "workspace:^", - "@deepseek-ai/dsh-tools": "workspace:^", - "@deepseek-ai/dsh-user-approval": "workspace:^", + "@deepseek-ai/dsh-acp": "workspace:*", + "@deepseek-ai/dsh-agent": "workspace:*", + "@deepseek-ai/dsh-agent-loop": "workspace:*", + "@deepseek-ai/dsh-agent-loop-testkit": "workspace:*", + "@deepseek-ai/dsh-attachment-local": "workspace:*", + "@deepseek-ai/dsh-bash-local": "workspace:*", + "@deepseek-ai/dsh-credentials-local": "workspace:*", + "@deepseek-ai/dsh-deepseek-llm-api-extensions": "workspace:*", + "@deepseek-ai/dsh-experimental-agent-team": "workspace:*", + "@deepseek-ai/dsh-experimental-ptc-runtime-python": "workspace:*", + "@deepseek-ai/dsh-experimental-tool-agent-team": "workspace:*", + "@deepseek-ai/dsh-fs-observation-policy": "workspace:*", + "@deepseek-ai/dsh-fs-sandbox": "workspace:*", + "@deepseek-ai/dsh-host-frontend-static": "workspace:*", + "@deepseek-ai/dsh-host-webserver": "workspace:*", + "@deepseek-ai/dsh-llm": "workspace:*", + "@deepseek-ai/dsh-llm-deepseek": "workspace:*", + "@deepseek-ai/dsh-llm-mock-server": "workspace:*", + "@deepseek-ai/dsh-llm-pi-ai": "workspace:*", + "@deepseek-ai/dsh-llm-replay": "workspace:*", + "@deepseek-ai/dsh-loader-smoke": "workspace:*", + "@deepseek-ai/dsh-plugin-package-inventory-deepseek": "workspace:*", + "@deepseek-ai/dsh-sandbox-local": "workspace:*", + "@deepseek-ai/dsh-sandbox-policy": "workspace:*", + "@deepseek-ai/dsh-session": "workspace:*", + "@deepseek-ai/dsh-session-checkpoint-policy": "workspace:*", + "@deepseek-ai/dsh-session-log-deepseek": "workspace:*", + "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:*", + "@deepseek-ai/dsh-session-query": "workspace:*", + "@deepseek-ai/dsh-shell-env": "workspace:*", + "@deepseek-ai/dsh-settings": "workspace:*", + "@deepseek-ai/dsh-subagent": "workspace:*", + "@deepseek-ai/dsh-subagent-fork-in-process": "workspace:*", + "@deepseek-ai/dsh-subagent-spawn-in-process": "workspace:*", + "@deepseek-ai/dsh-subprocess-local": "workspace:*", + "@deepseek-ai/dsh-system-prompt": "workspace:*", + "@deepseek-ai/dsh-tools": "workspace:*", + "@deepseek-ai/dsh-user-approval": "workspace:*", "@types/js-yaml": "^4.0.9", "@types/ws": "8.18.1", "ajv": "^8.20.0", 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 32bad4b043..eea02dc7ce 100644 --- a/apps/desktop-host/package.json +++ b/apps/desktop-host/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-desktop-host", "description": "Private Node-mode host process for the Electron desktop application", - "version": "0.1.7-alpha.1", + "version": "0.1.7-alpha.2", "private": true, "license": "MIT", "type": "module", @@ -10,16 +10,17 @@ "lib/index.js" ], "dependencies": { - "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh": "workspace:^", - "@deepseek-ai/dsh-agent": "workspace:^", - "@deepseek-ai/dsh-app-boot": "workspace:^", - "@deepseek-ai/dsh-client-connection": "workspace:^", - "@deepseek-ai/dsh-host-webserver": "workspace:^", - "@deepseek-ai/dsh-home-paths": "workspace:^", - "@deepseek-ai/dsh-jobs": "workspace:^", - "@deepseek-ai/dsh-tool-workspace-dependencies": "workspace:^", - "@deepseek-ai/dsh-skill-office": "workspace:^", - "@deepseek-ai/dsh-deepseek-account": "workspace:^" + "@deepseek-ai/libreoffice-kit": "^0.1.0", + "@deepseek-ai/cordis": "workspace:~", + "@deepseek-ai/dsh": "workspace:*", + "@deepseek-ai/dsh-agent": "workspace:*", + "@deepseek-ai/dsh-app-boot": "workspace:*", + "@deepseek-ai/dsh-client-connection": "workspace:*", + "@deepseek-ai/dsh-host-webserver": "workspace:*", + "@deepseek-ai/dsh-home-paths": "workspace:*", + "@deepseek-ai/dsh-jobs": "workspace:*", + "@deepseek-ai/dsh-tool-workspace-dependencies": "workspace:*", + "@deepseek-ai/dsh-skill-office": "workspace:*", + "@deepseek-ai/dsh-deepseek-account": "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 77500865f6..d25e3c5ef8 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: 2b07b2b9d95c3d16a3aaa58b6d52f55cb1242e2e -README.zh.md: 4ebae34332b1d622a35d0bf857f1f3252736512c +README.md: ec3f2f44d5e49db00bd595588a0594bcee0a1538 +README.zh.md: 50fba340a0edc114a75d4b6363b8e6c42be0c45f diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 2b07b2b9d9..ec3f2f44d5 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -185,9 +185,9 @@ Each target owns its packed package inputs, prepared runtime, package set, dsh t Desktop packs workspace packages locally and installs external dependencies through the target's bundled Node and pnpm. [Desktop's file policy](scripts/runtime-file-policy.ts) then filters the immutable `resources/app.asar/dsh/node_modules` copy before signing and integrity sealing. It omits TypeScript declarations, recognized JavaScript/CSS/TypeScript source maps, TypeScript build caches, Domino's test directory, selected native compiler outputs, and node-pty prebuilds for other platforms. It preserves runtime JavaScript, native modules and their DLL/EXE helpers, WASM, unknown assets, licenses, and notices. Dependency manifests pass through electron-builder's metadata cleanup before integrity sealing, so archiving preserves their recorded bytes. The policy does not alter npm tarballs, the bundled package manager, or user-installed plugin files. -The [Office conversion provider](../../packages/document/office-to-pdf/README.md) carries the target’s declared native engine, or WASM when the kit declares no matching native target. Preparation rejects a missing target engine before packaging. Complete native engine packages, including executables, libraries, data, licenses, and notices, are unpacked under `resources/app.asar.unpacked/dsh/node_modules/@deepseek-ai/`. The Desktop Host resolves engine manifests to these physical directories so native subprocesses can read their resources; the JavaScript API and WASM engine remain in ASAR. On macOS, the native helper receives the JIT entitlement required by [LibreOffice’s UNO bridge](https://github.com/LibreOffice/core/blob/master/sysui/desktop/macosx/hardened_runtime.xcent.in). +The [Office conversion provider](../../packages/document/office-to-pdf/README.md) carries the target’s declared native engine, or WASM when the kit declares no matching native target. Preparation rejects a missing target engine before packaging. The complete Office dependencies, including CLI, JavaScript libraries, and the selected engine’s executables, data, licenses, and notices, are unpacked under `resources/app.asar.unpacked/dsh/node_modules/`. The Desktop Host resolves engine manifests to these physical directories and supplies loaded skills with absolute standalone Node and unpacked CLI paths. Node lives in `resources/runtime/primary-runtime/dependencies/node/bin/`; the CLI is the unpacked `@deepseek-ai/libreoffice-kit/lib/cli.js`. On macOS, the native helper receives the JIT entitlement required by [LibreOffice’s UNO bridge](https://github.com/LibreOffice/core/blob/master/sysui/desktop/macosx/hardened_runtime.xcent.in). -The packaged application runs compiled JavaScript and pre-generated Typert metadata; it does not compile TypeScript plugins. Source-level debugger navigation and editor declarations remain available in development packages. [Copy-policy tests](tests/runtime-file-policy.spec.ts) cover exclusions and retained assets; The [payload smoke](tests/fixtures/runtime-payload-smoke.mjs) runs under Electron RunAsNode before the Host smoke and final inventory verification. The payload smoke resolves the search tool’s ripgrep executable and verifies text search and file enumeration. Signed Windows builds run these checks after dependency signing; other builds run them during `prepare:dsh`. The [Host smoke](scripts/smoke-runtime.ts) creates DOCX, XLSX, and PPTX inputs with bundled Python, converts each through the real Office provider, and checks PDF output. Every assembled application, including directory and unsigned Windows builds, repeats the payload and Host checks against ASAR. Archive integrity checks compare the complete archived descriptor with preparation, file bytes and membership in both archive and unpacked trees, recorded executable flags for archived files, and physical permissions for unpacked files. Conversion failures stop packaging before a release record is written; macOS DMG/ZIP builds run these checks before notarization. +The packaged application runs compiled JavaScript and pre-generated Typert metadata; it does not compile TypeScript plugins. Source-level debugger navigation and editor declarations remain available in development packages. [Copy-policy tests](tests/runtime-file-policy.spec.ts) cover exclusions and retained assets; The [payload smoke](tests/fixtures/runtime-payload-smoke.mjs) runs under Electron RunAsNode before the Host smoke and final inventory verification. The payload smoke resolves the search tool’s ripgrep executable and verifies text search and file enumeration. Signed Windows builds run these checks after dependency signing; other builds run them during `prepare:dsh`. The [Host smoke](scripts/smoke-runtime.ts) creates DOCX, XLSX, and PPTX inputs with bundled Python, converts each through the real Office provider, and checks PDF output. Every assembled application, including directory and unsigned Windows builds, repeats the payload and Host checks against ASAR. Archive integrity checks compare the complete archived descriptor with preparation, file bytes and membership in both archive and unpacked trees, recorded executable flags for archived files, and physical permissions for unpacked files. The Host smoke also obtains CLI paths from the loaded skill and runs capabilities and DOCX conversion with an empty PATH. Conversion failures stop packaging before a release record is written; macOS DMG/ZIP builds run these checks before notarization. Windows release qualification also runs [directory and replacement checks](scripts/smoke-windows.ps1) manually after the Desktop build. Set `$Makensis`, `$SevenZip`, and `$PluginDir` to the pinned builder’s NSIS compiler, 7-Zip executable, and x86-unicode NSIS plugin directory; pass the prepared `window-frame.dll` as `-FrameLibrary` to exercise the native extraction path and its failure report as well. From the repository root, run the command below. It verifies directory replacement and rollback, and both locked-file replacement modes; it is not part of the unit-test lane. @@ -399,7 +399,7 @@ The downloaded bytes are inert, and the installation call is recorded rather tha ## Low-level development overrides -An unpackaged Electron process uses `.desktop-build/development/project` under its application directory as its development project. `DSH_DESKTOP_PNPM_ENTRY` and `DSH_DESKTOP_DSH_DIR` select explicit runtime resources. Packaged applications ignore these variables, resolve signed resources from `process.resourcesPath`, and use the managed Desktop profile. +An unpackaged Electron process uses `.desktop-build/development/project` under its application directory as its development project. `DSH_DESKTOP_PNPM_ENTRY` and `DSH_DESKTOP_DSH_DIR` are optional overrides with application-path defaults. `DSH_DESKTOP_PRIMARY_RUNTIME_DIR` is required for every unpackaged launch: the development launchers (`dev:desktop`, `start:desktop`, and the workspace-update qualification runner) set it to the primary-runtime directory of the target they prepared, and a launch without it fails with the fatal startup dialog. The launcher must set it because the shell cannot derive that directory from `process.arch`: the build target fixes Windows to x64 while the host may be arm64. Packaged applications ignore these variables, resolve signed resources from `process.resourcesPath`, and use the managed Desktop profile. ## Known limitations @@ -408,6 +408,7 @@ An unpackaged Electron process uses `.desktop-build/development/project` under i - Release signing, notarization, update hosting, and previous-version installed-artifact qualification require the production release environment. - Dependency lifecycle scripts follow pnpm’s build permissions; Desktop provides no separate approval dialog. - The desktop shell shares sessions, settings, credentials, workspaces, and storage under `$DSH_HOME` with CLI dsh, while executable packages, plugin activation, and lockfiles remain separate. +- Unpackaged startup on an Electron win32-arm64 host now succeeds, but the payload remains x64: the architecture check in `packages/skill/tool-workspace-dependencies/src/index.ts` compares the recorded payload architecture against the host `process.arch`, so the `load_workspace_dependencies` tool can still reject the primary runtime. Sign in opens the configured platform page in the system browser. The Host owns PKCE and a temporary loopback callback, saves the credential before entering the workspace, and redirects the browser to the platform completion page. Opened and copied authorization links carry the effective Desktop theme as `theme=light` or `theme=dark`; `system` resolves at the time of the action. Cancel withdraws the local attempt even if the platform page later approves it. Settings offers Account sign-out; without a separate API key, sign-out returns to the welcome window. Packaged applications register dsh://open to show the window without passing credentials. On macOS, the development launcher prepares an ad-hoc-signed `Harness Dev.app` under `.desktop-build/development`, declares `dsh` in its Info.plist, and registers it with Launch Services. It loads the current workspace and records the selected development home, browser-data path, and debug settings for cold starts. Starting this bundle makes it the default `dsh://` handler; starting the packaged application registers the packaged handler again. The generated bundle does not contain account tokens and requires the workspace and prepared runtime to remain available. diff --git a/apps/desktop/README.zh.md b/apps/desktop/README.zh.md index 4ebae34332..50fba340a0 100644 --- a/apps/desktop/README.zh.md +++ b/apps/desktop/README.zh.md @@ -185,7 +185,7 @@ macOS arm64 命令要求 Apple Silicon。macOS x64 命令可以在 Intel macOS Desktop 在本地打包工作区包,并通过目标捆绑的 Node 和 pnpm 安装外部依赖。[Desktop 文件策略](scripts/runtime-file-policy.ts)随后在签名和完整性封装前过滤不可变的 `resources/app.asar/dsh/node_modules` 副本。它排除 TypeScript 声明、已识别的 JavaScript/CSS/TypeScript source map、TypeScript 构建缓存、Domino 测试目录、选定的原生编译器输出和其他平台的 node-pty 预构建文件。它保留运行时 JavaScript、原生模块及其 DLL/EXE 辅助文件、WASM、未知资源、许可证和 notices。依赖清单在完整性封装前经过 electron-builder 的元数据清理,确保归档保持已记录的字节。该策略不修改 npm tarball、捆绑的包管理器或用户安装的插件文件。 -[Office 转换提供方](../../packages/document/office-to-pdf/README.zh.md)携带目标已声明的原生引擎;kit 未声明匹配原生目标时携带 WASM 引擎。准备阶段在打包前拒绝缺少目标引擎的情况。完整原生引擎包(包括可执行文件、库、数据、许可证和 notices)解包到 `resources/app.asar.unpacked/dsh/node_modules/@deepseek-ai/` 下。Desktop Host 将引擎清单解析到这些物理目录,使原生子进程能读取资源;JavaScript API 和 WASM 引擎保留在 ASAR 中。macOS 上的原生辅助程序获得 [LibreOffice UNO 桥](https://github.com/LibreOffice/core/blob/master/sysui/desktop/macosx/hardened_runtime.xcent.in)所需的 JIT entitlement。 +[Office 转换提供方](../../packages/document/office-to-pdf/README.zh.md)携带目标已声明的原生引擎;kit 未声明匹配原生目标时携带 WASM 引擎。准备阶段在打包前拒绝缺少目标引擎的情况。完整 Office 依赖(CLI、JavaScript 库和选定引擎的可执行文件、数据、许可证及 notices)解包到 `resources/app.asar.unpacked/dsh/node_modules/` 下。Desktop Host 将引擎清单解析到这些物理目录,并向加载的技能提供独立 Node 和解包后 CLI 的绝对路径。Node 位于 `resources/runtime/primary-runtime/dependencies/node/bin/`;CLI 位于解包后的 `@deepseek-ai/libreoffice-kit/lib/cli.js`。macOS 上的原生辅助程序获得 [LibreOffice UNO 桥](https://github.com/LibreOffice/core/blob/master/sysui/desktop/macosx/hardened_runtime.xcent.in)所需的 JIT entitlement。 打包应用运行编译后的 JavaScript 和预生成的 Typert 元数据,不编译 TypeScript 插件。源码级调试导航和编辑器声明仍可从开发包中获取。[复制规则测试](tests/runtime-file-policy.spec.ts)覆盖排除项和保留资源;[产物 smoke](tests/fixtures/runtime-payload-smoke.mjs) 在 Host smoke 和最终清单验证之前,使用 Electron RunAsNode 执行。产物 smoke 解析搜索工具使用的 ripgrep 可执行文件,并验证文本搜索和文件枚举。Windows 签名构建在依赖签名后运行这些检查;其他构建在 `prepare:dsh` 中运行。[Host smoke](scripts/smoke-runtime.ts) 使用捆绑的 Python 创建 DOCX、XLSX 和 PPTX 输入,通过真实 Office 提供方逐一转换并检查 PDF 输出。每个组装后的应用(包括目录包和 Windows 未签名构建)都会针对 ASAR 重复产物和 Host 检查。归档完整性检查将归档内完整描述符与准备结果比对,并核对归档和解包目录中的文件内容与清单、归档内文件记录的执行标志,以及解包文件的物理权限。转换失败会在写入发布记录前终止打包;macOS DMG/ZIP 构建在公证前执行这些检查。 @@ -399,7 +399,7 @@ node apps/desktop/node_modules/pnpm/bin/pnpm.mjs --dir apps/desktop run test:upd ## 底层开发覆盖项 -未打包的 Electron 进程使用应用目录下的 `.desktop-build/development/project` 作为开发项目。`DSH_DESKTOP_PNPM_ENTRY` 和 `DSH_DESKTOP_DSH_DIR` 用于选择明确的运行时资源。打包应用会忽略这些变量,从 `process.resourcesPath` 解析签名资源,并使用受管 Desktop profile。 +未打包的 Electron 进程使用应用目录下的 `.desktop-build/development/project` 作为开发项目。`DSH_DESKTOP_PNPM_ENTRY` 和 `DSH_DESKTOP_DSH_DIR` 是带应用路径默认值的可选覆盖项。每次未打包启动都必须设置 `DSH_DESKTOP_PRIMARY_RUNTIME_DIR`:开发启动器(`dev:desktop`、`start:desktop` 及工作区更新验证运行器)会把它设置为自己已准备目标的 primary-runtime 目录;缺少该变量的启动会以致命启动对话框失败。启动器必须设置它,因为壳无法从 `process.arch` 推导该目录:构建目标将 Windows 固定为 x64,而宿主可能是 arm64。打包应用会忽略这些变量,从 `process.resourcesPath` 解析签名资源,并使用受管 Desktop profile。 ## 已知限制 @@ -408,6 +408,7 @@ node apps/desktop/node_modules/pnpm/bin/pnpm.mjs --dir apps/desktop run test:upd - 发布签名、公证、更新托管和跨上一版本的已安装产物验证需要生产发布环境。 - 依赖的生命周期脚本遵循 pnpm 的构建权限;Desktop 不提供单独的审批对话框。 - 桌面壳与 CLI dsh 共享 `$DSH_HOME` 下的会话、设置、凭据、工作区和存储,但可执行包、插件激活和锁文件彼此隔离。 +- 在 Electron win32-arm64 宿主上,未打包启动现在可以成功,但载荷仍为 x64:`packages/skill/tool-workspace-dependencies/src/index.ts` 的架构校验会把载荷记录的架构与宿主 `process.arch` 比较,因此 `load_workspace_dependencies` 工具仍可能拒绝 primary runtime。 登录会在系统浏览器中打开配置的平台页面。Host 负责 PKCE 和临时本机回调,在进入工作区前保存凭证,再将浏览器跳转到平台完成页。打开和复制的授权链接通过 `theme=light` 或 `theme=dark` 携带当前生效的 Desktop 主题;`system` 在执行操作时解析。即使平台页面随后批准,取消仍会撤销本地尝试。设置中的账号页面提供退出;没有独立 API Key 时,退出后返回欢迎窗。打包应用注册 dsh://open,只显示窗口而不传递凭证。macOS 开发启动器在 `.desktop-build/development` 下准备经临时签名的 `Harness Dev.app`,在 Info.plist 中声明 `dsh` 并注册到 Launch Services。它加载当前工作区,并记录选定的开发 home、浏览器数据路径和调试设置,以供冷启动使用。启动此应用会将其设为 `dsh://` 默认处理程序;启动打包应用会重新注册打包版处理程序。生成的应用包不包含账号 token,依赖工作区和已准备的运行环境继续存在。 diff --git a/apps/desktop/package.json b/apps/desktop/package.json index d7c8fbdd09..db69834af2 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-desktop", "description": "Electron desktop shell for a bundled dsh runtime and external plugins", - "version": "0.1.7-alpha.1", + "version": "0.1.7-alpha.2", "private": true, "license": "MIT", "type": "module", @@ -39,15 +39,15 @@ "electron-updater": "^6.8.9", "semver": "^7.8.5", "ws": "^8.21.0", - "@deepseek-ai/dsh-api-gateway": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/dsh-api-gateway": "workspace:*", + "@deepseek-ai/cordis": "workspace:~" }, "devDependencies": { - "@deepseek-ai/dsh-app-boot": "workspace:^", - "@deepseek-ai/dsh-client-ui-settings-general": "workspace:^", - "@deepseek-ai/dsh-client-ui-sidebar-browser": "workspace:^", - "@deepseek-ai/dsh-home-paths": "workspace:^", - "@deepseek-ai/node-addon-system": "workspace:^", + "@deepseek-ai/dsh-app-boot": "workspace:*", + "@deepseek-ai/dsh-client-ui-settings-general": "workspace:*", + "@deepseek-ai/dsh-client-ui-sidebar-browser": "workspace:*", + "@deepseek-ai/dsh-home-paths": "workspace:*", + "@deepseek-ai/node-addon-system": "workspace:~", "@electron/get": "^5.1.0", "@electron/notarize": "2.5.0", "@types/js-yaml": "^4.0.9", @@ -62,14 +62,14 @@ "koffi": "^3.1.0", "pnpm": "11.7.0", "typescript": "^6.0.3", - "@deepseek-ai/dsh-deepseek-account": "workspace:^", + "@deepseek-ai/dsh-deepseek-account": "workspace:*", "@types/ws": "^8.18.1", - "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", + "@deepseek-ai/dsh-client-ui-primitives": "workspace:*", "react": "^18.2.0", "react-dom": "^18.2.0", "@types/react": "~18.3.1", "@types/react-dom": "~18.3.0", "vite": "^6.0.0", - "@deepseek-ai/dsh-client-ui-theme": "workspace:^" + "@deepseek-ai/dsh-client-ui-theme": "workspace:*" } } 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 9f8ad5c22f..6690a3c69c 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 @@ -370,8 +381,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 4a823afdf9..146b2297eb 100644 --- a/apps/desktop/tests/welcome-startup.spec.ts +++ b/apps/desktop/tests/welcome-startup.spec.ts @@ -148,6 +148,7 @@ it('starts the Host for welcome onboarding and opens the workspace on skip witho vi.stubEnv('DSH_DESKTOP_NODE_BINARY', '/runtime/node') vi.stubEnv('DSH_DESKTOP_PNPM_ENTRY', '/runtime/pnpm') vi.stubEnv('DSH_DESKTOP_DSH_DIR', '/runtime/dsh') + vi.stubEnv('DSH_DESKTOP_PRIMARY_RUNTIME_DIR', '/runtime/primary-runtime') vi.stubEnv('DSH_DESKTOP_HOST_INSPECT_PORT', undefined) vi.stubEnv('DSH_DESKTOP_OPEN_DEVTOOLS', '0') vi.stubEnv('DSH_DESKTOP_MANDATORY_UPDATE_CONFIG', undefined) 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/package.json b/apps/web/package.json index 35f2e07840..f1c27b66c1 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-web-frontend", "description": "Web application entry: vite build over the @deepseek-ai/dsh-client-web shell library; dist/ served by apps/cli's dsh web", - "version": "0.1.7-alpha.1", + "version": "0.1.7-alpha.2", "publishConfig": { "access": "public" }, @@ -30,21 +30,21 @@ }, "license": "MIT", "devDependencies": { - "@deepseek-ai/cordis-plugin-group": "workspace:^", - "@deepseek-ai/dsh-client-modules": "workspace:^", - "@deepseek-ai/dsh-client-store": "workspace:^", - "@deepseek-ai/dsh-client-test-runtime": "workspace:^", - "@deepseek-ai/dsh-client-ui-deliverables": "workspace:^", - "@deepseek-ai/dsh-client-ui-dockkit": "workspace:^", - "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", - "@deepseek-ai/dsh-client-ui-slots": "workspace:^", - "@deepseek-ai/dsh-client-web": "workspace:^", - "@deepseek-ai/dsh-cmdline": "workspace:^", - "@deepseek-ai/dsh-pwsh-local": "workspace:^", - "@deepseek-ai/dsh-experimental-agent-team": "workspace:^", - "@deepseek-ai/dsh-experimental-webworker-packer": "workspace:^", - "@deepseek-ai/dsh-experimental-webworker-runtime": "workspace:^", - "@deepseek-ai/dsh-remote-mock": "workspace:^", + "@deepseek-ai/cordis-plugin-group": "workspace:~", + "@deepseek-ai/dsh-client-modules": "workspace:*", + "@deepseek-ai/dsh-client-store": "workspace:*", + "@deepseek-ai/dsh-client-test-runtime": "workspace:*", + "@deepseek-ai/dsh-client-ui-deliverables": "workspace:*", + "@deepseek-ai/dsh-client-ui-dockkit": "workspace:*", + "@deepseek-ai/dsh-client-ui-primitives": "workspace:*", + "@deepseek-ai/dsh-client-ui-slots": "workspace:*", + "@deepseek-ai/dsh-client-web": "workspace:*", + "@deepseek-ai/dsh-cmdline": "workspace:*", + "@deepseek-ai/dsh-pwsh-local": "workspace:*", + "@deepseek-ai/dsh-experimental-agent-team": "workspace:*", + "@deepseek-ai/dsh-experimental-webworker-packer": "workspace:*", + "@deepseek-ai/dsh-experimental-webworker-runtime": "workspace:*", + "@deepseek-ai/dsh-remote-mock": "workspace:*", "@types/node": "^22.0.0", "@types/react": "~18.3.1", "@types/react-dom": "~18.3.0", @@ -59,11 +59,11 @@ "vite": "^6.0.0", "vitest": "^4.1.8", "ws": "8.21.0", - "@deepseek-ai/dsh-launch-environment": "workspace:^", - "@deepseek-ai/dsh-workspace-changes": "workspace:^", - "@deepseek-ai/dsh-subprocess-local": "workspace:^", - "@deepseek-ai/dsh-experimental-auto-review": "workspace:^", - "@deepseek-ai/dsh-experimental-speech-to-text": "workspace:^", - "@deepseek-ai/dsh-experimental-voice-input-bundle": "workspace:^" + "@deepseek-ai/dsh-launch-environment": "workspace:*", + "@deepseek-ai/dsh-workspace-changes": "workspace:*", + "@deepseek-ai/dsh-subprocess-local": "workspace:*", + "@deepseek-ai/dsh-experimental-auto-review": "workspace:*", + "@deepseek-ai/dsh-experimental-speech-to-text": "workspace:*", + "@deepseek-ai/dsh-experimental-voice-input-bundle": "workspace:*" } } diff --git a/apps/web/tests/README.i18n.yaml b/apps/web/tests/README.i18n.yaml index 4d920402fa..6828bcc5ae 100644 --- a/apps/web/tests/README.i18n.yaml +++ b/apps/web/tests/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/web/tests/README.md -README.md: 8b9c58ce9e4cf2f5f6978c0c6eb5517fe000d3d2 -README.zh.md: 199ae3b56166aa2ffa1c0bed9a16b844ffd7b74f +README.md: 1dce900ab2ffa16850fe50573f9177088c8201db +README.zh.md: fd14377bc01897e7d330d36603601f9dce16e295 diff --git a/apps/web/tests/README.md b/apps/web/tests/README.md index 8b9c58ce9e..1dce900ab2 100644 --- a/apps/web/tests/README.md +++ b/apps/web/tests/README.md @@ -2,7 +2,15 @@ English | [中文](README.zh.md) -These tests boot the real web composition in-process and drive it with a real Chromium over real HTTP. The lane's mechanics — modes, fixtures, goldens, and the deliberate composition divergences from `dsh web` — are documented in [`scaffold.ts`](scaffold.ts) and the [browser e2e Agent Note](../../../.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md). +These tests boot the real web composition in-process and drive it with real browsers over real HTTP. Chromium runs the full lane; the [model and reasoning picker scenario](declared-reasoning.e2e.ts) also runs in WebKit to cover native mouse focus behavior. The lane's mechanics — modes, fixtures, goldens, and the deliberate composition divergences from `dsh web` — are documented in [`scaffold.ts`](scaffold.ts) and the [browser e2e Agent Note](../../../.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md). + +After installing workspace dependencies, install the browsers and their system dependencies from the repository root: + +```sh +pnpm --filter @deepseek-ai/dsh-web-frontend exec playwright install --with-deps chromium webkit +``` + +On Linux, `--with-deps` installs dependencies through the system package manager. The persistent CI VM must provide these dependencies through image maintenance; CI installs only the browser binaries, as required by the [failover runbook](../../../.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md). Ordinary scenarios begin with no registered Workspace or Session and a durable marker recording a removed default Workspace, so explicit folder-selection scenarios retain control of their cwd. `launchWebScaffold({ firstUse: true })` leaves initialization eligible for startup scenarios. @@ -10,6 +18,8 @@ Ordinary scenarios begin with no registered Workspace or Session and a durable m State-sensitive cases use Workspace, admission, attachment, and model-stream barriers to separate visible intermediate states from completed operations. Details close waits for frame transitions; archive verification assigns an explicit title to the seeded Session and follows that identity across reload. See the [CI fixture synchronization decision](../../../.agents/notes/implemented/testing/2026-09-08-ci-completion-observations.md). +Explicit scrolling uses `scrollIntoView` from `support.ts`: it resolves the locator again when its old element detaches and checks connection in the same browser task as native scrolling. Scenarios retain their visibility and geometry assertions after scrolling. + ## These are Host-face tests They type-check in the root `tsconfig.host.json`, not in the Client aggregate, because they read Host services directly: `ctx.connection`, the Host `SessionStore`, and `ctx.sessionProjectionCache`. Driving a browser at runtime does not make a file part of the Client program — the two faces merge Cordis `Context` under the same keys with different services, so one program cannot see both. Moving these files into the Client aggregate makes every Host-service access fail to compile. diff --git a/apps/web/tests/README.zh.md b/apps/web/tests/README.zh.md index 199ae3b561..fd14377bc0 100644 --- a/apps/web/tests/README.zh.md +++ b/apps/web/tests/README.zh.md @@ -2,7 +2,15 @@ [English](README.md) | 中文 -这些测试在进程内启动真实的 web 组合,并用真实 Chromium 通过真实 HTTP 驱动它。该 lane 的运行机制——模式、fixture(测试前置数据)、golden,以及与 `dsh web` 之间刻意保留的组合差异——记录在 [`scaffold.ts`](scaffold.ts) 和 [浏览器 e2e Agent Note](../../../.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md) 中。 +这些测试在进程内启动真实的 web 组合,并用真实浏览器通过真实 HTTP 驱动它。Chromium 运行整个 lane;[模型与推理强度选择场景](declared-reasoning.e2e.ts) 还在 WebKit 中运行,以覆盖原生鼠标焦点行为。该 lane 的运行机制——模式、fixture(测试前置数据)、golden,以及与 `dsh web` 之间刻意保留的组合差异——记录在 [`scaffold.ts`](scaffold.ts) 和 [浏览器 e2e Agent Note](../../../.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md) 中。 + +安装工作区依赖后,在仓库根目录安装浏览器及其系统依赖: + +```sh +pnpm --filter @deepseek-ai/dsh-web-frontend exec playwright install --with-deps chromium webkit +``` + +在 Linux 上,`--with-deps` 会通过系统包管理器安装依赖。持久化 CI VM 必须通过镜像维护提供这些依赖,CI 只安装浏览器程序,遵循[故障切换手册](../../../.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md)的要求。 普通场景以没有已登记 Workspace 或 Session、但持久化标记记录默认 Workspace 已被删除的状态启动,使显式文件夹选择场景自行决定 cwd。`launchWebScaffold({ firstUse: true })` 保留初始化资格,供启动场景使用。 @@ -10,6 +18,8 @@ 依赖状态的用例使用 Workspace、接纳、附件和模型流屏障,区分可见中间状态与已完成操作。详情关闭等待框架过渡结束;归档验证为 seed Session 设置显式标题,并跨重载跟踪该身份。参见 [CI fixture 同步决策](../../../.agents/notes/implemented/testing/2026-09-08-ci-completion-observations.zh.md)。 +显式滚动使用 `support.ts` 的 `scrollIntoView`:旧元素脱离 DOM 时重新解析 locator,并在同一个浏览器任务中检查连接状态、执行原生滚动。各场景保留滚动后的可见性与几何断言。 + ## 这些是 Host 面的测试 它们在根 `tsconfig.host.json` 中做类型检查,而不在 Client aggregate 中,因为它们直接读取 Host 服务:`ctx.connection`、Host 侧 `SessionStore` 与 `ctx.sessionProjectionCache`。运行时驱动浏览器并不使一个文件成为 Client 程序的一部分——两个 face 在相同的键上以不同服务合并 Cordis `Context`,因此单个程序无法同时看见两者。把这些文件挪进 Client aggregate 会让每一处 Host 服务访问都无法编译。 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/agent-preset-selection.e2e.ts b/apps/web/tests/agent-preset-selection.e2e.ts index c8be11b929..911b763592 100644 --- a/apps/web/tests/agent-preset-selection.e2e.ts +++ b/apps/web/tests/agent-preset-selection.e2e.ts @@ -389,8 +389,7 @@ describe('web e2e: agent-preset selection', () => { await compareOrRefreshGolden(HEADER_EXPECTED, snapshot, MODE) expect(snapshot).toContain('Minimal mode') expect(snapshot).toContain('button "1 subagent"') - // The preset label leads the header band; delegation navigation follows it. - expect(snapshot.indexOf('Minimal mode')).toBeLessThan(snapshot.indexOf('button "1 subagent"')) + expect(snapshot.indexOf('button "1 subagent"')).toBeLessThan(snapshot.indexOf('Minimal mode')) expect(snapshot.indexOf('button "1 subagent"')).toBeLessThan(snapshot.indexOf('button "More actions"')) // Static chrome, not a control: the header can only report a composition // the host would refuse to change. diff --git a/apps/web/tests/agent-team-panel.e2e.ts b/apps/web/tests/agent-team-panel.e2e.ts index 3e5ad5e0c2..8358ef1a20 100644 --- a/apps/web/tests/agent-team-panel.e2e.ts +++ b/apps/web/tests/agent-team-panel.e2e.ts @@ -1,11 +1,11 @@ // Keyless assembled-browser coverage for the opt-in Agent Teams bundle -// over the real Host Typert Remote flow. +// over the real Host projection frame flow. import { fileURLToPath } from 'node:url' import { join } from 'node:path' import { readFileSync } from 'node:fs' import type { Browser, Page } from 'playwright' import { chromium } from 'playwright' -import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import { afterAll, beforeAll, describe, expect, it, onTestFailed, onTestFinished, vi } from 'vitest' import * as yaml from 'js-yaml' import { entryListSchema } from '@deepseek-ai/cordis-plugin-include' import type {} from '@deepseek-ai/dsh-experimental-agent-team' @@ -18,6 +18,7 @@ import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './suppor const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/agent-team-panel', import.meta.url)) const PANEL_EXPECTED = join(SNAPSHOT_DIR, 'task.expected.md') +const RESIZE_EXPECTED = join(SNAPSHOT_DIR, 'resize.expected.md') const OVERLAY = fileURLToPath(new URL('./agent-team-panel.overlay.yml', import.meta.url)) const TEAM_PATCH = fileURLToPath(new URL('../../../packages/experimental/agent-team-profile/cordis.patch.yml', import.meta.url)) const INSTALL_ANCHORS = [ @@ -80,26 +81,29 @@ describe('web e2e: Agent Teams panel', () => { await scaffold?.close() }) - it('displays agent-owned tasks through a read-only board', async () => { + it('displays agent-owned task changes through a read-only board without a refresh action', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-agent-team-panel')) + const projectionReads = vi.spyOn(scaffold.ctx.sessionController, 'projections') + onTestFinished(() => { projectionReads.mockRestore() }) await page.locator('[data-team-action]').getByRole('button', { name: /Agent Team/iu }).click() const action = page.getByRole('dialog', { name: 'Agent Team', exact: true }) - await action.getByText('No shared tasks yet').waitFor() + await action.getByText('No shared tasks yet. Create them through the conversation.').waitFor() await action.getByText('lead').waitFor() + expect(projectionReads).not.toHaveBeenCalled() expect(await action.getByRole('button', { name: 'New task' }).count()).toBe(0) + expect(await action.getByRole('button', { name: /Refresh/u }).count()).toBe(0) + // Host-side Team mutations reach the open panel through projection frames only. const agent = scaffold.ctx.agents.list()[0]! const task = await scaffold.ctx.agentTeams.createTask(agent, { subject: 'Agent task', description: 'Created by the Team Lead', writeScopes: ['src/web'], }) - await action.getByRole('button', { name: 'Refresh Team' }).click() await action.getByText('Agent task', { exact: true }).waitFor() await action.getByText('Owner: Unowned', { exact: true }).waitFor() await scaffold.ctx.agentTeams.updateTask(agent, { taskId: task.id, expectedRevision: task.revision, action: 'claim', }) - await action.getByRole('button', { name: 'Refresh Team' }).click() await action.getByText('In progress', { exact: true }).waitFor() await action.getByText('Owner: lead', { exact: true }).waitFor() expect(await action.getByRole('button', { name: /^(New task|Edit|Complete|Reopen|Delete)$/u }).count()).toBe(0) @@ -107,9 +111,10 @@ describe('web e2e: Agent Teams panel', () => { const snapshot = await captureStableAria(page, '[data-team-panel]', scaffold.workspaceCwd) await compareOrRefreshGolden(PANEL_EXPECTED, snapshot, MODE) + expect(projectionReads).not.toHaveBeenCalled() expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) - await action.getByRole('button', { name: 'Close' }).click() + await page.keyboard.press('Escape') }, 60_000) it('keeps the panel inside the viewport and closes on outside click or Escape', async () => { @@ -131,35 +136,24 @@ describe('web e2e: Agent Teams panel', () => { .every(([x, y]) => element.contains(document.elementFromPoint(x!, y!))) })).toBe(true) } - await panel.getByText('Shared tasks', { exact: true }).click() + await panel.getByRole('heading', { name: /Shared tasks/u }).click() expect(await panel.count()).toBe(1) await page.mouse.click(2, 2) await panel.waitFor({ state: 'detached' }) await page.setViewportSize(viewport) await trigger.focus() await page.keyboard.press('Enter') - await panel.getByRole('heading', { name: 'Shared tasks' }).waitFor() + await panel.getByRole('heading', { name: /Shared tasks/u }).waitFor() expect(await panel.evaluate(element => element === document.activeElement)).toBe(true) - await page.keyboard.press('Tab') - expect(await panel.getByRole('button', { name: 'Refresh Team' }) - .evaluate(element => element === document.activeElement)).toBe(true) - await page.keyboard.press('Tab') - expect(await panel.getByRole('button', { name: 'Close', exact: true }) - .evaluate(element => element === document.activeElement)).toBe(true) await page.keyboard.press('Escape') await panel.waitFor({ state: 'detached' }) expect(await trigger.evaluate(element => element === document.activeElement)).toBe(true) await page.keyboard.press('Enter') await panel.waitFor() - await page.keyboard.press('Tab') - await page.keyboard.press('Tab') - await page.keyboard.press('Enter') - await panel.waitFor({ state: 'detached' }) - expect(await trigger.evaluate(element => element === document.activeElement)).toBe(true) - await page.keyboard.press('Enter') - await panel.waitFor() const outside = page.getByRole('button', { name: 'Settings', exact: true }) await outside.focus() + expect(await panel.count()).toBe(1) + await page.keyboard.press('Escape') await panel.waitFor({ state: 'detached' }) expect(await outside.evaluate(element => element === document.activeElement)).toBe(true) } finally { @@ -167,7 +161,50 @@ describe('web e2e: Agent Teams panel', () => { } }) + it('keeps the hovered panel open when the pointer returns directly to its trigger', async () => { + const trigger = page.locator('[data-team-action]').getByRole('button', { name: 'Agent Team', exact: true }) + const panel = page.getByRole('dialog', { name: 'Agent Team', exact: true }) + await trigger.hover() + await panel.waitFor() + await panel.hover() + await trigger.hover() + // The assertion spans the 120ms dismissal grace while the pointer stays on the trigger. + await page.waitForTimeout(180) + expect(await panel.count()).toBe(1) + await page.keyboard.press('Escape') + }) + + it('offers expansion when resizing makes a task description exceed two lines', async () => { + const agent = scaffold.ctx.agents.list()[0]! + await scaffold.ctx.agentTeams.createTask(agent, { + subject: 'Responsive task', + description: 'Check the task description before you start work. '.repeat(3), + writeScopes: [], + }) + const viewport = page.viewportSize()! + const trigger = page.locator('[data-team-action]').getByRole('button', { name: 'Agent Team', exact: true }) + const panel = page.getByRole('dialog', { name: 'Agent Team', exact: true }) + const card = panel.locator('article').filter({ hasText: 'Responsive task' }) + await trigger.click() + await card.waitFor() + expect(await card.getByRole('button', { name: 'Show more' }).count()).toBe(0) + try { + await page.setViewportSize({ width: 360, height: 700 }) + await card.getByRole('button', { name: 'Show more' }).waitFor() + expect(await card.locator('p').evaluate(p => p.scrollHeight > p.clientHeight)).toBe(true) + await compareOrRefreshGolden(RESIZE_EXPECTED, await captureStableAria(page, '[data-team-panel]', scaffold.workspaceCwd), MODE) + await card.getByRole('button', { name: 'Show more' }).click() + expect(await card.locator('p').evaluate(p => p.scrollHeight === p.clientHeight)).toBe(true) + await card.getByRole('button', { name: 'Show less' }).click() + await page.setViewportSize(viewport) + await expect.poll(() => card.getByRole('button', { name: 'Show more' }).count()).toBe(0) + } finally { + await page.keyboard.press('Escape') + await page.setViewportSize(viewport) + } + }) + it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => { - await assertFixtureInventory(SNAPSHOT_DIR, ['task.expected.md']) + await assertFixtureInventory(SNAPSHOT_DIR, ['task.expected.md', 'resize.expected.md']) }) }) 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/chat-scroll-contract.e2e.ts b/apps/web/tests/chat-scroll-contract.e2e.ts index a2fb4cc149..ef315336ea 100644 --- a/apps/web/tests/chat-scroll-contract.e2e.ts +++ b/apps/web/tests/chat-scroll-contract.e2e.ts @@ -524,6 +524,75 @@ describe('web e2e: long Chat scroll contract', () => { await browser?.close() }) + it.skipIf(MODE === 'record')('keeps floating controls anchored outside the clipped transcript', async () => { + await withScrollWorld({ + failureShot: 'web-e2e-chat-floating-controls', + seeds: [{ fixture: HISTORY_FIXTURE, id: HISTORY_SESSION_ID }], + }, async (world) => { + await openSeed(world.page, HISTORY_FIXTURE, HISTORY_FIXTURE.markers.assistant(HISTORY_FIXTURE.turns)) + const host = world.page.locator('[data-conversation-scroll]') + const backToBottom = world.page.getByRole('button', { name: 'Back to bottom', exact: true }) + const expectControls = async (): Promise => { + await expect.poll(() => host.evaluate((element) => { + const rail = element.querySelector('nav[aria-label="Turn navigation"]') + const button = element.querySelector('button[aria-label="Back to bottom"]') + const composer = element.querySelector('[data-composer-seat]') + if (rail === null || button === null || composer === null) return Infinity + const viewport = element.getBoundingClientRect() + const railBox = rail.getBoundingClientRect() + const buttonBox = button.getBoundingClientRect() + const composerBox = composer.getBoundingClientRect() + const top = viewport.top + element.clientTop + return Math.max( + Math.abs((railBox.top + railBox.bottom) / 2 - (top + composerBox.top) / 2), + Math.abs(viewport.left + element.clientLeft + element.clientWidth - railBox.right - 12), + Math.abs(composerBox.top - buttonBox.bottom - 16), + ) + }), { timeout: 10_000 }).toBeLessThanOrEqual(GEOMETRY_TOLERANCE) + } + + await wheelTranscript(world.page, -1_200) + await backToBottom.waitFor({ timeout: 10_000 }) + await expectControls() + await wheelTranscript(world.page, -1_200) + await expectControls() + + const seat = world.page.locator('[data-composer-seat]') + const initialHeight = await seat.evaluate(element => element.getBoundingClientRect().height) + const composer = world.page.locator('[data-composer-input][contenteditable="true"]').last() + await composer.fill(Array.from({ length: 8 }, (_, index) => `draft line ${index}`).join('\n')) + await expect.poll(() => seat.evaluate(element => element.getBoundingClientRect().height)) + .toBeGreaterThan(initialHeight + 40) + await expectControls() + await composer.fill('') + await expectControls() + + const rail = world.page.getByRole('navigation', { name: 'Turn navigation', includeHidden: true }) + const frame = rail.locator('../..') + const originalStyle = await frame.getAttribute('style') + try { + for (const clearance of [16, 8]) { + for (const contentWidth of [901, 900, 899, 901]) { + await frame.evaluate((element, { clearance, contentWidth }) => { + element.style.width = `${contentWidth + 2 * (clearance + 16)}px` + element.style.setProperty('--dsh-composer-side-clearance', `${clearance}px`) + }, { clearance, contentWidth }) + await expect.poll(() => rail.isVisible()).toBe(contentWidth > 900) + } + } + } finally { + await frame.evaluate((element, style) => { + if (style === null) element.removeAttribute('style') + else element.setAttribute('style', style) + }, originalStyle) + } + await expectControls() + await backToBottom.click() + await expectBottom(world.page) + assertClean(world) + }) + }) + it.skipIf(MODE === 'record')('preserves the reader anchor when history and streaming arrive concurrently', async () => { await withScrollWorld({ failureShot: 'web-e2e-chat-scroll-history-stream', @@ -608,6 +677,131 @@ describe('web e2e: long Chat scroll contract', () => { }) }, 180_000) + it.skipIf(MODE === 'record')('follows a growing process group independently of the outer transcript', async () => { + const parts = [ + 'GROUP_SCROLL_START\n\n', + ...Array.from({ length: 4 }, (_, batch) => Array.from({ length: 20 }, (_, row) => + `Group batch ${batch} paragraph ${row}: inspect the next recorded operation.\n\n`).join('')), + 'GROUP_SCROLL_END\n\n', + ] + const chunks: StreamChunk[] = [ + { type: 'block-start', index: 0, blockType: 'reasoning' }, + ...parts.map((text): StreamChunk => ({ type: 'reasoning-delta', index: 0, text })), + { type: 'block-end', index: 0, block: { type: 'reasoning', text: parts.join('') } }, + { type: 'block-start', index: 1, blockType: 'text' }, + { type: 'text-delta', index: 1, text: 'GROUP_SCROLL_DONE' }, + { type: 'block-end', index: 1, block: { type: 'text', text: 'GROUP_SCROLL_DONE' } }, + { type: 'usage', usage: { inputTokens: 256, outputTokens: 512 } }, + { type: 'finish', reason: { kind: 'stop' } }, + ] + await withScrollWorld({ + failureShot: 'web-e2e-chat-process-follow', + replay: [replayEntry(chunks)], + seeds: [{ fixture: HISTORY_FIXTURE, id: HISTORY_SESSION_ID }], + }, async (world) => { + await world.page.emulateMedia({ reducedMotion: 'no-preference' }) + await openSeed(world.page, HISTORY_FIXTURE, HISTORY_FIXTURE.markers.assistant(HISTORY_FIXTURE.turns)) + const gates = parts.slice(1).map(() => Promise.withResolvers()) + const dispose = world.scaffold.ctx.on('llm/stream', async function* (_options, next) { + let index = -1 + for await (const chunk of next()) { + if (chunk.type === 'reasoning-delta') await gates[index++]?.promise + yield chunk + } + }) + const settled = world.scaffold.whenTurnSettled(60_000) + try { + await world.page.locator('[data-composer-input][contenteditable="true"]').last().fill( + 'Inspect the recorded operations while I scroll the conversation.', + ) + await world.page.getByRole('button', { name: 'Send message', exact: true }).click() + const group = world.page.locator('[data-step-process]:has([data-variant="think"][data-state="running"])') + await group.locator('[data-process-activity]').click() + await group.locator('[data-disclosure-row]').click() + const body = group.locator('[data-step-process-body]') + await nextPaint(world.page) + expect(await body.evaluate(element => element.scrollHeight <= element.clientHeight)).toBe(true) + const height = () => body.evaluate(element => element.scrollHeight) + const top = () => body.evaluate(element => element.scrollTop) + const expectGroupBottom = async (): Promise => { + await expect.poll(() => body.evaluate(element => + element.scrollHeight - element.clientHeight - element.scrollTop)) + .toBeLessThanOrEqual(GEOMETRY_TOLERANCE) + } + const grow = async (index: number): Promise => { + const before = await height() + gates[index]!.resolve(undefined) + await expect.poll(height).toBeGreaterThan(before + 200) + } + + // Outer following is a precondition independent of opening the inner disclosure. + const backToBottom = world.page.getByRole('button', { name: 'Back to bottom', exact: true }) + await wheelTranscript(world.page, -400) + await backToBottom.click() + await expectBottom(world.page) + await grow(0) + await expectGroupBottom() + await expectBottom(world.page) + // Per-frame growth exercises the real observer and native animation + // independently of transport batching while the next model chunk waits. + const content = group.locator('[data-step-process-content]') + const heightStyle = await content.evaluate(element => element.style.height) + const beforeGrowth = await top() + try { + const duringGrowth = await body.evaluate(async (scroller) => { + const content = scroller.querySelector('[data-step-process-content]')! + const initialHeight = content.getBoundingClientRect().height + let halfway = scroller.scrollTop + for (let frame = 1; frame <= 60; frame++) { + content.style.height = `${initialHeight + frame * 20}px` + await new Promise(resolve => requestAnimationFrame(() => { resolve() })) + if (frame === 30) halfway = scroller.scrollTop + } + return halfway + }) + expect(duringGrowth).toBeGreaterThan(beforeGrowth + 20) + await expectGroupBottom() + } finally { + await content.evaluate((element, previous) => { element.style.height = previous }, heightStyle) + } + await expectGroupBottom() + await wheelTranscript(world.page, -800) + await backToBottom.waitFor() + const outerTop = (await scrollGeometry(world.page)).scrollTop + await grow(1) + await expectGroupBottom() + expect(Math.abs((await scrollGeometry(world.page)).scrollTop - outerTop)).toBeLessThanOrEqual(GEOMETRY_TOLERANCE) + + await backToBottom.click() + await expectBottom(world.page) + const pinnedTop = await top() + await body.hover() + await world.page.mouse.wheel(0, -160) + await expect.poll(top).toBeLessThan(pinnedTop - 100) + await nextPaint(world.page) + const readerTop = await top() + await grow(2) + expect(Math.abs(await top() - readerTop)).toBeLessThanOrEqual(GEOMETRY_TOLERANCE) + await expectBottom(world.page) + + await group.locator('[data-process-activity]').click() + await group.locator('[data-process-activity]').click() + await expectGroupBottom() + + await body.hover() + await world.page.mouse.wheel(0, 10_000) + await expectGroupBottom() + await grow(3) + await expectGroupBottom() + assertClean(world) + } finally { + for (const gate of gates) gate.resolve(undefined) + dispose() + await settled + } + }) + }) + it.skipIf(MODE === 'record')('virtualizes the outline rail and jumps to an unloaded turn', async () => { await withScrollWorld({ failureShot: 'web-e2e-turn-rail-jump', diff --git a/apps/web/tests/declared-reasoning.e2e.ts b/apps/web/tests/declared-reasoning.e2e.ts index 897e192cab..6079e187bf 100644 --- a/apps/web/tests/declared-reasoning.e2e.ts +++ b/apps/web/tests/declared-reasoning.e2e.ts @@ -6,9 +6,9 @@ import { readFile } from 'node:fs/promises' import { fileURLToPath } from 'node:url' import { join } from 'node:path' -import type { Browser, Page } from 'playwright' -import { chromium } from 'playwright' -import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import type { Browser, Page, Request } from 'playwright' +import { chromium, webkit } from 'playwright' +import { afterAll, beforeAll, describe, expect, it, onTestFailed, onTestFinished } from 'vitest' import { assertFixtureInventory, captureStableAria, compareOrRefreshGolden, launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold, @@ -19,9 +19,13 @@ import { ZH_BROWSER_LOCALE, connectFreshWorkspaceZh, saveFailureShot } from './s const OVERLAY = fileURLToPath(new URL('./declared-reasoning.overlay.yml', import.meta.url)) const SNAPSHOT_DIR = fileURLToPath(new URL('./expected/declared-reasoning', import.meta.url)) const UI_EXPECTED = fileURLToPath(new URL('./expected/declared-reasoning/ui.expected.md', import.meta.url)) +const POINTER_EXPECTED = fileURLToPath(new URL('./expected/declared-reasoning/pointer-menu.expected.md', import.meta.url)) const MODE = webSnapshotMode() -describe.skipIf(MODE === 'record')('web e2e: declared reasoning efforts reach the composer', () => { +describe.skipIf(MODE === 'record').each([ + { name: 'Chromium', engine: chromium }, + { name: 'WebKit', engine: webkit }, +])('web e2e: declared reasoning efforts reach the composer ($name)', ({ engine }) => { let scaffold: WebScaffold let browser: Browser let page: Page @@ -39,15 +43,17 @@ describe.skipIf(MODE === 'record')('web e2e: declared reasoning efforts reach th displayName: 'Acme Gateway', api: 'openai-completions', baseURL: 'https://gateway.acme.example/v1', - models: [{ - id: 'acme-think', - name: 'Acme Think', + models: [ + { id: 'acme-think', name: 'Acme Think' }, + { id: 'acme-swift', name: 'Acme Swift' }, + ].map(model => ({ + ...model, reasoningEfforts: { off: null, high: 'high', max: 'ultra' }, - }], + })), }, }, }) - browser = await chromium.launch() + browser = await engine.launch() page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE }) tripwire = watchConsole(page) await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) @@ -56,12 +62,15 @@ describe.skipIf(MODE === 'record')('web e2e: declared reasoning efforts reach th }, 120_000) afterAll(async () => { - await browser?.close() - await scaffold?.close() + try { + await browser?.close() + } finally { + await scaffold?.close() + } }) it('offers exactly the declared levels and records the picked one', async () => { - onTestFailed(() => saveFailureShot(page, 'web-e2e-declared-reasoning')) + onTestFailed(() => saveFailureShot(page, `web-e2e-declared-reasoning-${engine.name()}`)) const trigger = page.getByRole('button', { name: /^选择模型/ }) await trigger.waitFor({ timeout: 15_000 }) await trigger.click() @@ -125,7 +134,117 @@ describe.skipIf(MODE === 'record')('web e2e: declared reasoning efforts reach th expect(tripwire.pageErrors).toEqual([]) }, 60_000) + it('opens from the pointer with keyboard focus and closes from the trigger in every pane', async () => { + onTestFailed(() => saveFailureShot(page, `web-e2e-model-trigger-${engine.name()}`)) + const trigger = page.getByRole('button', { name: /^选择模型/ }) + const menu = page.getByRole('menu') + for (const pane of ['root', 'model', 'effort']) { + await page.locator('[data-composer-input][contenteditable="true"]').focus() + await trigger.click() + await expect.poll(() => trigger.evaluate(element => element === document.activeElement)).toBe(true) + if (pane === 'root') { + await page.keyboard.press('ArrowDown') + await expect.poll(() => page.getByRole('menuitem', { name: /^模型/ }) + .evaluate(element => element === document.activeElement)).toBe(true) + } else { + await page.getByRole('menuitem', { name: pane === 'model' ? /^模型/ : /推理等级/ }).click() + await expect.poll(() => page.locator('[role="menuitemradio"][aria-checked="true"]') + .evaluate(element => element === document.activeElement)).toBe(true) + } + await trigger.click() + await menu.waitFor({ state: 'detached' }) + await expect.poll(() => trigger.evaluate(element => element === document.activeElement)).toBe(true) + } + expect(tripwire.pageErrors).toEqual([]) + }) + + it('selects model and effort by mouse and keeps keyboard control after cancelled or rejected clicks', async () => { + onTestFailed(() => saveFailureShot(page, `web-e2e-model-pointer-${engine.name()}`)) + let selections = 0 + const countSelection = (request: Request): void => { + if (new URL(request.url()).pathname.endsWith('/session/selectModel')) selections++ + } + page.on('request', countSelection) + onTestFinished(() => { page.off('request', countSelection) }) + const trigger = page.getByRole('button', { name: /^选择模型/ }) + const menu = page.getByRole('menu') + await trigger.click() + await page.getByRole('menuitem', { name: /^模型/ }).click() + const current = page.getByRole('menuitemradio', { name: 'Acme Think', exact: true }) + const target = page.getByRole('menuitemradio', { name: 'Acme Swift', exact: true }) + await expect.poll(() => current.evaluate(element => element === document.activeElement)).toBe(true) + + // Native mousedown must not blur the focused row and unmount the menu before click in WebKit. + await target.getByText('Acme Swift', { exact: true }).hover() + await page.mouse.down() + try { + await expect.poll(() => menu.count()).toBe(1) + await expect.poll(() => current.evaluate(element => element === document.activeElement)).toBe(true) + await page.getByText('Acme Gateway', { exact: true }).hover() + } finally { + await page.mouse.up() + } + // Later selection counts also include any request from this cancelled press. + expect(selections).toBe(0) + await page.keyboard.press('ArrowDown') + await expect.poll(() => target.evaluate(element => element === document.activeElement)).toBe(true) + await page.keyboard.press('Escape') + await page.keyboard.press('Shift+Tab') + await menu.waitFor({ state: 'detached' }) + + await trigger.click() + await page.getByRole('menuitem', { name: /^模型/ }).click() + await target.getByText('Acme Swift', { exact: true }).click() + await menu.waitFor({ state: 'detached' }) + expect(selections).toBe(1) + expect(scaffold.ctx.agentDefaultModel.currentSelection()?.model).toBe('acme-swift') + + await trigger.click() + await page.getByRole('menuitem', { name: /推理等级/ }).click() + await page.getByRole('menuitemradio', { name: 'Max', exact: true }).click() + await menu.waitFor({ state: 'detached' }) + expect(selections).toBe(2) + expect(scaffold.ctx.agentDefaultModel.currentSelection()?.reasoningEffort).toBe('max') + + await page.route('**/api/session/selectModel', async (route) => { + const envelope = route.request().postDataJSON() as { rpcId: string } + await route.fulfill({ + json: { + type: 'server-response', rpcId: envelope.rpcId, + result: { + ok: false, + error: { code: 'session/writer-held', message: 'writer held', details: { sessionId: 'held-session' } }, + }, + }, + }) + }, { times: 1 }) + await trigger.click() + await page.getByRole('menuitem', { name: /^模型/ }).click() + await expect.poll(() => target.evaluate(element => element === document.activeElement)).toBe(true) + await current.click() + await page.getByRole('alert').waitFor() + expect(selections).toBe(3) + await compareOrRefreshGolden(POINTER_EXPECTED, await captureStableAria(page, '[role="menu"]', scaffold.workspaceCwd), MODE) + await expect.poll(() => trigger.evaluate(element => element === document.activeElement)).toBe(true) + await page.keyboard.press('Tab') + await expect.poll(() => target.evaluate(element => element === document.activeElement)).toBe(true) + await page.keyboard.press('ArrowUp') + await expect.poll(() => current.evaluate(element => element === document.activeElement)).toBe(true) + await page.keyboard.press('Escape') + await page.keyboard.press('Escape') + await menu.waitFor({ state: 'detached' }) + + await trigger.click() + await page.getByRole('menuitem', { name: /^模型/ }).click() + await page.locator('[data-composer-input][contenteditable="true"]').focus() + await menu.waitFor({ state: 'detached' }) + await trigger.click() + await page.mouse.click(0, 0) + await menu.waitFor({ state: 'detached' }) + expect(tripwire.pageErrors).toEqual([]) + }) + it('keeps its snapshot inventory closed', async () => { - await assertFixtureInventory(SNAPSHOT_DIR, ['ui.expected.md']) + await assertFixtureInventory(SNAPSHOT_DIR, ['ui.expected.md', 'pointer-menu.expected.md']) }) }) diff --git a/apps/web/tests/document-preview.e2e.ts b/apps/web/tests/document-preview.e2e.ts index af48f48e2c..14c2728288 100644 --- a/apps/web/tests/document-preview.e2e.ts +++ b/apps/web/tests/document-preview.e2e.ts @@ -9,12 +9,12 @@ import { chromium } from 'playwright' import { afterAll, beforeAll, describe, expect, it, onTestFailed, vi } from 'vitest' import { createLaunchEnvironmentSnapshot } from '@deepseek-ai/dsh-launch-environment' import { realOfficeBytes } from './office-fixture.ts' -import { excelFixture, excelHtmlFixture, excelHtmlText, meetingMinutesFixture } from '../../../packages/client/ui-sidebar-documentpreview/tests/excel-fixture.ts' +import { excelFixture, excelFreezeFixture, excelHtmlFixture, excelHtmlText, meetingMinutesFixture } from '../../../packages/client/ui-sidebar-documentpreview/tests/excel-fixture.ts' import { excelDrawingFixture } from '../../../packages/client/ui-sidebar-documentpreview/tests/excel-drawing-fixture.ts' import { xlsFixture } from '../../../packages/client/ui-sidebar-documentpreview/tests/xls-fixture.ts' import { pdfFixture, selectionPdfFixture } from '../../../packages/client/ui-sidebar-documentpreview/tests/pdf-fixture.ts' import { assertFixtureInventory, compareOrRefreshGolden, launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold } from './scaffold.ts' -import { openSettings, connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts' +import { openSettings, connectFreshWorkspace, connectFreshWorkspaceZh, newEnglishPage, saveFailureShot, scrollIntoView } from './support.ts' const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/lifecycle-chrome/session.v3.jsonl', import.meta.url)) const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/document-preview', import.meta.url)) @@ -104,15 +104,39 @@ async function hideDocumentZoom(page: Page, preview: Locator): Promise { await expect.poll(() => preview.locator('[data-document-zoom-controls]').getAttribute('data-document-zoom-visible')).toBeNull() } -it.skipIf(MODE === 'record')('resizes the spreadsheet canvas with its pane while retaining the selected cell', async () => { +/** The canvas fills the space between the formula bar and sheet tabs without a statistics row. */ +async function expectExcelLayout(excel: Locator): Promise { + await expect.poll(() => excel.locator('.fortune-stat-area').isVisible()).toBe(false) + expect(await excel.locator('.luckysheet-sheets-item-function:visible').count()).toBe(0) + await expect.poll(() => excel.evaluate((node) => { + const pane = node.getBoundingClientRect() + const canvas = node.querySelector('canvas')!.getBoundingClientRect() + const formula = node.querySelector('.fortune-workarea')!.getBoundingClientRect() + const tabs = node.querySelector('.luckysheet-sheet-area')!.getBoundingClientRect() + const scroller = node.querySelector('.fortune-sheettab-container')!.getBoundingClientRect() + const controls = [...node.querySelectorAll('.fortune-sheettab-scroll, .fortune-zoom-button')] + .map(control => control.getBoundingClientRect()) + return Math.max(Math.abs(canvas.left - pane.left), Math.abs(canvas.width - pane.width), + Math.abs(canvas.top - formula.bottom), Math.abs(canvas.bottom - tabs.top), Math.abs(tabs.bottom - pane.bottom), + Math.abs(scroller.left - tabs.left), scroller.right - controls[0]!.left, + ...controls.map((control, index) => control.right - (controls[index + 1]?.left ?? tabs.right))) + })).toBeLessThanOrEqual(1) + expect(await excel.locator('.fortune-sheettab-scroll, .fortune-zoom-button').evaluateAll(controls => controls.every((control) => { + const rect = control.getBoundingClientRect() + return control.contains(document.elementFromPoint(rect.x + rect.width / 2, rect.y + rect.height / 2)) + }))).toBe(true) +} + +it.skipIf(MODE === 'record').each(['en-US', 'zh-CN'])('fills the spreadsheet pane with read-only controls in %s', async (locale) => { const scaffold = await launchWebScaffold({ replayFixture: FIXTURE, paceMs: 5, compareReplaySession: false }) let browser: Browser | undefined try { browser = await chromium.launch() - const page = await newEnglishPage(browser) - onTestFailed(async () => { await saveFailureShot(page, `screenshots/0908-document-preview/excel-resize-${process.pid}`) }) + const page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale, timezoneId: 'Asia/Shanghai' }) + const consoleErrors = watchConsole(page) + onTestFailed(async () => { await saveFailureShot(page, `screenshots/0908-document-preview/excel-resize-${locale}-${process.pid}`) }) await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) - await connectFreshWorkspace(page, scaffold.workspaceCwd) + await (locale === 'zh-CN' ? connectFreshWorkspaceZh : connectFreshWorkspace)(page, scaffold.workspaceCwd) const settled = scaffold.whenTurnSettled() const input = page.locator('[data-composer-input]').first() await input.fill(PROMPT) @@ -120,7 +144,14 @@ it.skipIf(MODE === 'record')('resizes the spreadsheet canvas with its pane while const sessionId = await settled const cwd = scaffold.ctx.agents.get(sessionId)?.session.header.cwd if (cwd === undefined) throw new Error('settled Session has no workspace cwd') - await writeFile(join(cwd, 'budget.xlsx'), await excelFixture()) + const longCellText = 'Monthly export/import matrix and category comparison. 各商品类别分月出口/进口矩阵与结构对比图(公式汇总) '.repeat(3) + await Promise.all([ + writeFile(join(cwd, 'budget.xlsx'), await excelFixture()), + writeFile(join(cwd, 'meeting.xlsx'), await meetingMinutesFixture()), + writeFile(join(cwd, 'legacy.xls'), xlsFixture()), + writeFile(join(cwd, 'values.csv'), `${longCellText},2\n3,4\n`), + writeFile(join(cwd, 'values.tsv'), '1\t2\n3\t4\n'), + ]) await page.getByText('LIGHTHOUSE', { exact: true }).waitFor() const column = page.locator('[data-rightbar-col]') await page.locator('[data-sidebar-right-expand]').click() @@ -132,6 +163,16 @@ it.skipIf(MODE === 'record')('resizes the spreadsheet canvas with its pane while await excel.locator('.fortune-sheet-overlay').click({ position: { x: 140, y: 30 } }) const formula = excel.locator('.fortune-fx-input') await expect.poll(() => formula.innerText()).toBe('46281') + await page.keyboard.press('Shift+ArrowLeft') + const selection = excel.locator('.fortune-name-box') + await expect.poll(() => selection.innerText()).toBe('A1:B1') + await expectExcelLayout(excel) + await page.context().grantPermissions(['clipboard-read', 'clipboard-write'], { origin: new URL(page.url()).origin }) + await page.keyboard.press('ControlOrMeta+C') + await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toContain('42\t2026-09-16') + const arrow = excel.locator('.luckysheet-sheets-item-function').first() + await arrow.evaluate((node) => { (node as HTMLElement).focus() }) + expect(await arrow.evaluate(node => document.activeElement === node)).toBe(false) const canvas = excel.locator('canvas').first() const originalCanvas = await canvas.elementHandle() const original = await canvas.boundingBox() @@ -145,17 +186,227 @@ it.skipIf(MODE === 'record')('resizes the spreadsheet canvas with its pane while await expect.poll(() => formula.innerText()).toBe('46281') expect(await originalCanvas.evaluate(node => node.isConnected)).toBe(true) expect(page.viewportSize()).toEqual(viewport) - await successShot(page, 'excel-resize-wide') + await expectExcelLayout(excel) + await successShot(page, `excel-resize-wide-${locale}`) + await layout.evaluate((node) => { node.textContent = '[data-sidebar-right-panel] { width: 360px !important; }' }) + await expect.poll(async () => Math.round((await panel.boundingBox())!.width)).toBe(360) + await expectExcelLayout(excel) + expect(await excel.locator('.fortune-sheettab-container').evaluate((node) => { + const selected = node.querySelector('.luckysheet-sheets-item-active')!.getBoundingClientRect() + return selected.right - node.getBoundingClientRect().right + })).toBeLessThanOrEqual(1) + await page.setViewportSize({ width: 1680, height: 600 }) + await expect.poll(async () => Math.round(original.height - (await canvas.boundingBox())!.height)).toBe(400) + await expectExcelLayout(excel) + expect(await selection.innerText()).toBe('A1:B1') + await successShot(page, `excel-resize-narrow-${locale}`) + await page.setViewportSize(viewport!) await layout.evaluate((node) => { node.textContent = '' }) await expect.poll(async () => Math.round((await canvas.boundingBox())!.width)).toBe(Math.round(original.width)) expect(await formula.innerText()).toBe('46281') - await successShot(page, 'excel-resize-restored') + expect(await selection.innerText()).toBe('A1:B1') + expect(await originalCanvas.evaluate(node => node.isConnected)).toBe(true) + await expectExcelLayout(excel) + await successShot(page, `excel-resize-restored-${locale}`) + const zoom = excel.locator('.fortune-zoom-ratio-current') + await excel.locator('.fortune-zoom-button').first().click() + await expect.poll(() => zoom.innerText()).toBe('90%') + await excel.locator('.fortune-zoom-button').last().click() + await expect.poll(() => zoom.innerText()).toBe('100%') + await zoom.click() + await excel.locator('.fortune-zoom-ratio-item').getByText('150%', { exact: true }).click() + await expect.poll(() => zoom.innerText()).toBe('150%') + await zoom.click() + await excel.locator('.fortune-zoom-ratio-item').getByText('100%', { exact: true }).click() + const filesTab = column.locator('[data-dockkit-tab]').filter({ has: page.getByText(locale === 'zh-CN' ? '文件' : 'Files', { exact: true }) }) + const preview = column.locator('[data-textpreview-url]') + await openPreviewFile(column, filesTab, preview, 'meeting.xlsx') + const tabScroller = excel.locator('.fortune-sheettab-container-c') + const activeSheet = excel.locator('.luckysheet-sheets-item-active .luckysheet-sheets-item-name') + await expect.poll(() => activeSheet.innerText()).toBe('会议信息') + const meetingCanvas = await canvas.elementHandle() + if (meetingCanvas === null) throw new Error('meeting spreadsheet canvas is unavailable') + await expectExcelLayout(excel) + await excel.locator('.fortune-sheet-overlay').click({ position: { x: 60, y: 40 } }) + await expect.poll(() => formula.innerText()).toBe('会议纪要') + await expect.poll(() => selection.innerText()).toBe('A1') + for (const width of [1000, 360, 1000, 360]) { + await layout.evaluate((node, width) => { node.textContent = `[data-sidebar-right-panel] { width: ${width}px !important; }` }, width) + await expect.poll(async () => Math.round((await panel.boundingBox())!.width)).toBe(width) + await expectExcelLayout(excel) + expect(await meetingCanvas.evaluate(node => node.isConnected)).toBe(true) + expect(await selection.innerText()).toBe('A1') + await expect.poll(() => excel.locator('.fortune-sheettab-scroll').count()).toBe(width === 360 ? 2 : 0) + } + for (const direction of ['right', 'left'] as const) { + const arrow = excel.locator(`#fortune-sheettab-${direction}scroll`) + for (let step = 0; step < 8; step += 1) { + const { offset, maximum } = await tabScroller.evaluate(node => ({ + offset: node.scrollLeft, maximum: node.scrollWidth - node.clientWidth, + })) + const target = direction === 'right' ? Math.min(offset + 150, maximum) : Math.max(offset - 150, 0) + if (Math.abs(target - offset) <= 1) break + await arrow.click() + await expect.poll(() => tabScroller.evaluate(node => node.scrollLeft)).toBeCloseTo(target, 0) + } + const sheetName = direction === 'right' ? '填写说明' : '会议信息' + await excel.locator('.luckysheet-sheets-item-name').getByText(sheetName, { exact: true }).click() + await expect.poll(() => activeSheet.innerText()).toBe(sheetName) + await expectExcelLayout(excel) + await successShot(page, `excel-navigation-${direction}-${locale}`) + } + expect(await tabScroller.evaluate(node => Math.abs( + node.firstElementChild!.getBoundingClientRect().left - node.getBoundingClientRect().left, + ))).toBeLessThanOrEqual(1) + await excel.locator('.fortune-zoom-button').first().click() + await expect.poll(() => zoom.innerText()).toBe('90%') + await excel.locator('.fortune-zoom-button').last().click() + await expect.poll(() => zoom.innerText()).toBe('100%') + await zoom.click() + await excel.locator('.fortune-zoom-ratio-item').getByText('150%', { exact: true }).click() + await expect.poll(() => zoom.innerText()).toBe('150%') + expect(await activeSheet.innerText()).toBe('会议信息') + await expectExcelLayout(excel) + await successShot(page, `excel-navigation-zoom-${locale}`) + await layout.evaluate((node) => { node.textContent = '' }) + await meetingCanvas.dispose() + for (const colorScheme of ['light', 'dark'] as const) { + await page.emulateMedia({ colorScheme }) + await expect.poll(() => page.locator('body').getAttribute('data-ds-dark-theme')).toBe(colorScheme === 'dark' ? '' : null) + for (const name of ['budget.xlsx', 'meeting.xlsx', 'legacy.xls', 'values.csv', 'values.tsv']) { + await openPreviewFile(column, filesTab, preview, name) + await excel.locator('.fortune-sheet-overlay').waitFor() + await expectExcelLayout(excel) + if (name === 'values.csv') { + await excel.locator('.fortune-sheet-overlay').click({ position: { x: 70, y: 30 } }) + await expect.poll(() => formula.textContent()).toBe(longCellText) + await layout.evaluate((node) => { node.textContent = '[data-sidebar-right-panel] { width: 360px !important; }' }) + await expectExcelLayout(excel) + await expect.poll(() => formula.evaluate((node) => { + const range = document.createRange() + range.selectNodeContents(node) + return range.getClientRects().length + })).toBe(1) + expect(await formula.evaluate(node => node.scrollHeight <= node.clientHeight)).toBe(true) + expect(await formula.evaluate(node => node.scrollWidth > node.clientWidth)).toBe(true) + await formula.hover() + await page.mouse.wheel(400, 0) + await expect.poll(() => formula.evaluate(node => node.scrollLeft)).toBeGreaterThan(0) + await successShot(page, `excel-formula-single-line-${locale}-${colorScheme}`) + await layout.evaluate((node) => { node.textContent = '' }) + await expectExcelLayout(excel) + } + await successShot(page, `excel-controls-${name}-${locale}-${colorScheme}`) + } + } + expect(consoleErrors.pageErrors).toEqual([]) await originalCanvas.dispose() } finally { try { await browser?.close() } finally { await scaffold.close() } } }) +/** 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) => { + const canvas = node.querySelector('canvas')! + const bounds = canvas.getBoundingClientRect() + const scale = canvas.width / bounds.width + const columnHeader = node.querySelector('.fortune-col-header')! + const rowHeader = node.querySelector('.fortune-row-header')! + const columnHandle = node.querySelector('.fortune-cols-freeze-handle')! + const rowHandle = node.querySelector('.fortune-rows-freeze-handle')! + // Canvas coordinates include the 1.5px omitted from FortuneSheet's header elements. + const x = Number.parseFloat(rowHeader.style.width) + 1.5 + Number.parseFloat(columnHandle.style.left) - columnHeader.scrollLeft + const y = Number.parseFloat(columnHeader.style.height) + 1.5 + Number.parseFloat(rowHandle.style.top) - rowHeader.scrollTop - 2 + const context = canvas.getContext('2d')! + let ink = 0 + for (let offset = -Math.round(scale); offset < 0; offset += 1) { + const px = direction === 'x' ? Math.floor(x * scale) + offset : Math.floor((bounds.width - 25) * scale) + const py = direction === 'y' ? Math.floor(y * scale) + offset : Math.floor((y + 15) * scale) + const pixel = context.getImageData(px, py, 1, 1).data + ink += 255 - pixel[0]! + } + return ink / scale + }, axis) +} + +it.skipIf(MODE === 'record').each([1, 2])('keeps frozen headings without dividers at DPR %i', async (deviceScaleFactor) => { + const scaffold = await launchWebScaffold({ replayFixture: FIXTURE, paceMs: 5, compareReplaySession: false }) + let browser: Browser | undefined + try { + browser = await chromium.launch() + const page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, deviceScaleFactor, locale: 'en-US' }) + const consoleErrors = watchConsole(page) + onTestFailed(async () => { await saveFailureShot(page, `screenshots/0908-document-preview/excel-freeze-${deviceScaleFactor}-${process.pid}`) }) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await connectFreshWorkspace(page, scaffold.workspaceCwd) + const settled = scaffold.whenTurnSettled() + await page.locator('[data-composer-input]').first().fill(PROMPT) + await page.locator('[data-composer-input]').first().press('Enter') + const cwd = scaffold.ctx.agents.get(await settled)?.session.header.cwd + if (cwd === undefined) throw new Error('settled Session has no workspace cwd') + await writeFile(join(cwd, 'freeze.xlsx'), await excelFreezeFixture()) + await page.getByText('LIGHTHOUSE', { exact: true }).waitFor() + const column = page.locator('[data-rightbar-col]') + await page.locator('[data-sidebar-right-expand]').click() + await column.locator('[data-sidebar-right-guide-entry="files"]').click() + await column.locator('[data-files-entry="file"]').getByRole('button', { name: 'freeze.xlsx', exact: true }).click() + const excel = column.locator('[data-excel-preview]') + const selectTopLeft = async () => { + await excel.locator('.fortune-sheet-overlay').click({ position: { x: 80, y: 35 } }) + return await excel.locator('.fortune-fx-input').innerText() + } + for (const sheet of ['Both', 'Rows', 'Columns', 'None']) { + await excel.locator('.luckysheet-sheets-item-name').getByText(sheet, { exact: true }).click() + await expect.poll(selectTopLeft).toBe('R1C1') + for (const handle of ['.fortune-cols-freeze-handle', '.fortune-rows-freeze-handle']) { + expect(await excel.locator(handle).isVisible()).toBe(false) + expect(await excel.locator(handle).evaluate(node => node.getClientRects().length)).toBe(0) + } + for (const ratio of ['100%', '150%']) { + await excel.locator('.fortune-zoom-ratio-current').click() + await excel.locator('.fortune-zoom-ratio-item').getByText(ratio, { exact: true }).click() + await expect.poll(() => excel.locator('.fortune-zoom-ratio-current').innerText()).toBe(ratio) + await excel.locator('.fortune-fx-input').click() + for (const axis of ['x', 'y'] as const) { + if (sheet === 'Both' || sheet === (axis === 'x' ? 'Columns' : 'Rows')) { + await expect.poll(() => freezeDividerInk(excel, axis)).toBe(0) + } + } + await successShot(page, `excel-freeze-${sheet}-${ratio}-${deviceScaleFactor}`) + } + await excel.locator('.luckysheet-scrollbar-x').evaluate((node) => { node.scrollLeft = 240 }) + await excel.locator('.luckysheet-scrollbar-y').evaluate((node) => { node.scrollTop = 240 }) + await expect.poll(async () => { + const value = await selectTopLeft() + const match = /^R(\d+)C(\d+)$/.exec(value) + return match && { rowFixed: match[1] === '1', columnFixed: match[2] === '1' } + }).toEqual({ rowFixed: sheet === 'Both' || sheet === 'Rows', columnFixed: sheet === 'Both' || sheet === 'Columns' }) + await successShot(page, `excel-freeze-scrolled-${sheet}-${deviceScaleFactor}`) + } + await excel.locator('.luckysheet-sheets-item-name').getByText('Both', { exact: true }).click() + await expect.poll(selectTopLeft).toBe('R1C1') + const canvas = await excel.locator('canvas').first().elementHandle() + const selection = await excel.locator('.fortune-name-box').innerText() + const layout = await page.addStyleTag({ content: '[data-sidebar-right-panel] { width: 360px !important; }' }) + for (const colorScheme of ['light', 'dark'] as const) { + await page.emulateMedia({ colorScheme }) + await expect.poll(() => page.locator('body').getAttribute('data-ds-dark-theme')).toBe(colorScheme === 'dark' ? '' : null) + await expectExcelLayout(excel) + await successShot(page, `excel-freeze-narrow-${colorScheme}-${deviceScaleFactor}`) + } + await layout.evaluate(node => node.parentNode!.removeChild(node)) + await expectExcelLayout(excel) + expect(await canvas!.evaluate(node => node.isConnected)).toBe(true) + expect(await excel.locator('.fortune-name-box').innerText()).toBe(selection) + await canvas!.dispose() + expect(consoleErrors.pageErrors).toEqual([]) + } finally { + try { await browser?.close() } finally { await scaffold.close() } + } +}) + describe.skipIf(MODE === 'record')('web e2e: document preview through Files', () => { let scaffold: WebScaffold let browser: Browser @@ -396,13 +647,13 @@ else process.exit(1); const markdownImages: string[] = [] for (const alt of ['relative image', 'absolute image', 'reference image']) { const image = preview.getByRole('img', { name: alt, exact: true }) - await image.scrollIntoViewIfNeeded() + await scrollIntoView(image) await expect.poll(() => image.evaluate((node: HTMLImageElement) => node.complete && node.naturalWidth > 0)).toBe(true) const source = new URL(await image.getAttribute('src') ?? '') expect(source.pathname).toBe('/api/file') markdownImages.push(alt) } - await preview.getByRole('heading', { name: heading, exact: true }).scrollIntoViewIfNeeded() + await scrollIntoView(preview.getByRole('heading', { name: heading, exact: true })) await successShot(page, 'markdown') const markdownTab = column.locator('[data-dockkit-tab]').filter({ has: page.getByText('smoke.md', { exact: true }) }) const markdownTabId = await markdownTab.getAttribute('data-dockkit-tab') @@ -567,7 +818,7 @@ else process.exit(1); expect(firstColor).toBe('red') const workerNames = await Promise.all(page.workers().map(worker => worker.evaluate(() => self.name))) expect(workerNames).toContain('dsh-pdf') - await preview.locator('[data-pdf-page="2"]').scrollIntoViewIfNeeded() + await scrollIntoView(preview.locator('[data-pdf-page="2"]')) const secondPage = preview.getByRole('img', { name: 'PDF page 2', exact: true }) await secondPage.waitFor({ state: 'visible', timeout: 30_000 }) await expect.poll(() => canvasColor(secondPage), { timeout: 30_000 }).toBe('blue') @@ -580,7 +831,7 @@ else process.exit(1); await filesTab.click() await column.locator('[data-files-state="tree"]').waitFor({ state: 'visible' }) await pdfTab.click() - await preview.locator('[data-pdf-page="2"]').scrollIntoViewIfNeeded() + await scrollIntoView(preview.locator('[data-pdf-page="2"]')) await secondPage.waitFor({ state: 'visible', timeout: 30_000 }) await expect.poll(() => canvasColor(secondPage), { timeout: 30_000 }).toBe('blue') const restoredColor = await canvasColor(secondPage) 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 7bccb7e4c2..4ce3d29216 100644 --- a/apps/web/tests/expected/agent-preset-authoring/section.expected.md +++ b/apps/web/tests/expected/agent-preset-authoring/section.expected.md @@ -9,7 +9,7 @@ - button "关闭" - heading "Agent 预设" [level=2] - paragraph: 选择 Agent 的工具和工作方式。日常任务用「标准模式」,扩展 DSH 的能力用「创造模式」。 - - text: 新任务可选择模式 Beta + - text: 新任务可选择模式 实验性 - paragraph: 开启后,可为每个新任务选择模式,并在这里设置默认值。关闭后,新任务使用应用配置的默认预设。已有任务不受影响。 - switch "新任务可选择模式" [checked] - heading "内置" [level=3] @@ -21,6 +21,7 @@ - text: 处理代码、文件和资料,适合大多数任务。Agent 会按需使用检索、编辑和终端等工具。 - 'button "模式说明: 标准模式"': 模式说明 - 'button "如何使用: 标准模式"': 如何使用 + - 'button "查看配置: 标准模式"': 查看配置 - listitem: - 'button "设为新任务默认: PTC 模式"': - text: PTC 模式 内置 @@ -28,6 +29,7 @@ - text: 包含标准模式的所有能力,更适合批量调用工具,并对结果进行筛选、整理、去重、统计或汇总的任务。 - 'button "模式说明: PTC 模式"': 模式说明 - 'button "如何使用: PTC 模式"': 如何使用 + - 'button "查看配置: PTC 模式"': 查看配置 - listitem: - 'button "设为新任务默认: 极简模式"': - text: 极简模式 内置 @@ -35,6 +37,7 @@ - text: Agent 仅使用终端工具完成任务,适合测试和对比其基础表现。 - 'button "模式说明: 极简模式"': 模式说明 - 'button "如何使用: 极简模式"': 如何使用 + - 'button "查看配置: 极简模式"': 查看配置 - listitem: - 'button "设为新任务默认: 创造模式"': - text: 创造模式 内置 @@ -42,5 +45,6 @@ - text: 用对话定制 DSH:让 Agent 编写插件,添加新功能或界面;也能组合工具和提示词,创建自己的模式。 - 'button "模式说明: 创造模式"': 模式说明 - 'button "如何使用: 创造模式"': 如何使用 + - 'button "查看配置: 创造模式"': 查看配置 - heading "自定义" [level=3] - button "让 Agent 帮我创建预设模式" diff --git a/apps/web/tests/expected/agent-preset-selection/header.expected.md b/apps/web/tests/expected/agent-preset-selection/header.expected.md index 2b87066124..81cc3c1175 100644 --- a/apps/web/tests/expected/agent-preset-selection/header.expected.md +++ b/apps/web/tests/expected/agent-preset-selection/header.expected.md @@ -1,5 +1,5 @@ - navigation "Session hierarchy": Seeded turn -- text: Minimal mode - button "1 subagent" +- text: Minimal mode - button "More actions" - button "Open right sidebar" 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..06c0c5258b 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" diff --git a/apps/web/tests/expected/declared-reasoning/pointer-menu.expected.md b/apps/web/tests/expected/declared-reasoning/pointer-menu.expected.md new file mode 100644 index 0000000000..21a27082cd --- /dev/null +++ b/apps/web/tests/expected/declared-reasoning/pointer-menu.expected.md @@ -0,0 +1,9 @@ +- menu "模型与推理等级": + - group "DeepSeek": + - text: DeepSeek + - menuitemradio "DeepSeek-V4-Flash" + - menuitemradio "DeepSeek-V4-Flash-Vision-Exp" + - group "Acme Gateway": + - text: Acme Gateway + - menuitemradio "Acme Think" + - menuitemradio "Acme Swift" [checked] diff --git a/apps/web/tests/expected/plugin-config/official.expected.md b/apps/web/tests/expected/plugin-config/official.expected.md index c1cac31c15..dd8775171f 100644 --- a/apps/web/tests/expected/plugin-config/official.expected.md +++ b/apps/web/tests/expected/plugin-config/official.expected.md @@ -7,11 +7,11 @@ - list: - listitem: - button "查看 智能体团队": 智能体团队 - - text: Beta 启用团队协作、团队工具、成员列表和共享任务看板。 + - text: 实验性 启用团队协作、团队工具、成员列表和共享任务看板。 - switch "启用 智能体团队" - listitem: - button "查看 语音输入": 语音输入 - - text: Beta 在本机使用 SenseVoice 转写录音;首次使用需安装依赖 + - text: 实验性 在本机使用 SenseVoice 转写录音;首次使用需安装依赖 - switch "启用 语音输入" - listitem: - button "查看 终端": 终端 diff --git a/apps/web/tests/expected/plugin-install-github/failed.expected.md b/apps/web/tests/expected/plugin-install-github/failed.expected.md new file mode 100644 index 0000000000..74395524dc --- /dev/null +++ b/apps/web/tests/expected/plugin-install-github/failed.expected.md @@ -0,0 +1,6 @@ +- dialog "无法访问 GitHub": + - heading "无法访问 GitHub" [level=2] + - button "关闭" + - paragraph: 请尝试其他安装来源。 + - button "取消" + - button "改用国内镜像" diff --git a/apps/web/tests/expected/plugin-install-github/mirror.expected.md b/apps/web/tests/expected/plugin-install-github/mirror.expected.md new file mode 100644 index 0000000000..fbdc9a5116 --- /dev/null +++ b/apps/web/tests/expected/plugin-install-github/mirror.expected.md @@ -0,0 +1,8 @@ +- dialog "添加插件": + - heading "添加插件" [level=2] + - button "关闭" + - textbox "插件包名": + - /placeholder: 例如 @deepseek-ai/dsh-experimental-auto-review + - button "插件安装引导和示例" + - button "安装源 中国大陆镜像源" + - button "安装" [disabled] diff --git a/apps/web/tests/expected/plugin-install-github/timeout.expected.md b/apps/web/tests/expected/plugin-install-github/timeout.expected.md new file mode 100644 index 0000000000..0d76ddbe98 --- /dev/null +++ b/apps/web/tests/expected/plugin-install-github/timeout.expected.md @@ -0,0 +1,6 @@ +- dialog "连接 GitHub 超时": + - heading "连接 GitHub 超时" [level=2] + - button "关闭" + - paragraph: 请尝试其他安装来源。 + - button "取消" + - button "改用国内镜像" diff --git a/apps/web/tests/expected/plugin-manager/live-enabled.expected.md b/apps/web/tests/expected/plugin-manager/live-enabled.expected.md index c9b222597d..f7d0ebbe50 100644 --- a/apps/web/tests/expected/plugin-manager/live-enabled.expected.md +++ b/apps/web/tests/expected/plugin-manager/live-enabled.expected.md @@ -7,11 +7,11 @@ - list: - listitem: - button "查看 智能体团队": 智能体团队 - - text: Beta 启用团队协作、团队工具、成员列表和共享任务看板。 + - text: 实验性 启用团队协作、团队工具、成员列表和共享任务看板。 - switch "启用 智能体团队" - listitem: - button "查看 语音输入": 语音输入 - - text: Beta 在本机使用 SenseVoice 转写录音;首次使用需安装依赖 + - text: 实验性 在本机使用 SenseVoice 转写录音;首次使用需安装依赖 - switch "启用 语音输入" - listitem: - button "查看 终端": 终端 diff --git a/apps/web/tests/expected/plugin-manager/manager.expected.md b/apps/web/tests/expected/plugin-manager/manager.expected.md index bb3c6cce17..3f33e93302 100644 --- a/apps/web/tests/expected/plugin-manager/manager.expected.md +++ b/apps/web/tests/expected/plugin-manager/manager.expected.md @@ -7,11 +7,11 @@ - list: - listitem: - button "查看 智能体团队": 智能体团队 - - text: Beta 启用团队协作、团队工具、成员列表和共享任务看板。 + - text: 实验性 启用团队协作、团队工具、成员列表和共享任务看板。 - switch "启用 智能体团队" - listitem: - button "查看 语音输入": 语音输入 - - text: Beta 在本机使用 SenseVoice 转写录音;首次使用需安装依赖 + - text: 实验性 在本机使用 SenseVoice 转写录音;首次使用需安装依赖 - switch "启用 语音输入" - listitem: - button "查看 终端": 终端 diff --git a/apps/web/tests/expected/voice-download-fallback.expected.md b/apps/web/tests/expected/voice-download-fallback.expected.md new file mode 100644 index 0000000000..898f471215 --- /dev/null +++ b/apps/web/tests/expected/voice-download-fallback.expected.md @@ -0,0 +1,4 @@ +- alert: + - paragraph: "Cannot download model.int8.onnx: the download service returned HTTP 502." + - paragraph: Confirm that the download address is available and the proxy can reach the download service, or retry later. + - text: "Download source: https://fallback-model-source.example" diff --git a/apps/web/tests/expected/voice-source-auto.expected.md b/apps/web/tests/expected/voice-source-auto.expected.md new file mode 100644 index 0000000000..a64c41e080 --- /dev/null +++ b/apps/web/tests/expected/voice-source-auto.expected.md @@ -0,0 +1,19 @@ +- strong: SenseVoiceSmall (INT8) +- paragraph: Local models will be downloaded to the machine running DSH. No Python or compiler is required. +- term: Disk space +- definition: Allow about 1 GB for models, runtime, and download caches +- term: Memory +- definition: About 1 GB with the model loaded; recognition may use more +- term: First setup +- definition: Allow roughly 1–10 minutes, depending on the network and machine +- text: These are estimates; slow networks may take longer. Downloads are reused; idle models release memory by default. +- button "First use downloads the local recognition models.": + - text: First use downloads the local recognition models. + - status +- text: Model download source +- combobox "Model download source": + - option "Automatic (recommended)" [selected] + - option "Hugging Face" + - option "HF-Mirror (China mirror)" +- text: Prefer the first responding source and try another if a download fails. +- button "Download and prepare" diff --git a/apps/web/tests/expected/voice-source-manual.expected.md b/apps/web/tests/expected/voice-source-manual.expected.md new file mode 100644 index 0000000000..1867a5bb80 --- /dev/null +++ b/apps/web/tests/expected/voice-source-manual.expected.md @@ -0,0 +1,19 @@ +- strong: SenseVoiceSmall (INT8) +- paragraph: Local models will be downloaded to the machine running DSH. No Python or compiler is required. +- term: Disk space +- definition: Allow about 1 GB for models, runtime, and download caches +- term: Memory +- definition: About 1 GB with the model loaded; recognition may use more +- term: First setup +- definition: Allow roughly 1–10 minutes, depending on the network and machine +- text: These are estimates; slow networks may take longer. Downloads are reused; idle models release memory by default. +- button "First use downloads the local recognition models.": + - text: First use downloads the local recognition models. + - status +- text: Model download source +- combobox "Model download source": + - option "Automatic (recommended)" + - option "Hugging Face" + - option "HF-Mirror (China mirror)" [selected] +- text: Download only from the selected source. Verified downloaded files are reused. +- button "Download and prepare" diff --git a/apps/web/tests/fixtures/restart-startup.js b/apps/web/tests/fixtures/restart-startup.js new file mode 100644 index 0000000000..2967fcee10 --- /dev/null +++ b/apps/web/tests/fixtures/restart-startup.js @@ -0,0 +1,30 @@ +/** Hold one Host service until the restart test releases application startup. */ +export const name = 'web-restart-startup' + +/** Keep the carrier services available while Session Controller startup is held. */ +export const inject = ['typertGateway', 'connection', 'webServer'] + +/** + * Provide the test-only dependency after startup has been released. + * @param {import('@deepseek-ai/cordis').Context} ctx - Host plugin context. + * @returns {Promise} Once the barrier is released or the plugin is disposed. + */ +export async function apply(ctx) { + if (process.env.DSH_WEB_RESTART_HOLD_STARTUP === '1') { + if (typeof process.send !== 'function') throw new Error('web restart startup fixture requires an IPC channel') + const released = Promise.withResolvers() + const resume = (message) => { + if (message === 'resume-startup') released.resolve(true) + } + ctx.effect(() => { + process.on('message', resume) + return () => { + process.off('message', resume) + released.resolve(false) + } + }, 'web-restart: startup barrier') + process.send('startup-blocked') + if (!await released.promise) return + } + ctx.reflect.provide('webRestartReady', {}) +} diff --git a/apps/web/tests/fixtures/restart-startup.overlay.yml b/apps/web/tests/fixtures/restart-startup.overlay.yml new file mode 100644 index 0000000000..3f7b9bc45c --- /dev/null +++ b/apps/web/tests/fixtures/restart-startup.overlay.yml @@ -0,0 +1,5 @@ +- id: session-controller + inject: [webRestartReady] +- insert: + - id: web-restart-startup + name: ./restart-startup.js diff --git a/apps/web/tests/goal-bar.e2e.ts b/apps/web/tests/goal-bar.e2e.ts index 23bdf194cd..a93c9d7c13 100644 --- a/apps/web/tests/goal-bar.e2e.ts +++ b/apps/web/tests/goal-bar.e2e.ts @@ -13,7 +13,7 @@ import { assertFixtureInventory, captureStableAria, compareOrRefreshGolden, launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' -import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts' +import { connectFreshWorkspace, expectTooltipOnTop, newEnglishPage, saveFailureShot } from './support.ts' const SNAPSHOT_DIR = fileURLToPath(new URL('./expected/goal-bar', import.meta.url)) const ACTIVE_EXPECTED = join(SNAPSHOT_DIR, 'active.expected.md') @@ -58,6 +58,9 @@ describe('web e2e: goal bar clear convergence', () => { await pause.hover() const pauseTooltip = page.getByRole('tooltip', { name: 'Pause goal', exact: true }) await pauseTooltip.waitFor() + // The strip rides directly above the input card, which paints later; the + // bubble must escape the strip's stacking context instead of landing under it. + await expectTooltipOnTop(pauseTooltip) const tooltipGeometry = await page.evaluate(() => { const element = document.querySelector('[role="tooltip"]') if (element === null) return null @@ -67,11 +70,18 @@ describe('web e2e: goal bar clear convergence', () => { declaredTop: Number.parseFloat(element.style.top), tooltipCenter: tooltip.left + tooltip.width / 2, tooltipTop: tooltip.top, + left: tooltip.left, + right: tooltip.right, + viewportWidth: window.innerWidth, + visibility: element.style.visibility, } }) expect(tooltipGeometry).not.toBeNull() expect(Math.abs(tooltipGeometry!.tooltipCenter - tooltipGeometry!.declaredLeft)).toBeLessThan(2) expect(Math.abs(tooltipGeometry!.tooltipTop - tooltipGeometry!.declaredTop)).toBeLessThan(2) + expect(tooltipGeometry!.visibility).toBe('visible') + expect(tooltipGeometry!.left).toBeGreaterThanOrEqual(12) + expect(tooltipGeometry!.right).toBeLessThanOrEqual(tooltipGeometry!.viewportWidth - 12) await page.mouse.move(0, 0) await pauseTooltip.waitFor({ state: 'hidden' }) const snapshot = await captureStableAria(page, '[data-goal-bar]', scaffold.workspaceCwd) diff --git a/apps/web/tests/idle-submission-handoff.e2e.ts b/apps/web/tests/idle-submission-handoff.e2e.ts new file mode 100644 index 0000000000..9d27909d4d --- /dev/null +++ b/apps/web/tests/idle-submission-handoff.e2e.ts @@ -0,0 +1,275 @@ +/** Keep idle submissions in Chat across independently delivered Inbox and transcript frames. */ +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { chromium, type Page } from 'playwright' +import { expect, it } from 'vitest' +import type { InboxState } from '@deepseek-ai/dsh-agent/types' +import type { SessionEvent } from '@deepseek-ai/dsh-session/types' +import { deriveReplayScript, parseSessionLog } from '@deepseek-ai/dsh-llm-replay' +import { + parseRemoteStreamClientMessage, parseRemoteStreamServerMessage, +} from '@deepseek-ai/dsh-api-gateway/stream-protocol' +import { launchWebScaffold, seedSession, watchConsole, webSnapshotMode } from './scaffold.ts' +import { newEnglishPage } from './support.ts' + +const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/live-interactions/session.v3.jsonl', import.meta.url)) +const SESSION_ID = 'idle-submission-handoff' +const TEXT = 'IDLE_SUBMISSION_HANDOFF Keep this message in the conversation.' + +interface Delivery { + readonly stream: string + readonly inbox?: InboxState + readonly event?: SessionEvent + readonly send: () => void +} + +/** Delay real Host frames while preserving FIFO within each logical stream. */ +class HistoryDeliveryGate { + private readonly streams = new Map() + private buffered: Delivery[] = [] + holding = false + + async install(page: Page): Promise { + await page.routeWebSocket('**/api/remote.mux', (socket) => { + const server = socket.connectToServer() + socket.onMessage((message) => { + const frame = parseRemoteStreamClientMessage(String(message)) + if (frame.type === 'open') this.streams.set(frame.streamId, frame.endpoint) + server.send(message) + }) + server.onMessage((message) => { + const frame = parseRemoteStreamServerMessage(String(message)) + const endpoint = this.streams.get(frame.streamId) + if (!this.holding || (endpoint !== 'session/control' && endpoint !== 'session/follow')) { + socket.send(message) + return + } + const control = endpoint === 'session/control' && frame.type === 'item' + ? frame.value as { type: string; sessionId?: string; key?: string; value?: InboxState } : undefined + const inbox = control?.type === 'projection' && control.sessionId === SESSION_ID && control.key === 'inbox' + ? control.value : undefined + const follow = endpoint === 'session/follow' && frame.type === 'item' + ? frame.value as { type: string; event?: SessionEvent } : undefined + this.buffered.push({ stream: frame.streamId, send: () => { socket.send(message) }, + ...follow?.event === undefined ? {} : { event: follow.event }, ...inbox === undefined ? {} : { inbox } }) + }) + }) + } + + inboxDelivery(queued: boolean, text = TEXT): Delivery | undefined { + return this.buffered.find(item => item.inbox !== undefined && item.inbox['next-turn'].some(message => + message.content.some(block => block.type === 'text' && block.text === text)) === queued) + } + + admissionDelivery(text = TEXT): Delivery | undefined { + return this.buffered.find(item => item.event?.type === 'user/message' + && item.event.data.content.some(block => block.type === 'text' && block.text === text)) + } + + turnStartDelivery(): Delivery | undefined { return this.buffered.find(item => item.event?.type === 'turn/start') } + + turnEndDelivery(): Delivery | undefined { return this.buffered.find(item => item.event?.type === 'turn/end') } + + releaseThrough(target: Delivery): void { + let reached = false + this.buffered = this.buffered.filter((item) => { + if (reached || item.stream !== target.stream) return true + item.send() + reached = item === target + return false + }) + } + + releaseAll(): void { + this.holding = false + for (const item of this.buffered) item.send() + this.buffered = [] + } +} + +async function placement(page: Page, input = TEXT) { + return page.evaluate((text) => { + const matching = (selector: string) => [...document.querySelectorAll(selector)] + .filter(element => element.textContent?.includes(text)) + const echo = matching('[data-submission-echo]') + const dock = matching('[data-queue-dock] li') + const durable = matching('[data-chat-flow-kind="user"]') + return { + echo: echo.length, + dock: dock.length, + durable: durable.length, + messageTop: (echo[0] ?? dock[0] ?? durable[0])?.getBoundingClientRect().top ?? null, + composerTop: document.querySelector('[data-composer-card]')?.getBoundingClientRect().top ?? null, + } + }, input) +} + +it.skipIf(webSnapshotMode() === 'record').each(['inbox-first', 'transcript-first'] as const)( + 'keeps an idle submission in Chat under CPU throttling (%s)', async (order) => { + const scaffold = await launchWebScaffold({ replayFixture: FIXTURE, compareReplaySession: false }) + const releasePrompt = Promise.withResolvers() + const promptBlocked = Promise.withResolvers() + const gate = new HistoryDeliveryGate() + let browser: Awaited> | undefined + try { + await seedSession(scaffold, await readFile(FIXTURE, 'utf8'), SESSION_ID) + browser = await chromium.launch() + const page = await newEnglishPage(browser) + const tripwire = watchConsole(page) + await gate.install(page) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await page.locator('[role="treeitem"]').first().click() + await page.locator('[role="treeitem"]').nth(1).click() + await page.locator('[data-chat-flow-kind="user"]').first().waitFor() + await page.getByRole('button', { name: 'Send message', exact: true }).waitFor() + const cdp = await page.context().newCDPSession(page) + await cdp.send('Emulation.setCPUThrottlingRate', { rate: 6 }) + await page.route('**/api/session/prompt', async (route) => { + promptBlocked.resolve(undefined) + await releasePrompt.promise + await route.continue() + }, { times: 1 }) + const trace: Array<{ phase: string } & Awaited>> = [] + const settled = scaffold.whenTurnSettled() + gate.holding = true + const input = page.locator('[data-composer-input]').first() + await input.fill(TEXT) + await input.press('Enter') + await promptBlocked.promise + await expect.poll(() => placement(page)).toMatchObject({ echo: 1, dock: 0, durable: 0 }) + trace.push({ phase: 'optimistic', ...await placement(page) }) + releasePrompt.resolve(undefined) + + if (order === 'transcript-first') { + await expect.poll(() => gate.turnStartDelivery()).toBeDefined() + gate.releaseThrough(gate.turnStartDelivery()!) + await page.locator('[data-turn-process]').last().filter({ hasText: 'Deep diving' }).waitFor() + await expect.poll(() => placement(page)).toMatchObject({ echo: 1, dock: 0, durable: 0 }) + trace.push({ phase: 'turn-start', ...await placement(page) }) + await expect.poll(() => gate.admissionDelivery()).toBeDefined() + gate.releaseThrough(gate.admissionDelivery()!) + await expect.poll(() => placement(page)).toMatchObject({ echo: 0, dock: 0, durable: 1 }) + trace.push({ phase: 'durable-before-inbox', ...await placement(page) }) + } + + await expect.poll(() => gate.inboxDelivery(true)).toBeDefined() + gate.releaseThrough(gate.inboxDelivery(true)!) + await expect.poll(() => placement(page)).toMatchObject({ echo: order === 'inbox-first' ? 1 : 0, dock: 0, durable: order === 'transcript-first' ? 1 : 0 }) + trace.push({ phase: 'inbox', ...await placement(page) }) + + await expect.poll(() => gate.inboxDelivery(false)).toBeDefined() + gate.releaseThrough(gate.inboxDelivery(false)!) + await expect.poll(() => placement(page)).toMatchObject({ echo: order === 'inbox-first' ? 1 : 0, dock: 0, durable: order === 'transcript-first' ? 1 : 0 }) + trace.push({ phase: 'claim-projection', ...await placement(page) }) + + if (order === 'inbox-first') { + await expect.poll(() => gate.turnStartDelivery()).toBeDefined() + gate.releaseThrough(gate.turnStartDelivery()!) + await page.locator('[data-turn-process]').last().filter({ hasText: 'Deep diving' }).waitFor() + await expect.poll(() => placement(page)).toMatchObject({ echo: 1, dock: 0, durable: 0 }) + trace.push({ phase: 'turn-start', ...await placement(page) }) + } + + gate.releaseAll() + await expect.poll(() => placement(page)).toMatchObject({ echo: 0, dock: 0, durable: 1 }) + trace.push({ phase: 'durable', ...await placement(page) }) + for (const entry of trace) { + expect(entry.messageTop, entry.phase).toBe(trace[0]?.messageTop) + expect(entry.composerTop, entry.phase).toBe(trace[0]?.composerTop) + } + await settled + await cdp.send('Emulation.setCPUThrottlingRate', { rate: 1 }) + await cdp.detach() + expect(tripwire.pageErrors).toEqual([]) + } finally { + releasePrompt.resolve(undefined) + gate.releaseAll() + try { await browser?.close() } finally { await scaffold.close() } + } + }, +) + +it.skipIf(webSnapshotMode() === 'record').each(['ABC', 'ACB', 'BAC', 'BCA', 'CAB', 'CBA'])( + 'hands off three rapid submissions through all Turns in Host order %s', async (hostOrder) => { + const root = await mkdtemp(join(tmpdir(), 'dsh-input-handoff-')) + const releases = new Map(['A', 'B', 'C'].map(id => [id, Promise.withResolvers()])) + const blocked = new Set() + const gate = new HistoryDeliveryGate() + let browser: Awaited> | undefined + let scaffold: Awaited> | undefined + try { + const fixture = await readFile(FIXTURE, 'utf8') + const recorded = deriveReplayScript(parseSessionLog(fixture)) + expect(recorded).toHaveLength(1) + const override = join(root, 'replay.override.json') + await writeFile(override, JSON.stringify([recorded[0], recorded[0], recorded[0]])) + scaffold = await launchWebScaffold({ replayFixture: FIXTURE, replayOverride: override, compareReplaySession: false }) + await seedSession(scaffold, fixture, SESSION_ID) + browser = await chromium.launch() + const page = await newEnglishPage(browser) + const tripwire = watchConsole(page) + await gate.install(page) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await page.locator('[role="treeitem"]').first().click() + await page.locator('[role="treeitem"]').nth(1).click() + await page.locator('[data-chat-flow-kind="user"]').first().waitFor() + await page.getByRole('button', { name: 'Send message', exact: true }).waitFor() + const text = (id: string) => `RAPID_INPUT_${id}` + await page.route('**/api/session/prompt', async (route) => { + const request = route.request().postData() ?? '' + const id = [...releases.keys()].find(id => request.includes(text(id))) + if (id === undefined) throw new Error('unexpected prompt in rapid input fixture') + blocked.add(id) + await releases.get(id)!.promise + await route.continue() + }) + gate.holding = true + const input = page.locator('[data-composer-input]').first() + for (const id of releases.keys()) { + await input.fill(text(id)) + await input.press('Enter') + await expect.poll(() => blocked.has(id)).toBe(true) + await expect.poll(() => placement(page, text(id))).toMatchObject({ echo: 1, dock: 0, durable: 0 }) + } + const entered: string[] = [] + for (const id of hostOrder) { + const settled = scaffold.whenTurnSettled() + releases.get(id)!.resolve(undefined) + await expect.poll(() => gate.turnStartDelivery()).toBeDefined() + gate.releaseThrough(gate.turnStartDelivery()!) + await expect.poll(() => gate.admissionDelivery(text(id))).toBeDefined() + gate.releaseThrough(gate.admissionDelivery(text(id))!) + entered.push(id) + for (const candidate of releases.keys()) { + const admitted = entered.includes(candidate) + await expect.poll(() => placement(page, text(candidate))).toMatchObject({ + echo: admitted ? 0 : 1, dock: 0, durable: admitted ? 1 : 0, + }) + } + await expect.poll(() => gate.inboxDelivery(true, text(id))).toBeDefined() + gate.releaseThrough(gate.inboxDelivery(true, text(id))!) + await expect.poll(() => placement(page, text(id))).toMatchObject({ echo: 0, dock: 0, durable: 1 }) + await expect.poll(() => gate.inboxDelivery(false, text(id))).toBeDefined() + gate.releaseThrough(gate.inboxDelivery(false, text(id))!) + await expect.poll(() => gate.turnEndDelivery()).toBeDefined() + gate.releaseThrough(gate.turnEndDelivery()!) + await settled + } + gate.releaseAll() + const actual = await page.locator('[data-chat-flow-kind="user"]').allTextContents() + expect(actual.filter(value => value.includes('RAPID_INPUT_')).map(value => + [...releases.keys()].find(id => value.includes(text(id))))).toEqual(hostOrder.split('')) + expect(await page.locator('[data-submission-echo]').count()).toBe(0) + expect(await page.locator('[data-queue-dock]').count()).toBe(0) + expect(tripwire.pageErrors).toEqual([]) + } finally { + for (const release of releases.values()) release.resolve(undefined) + gate.releaseAll() + try { await browser?.close() } finally { + try { await scaffold?.close() } finally { await rm(root, { recursive: true, force: true }) } + } + } + }, +) diff --git a/apps/web/tests/message-feedback.e2e.ts b/apps/web/tests/message-feedback.e2e.ts index 69676f37d2..37797460c2 100644 --- a/apps/web/tests/message-feedback.e2e.ts +++ b/apps/web/tests/message-feedback.e2e.ts @@ -11,7 +11,7 @@ import { acknowledgeReloadConnectionLoss, launchWebScaffold, seedSession, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' -import { newEnglishPage, saveFailureShot } from './support.ts' +import { newEnglishPage, saveFailureShot, scrollIntoView } from './support.ts' // Borrowed read-only: this scenario needs any settled assistant message to // address, not a new recording (message-actions / sidebar-scrollbar pattern). @@ -67,7 +67,7 @@ describe('web e2e: durable per-message feedback', () => { await page.getByText('DONE', { exact: true }).waitFor({ timeout: 30_000 }) const like = page.getByRole('button', { name: 'Good response' }).first() await like.waitFor({ timeout: 30_000 }) - await like.scrollIntoViewIfNeeded() + await scrollIntoView(like) await like.hover() await like.click() const dialog = page.getByRole('dialog', { name: 'Submit feedback' }) @@ -111,12 +111,12 @@ describe('web e2e: durable per-message feedback', () => { // the unrated control is what triggers the authoritative re-read. const cold = page.getByRole('button', { name: 'Good response' }).first() await cold.waitFor({ timeout: 30_000 }) - await cold.scrollIntoViewIfNeeded() + await scrollIntoView(cold) await cold.hover() const restored = page.getByRole('button', { name: 'Remove rating' }).first() await restored.waitFor({ timeout: 30_000 }) - await restored.scrollIntoViewIfNeeded() + await scrollIntoView(restored) await restored.hover() await expect.poll(() => restored.getAttribute('aria-pressed'), { timeout: 15_000 }).toBe('true') // The retract label sits on the Dislike side: the Like stays unpressed. diff --git a/apps/web/tests/models-settings.e2e.ts b/apps/web/tests/models-settings.e2e.ts index 3d9fd95906..68857956f3 100644 --- a/apps/web/tests/models-settings.e2e.ts +++ b/apps/web/tests/models-settings.e2e.ts @@ -397,7 +397,7 @@ describe('web e2e: Models settings page configures a dormant provider', () => { const picker = page.getByRole('dialog', { name: '选择要添加的模型' }) await picker.getByRole('button', { name: '取消全选' }).click() await picker.getByRole('searchbox', { name: '搜索模型' }).fill('gpt-6-astra') - await picker.getByRole('checkbox', { name: 'GPT-6 Astra', exact: true }).check() + await picker.getByRole('checkbox', { name: 'gpt-6-astra', exact: true }).check() await picker.getByRole('button', { name: '添加所选' }).click() await dialog.getByRole('button', { name: '模型选项 1' }).click() expect(await image.isChecked()).toBe(true) diff --git a/apps/web/tests/plugin-install-github.e2e.ts b/apps/web/tests/plugin-install-github.e2e.ts new file mode 100644 index 0000000000..1926b6f4e2 --- /dev/null +++ b/apps/web/tests/plugin-install-github.e2e.ts @@ -0,0 +1,105 @@ +// Real Git reaches a controlled proxy failure or stall; the browser +// offers a mirror for a replacement spec without retrying the failed address. +import { once } from 'node:events' +import { mkdtemp, readFile, writeFile, rm } from 'node:fs/promises' +import { createServer, type Socket } from 'node:net' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { chromium } from 'playwright' +import { expect, it, onTestFinished } from 'vitest' +import { launchWebScaffold, captureStableAria, compareOrRefreshGolden, webSnapshotMode, watchConsole } from './scaffold.ts' +import { ZH_BROWSER_LOCALE } from './support.ts' + +it.each(['network', 'timeout'] as const)('offers a mirror after a GitHub %s and waits for replacement input', async (failure) => { + const scratch = await mkdtemp(join(tmpdir(), 'dsh-install-github-')) + onTestFinished(() => rm(scratch, { recursive: true, force: true })) + const sockets = new Set() + let connections = 0 + const proxy = createServer((socket) => { + sockets.add(socket) + socket.on('error', () => {}) + socket.once('close', () => { sockets.delete(socket) }) + socket.once('data', () => { connections++; if (failure === 'network') socket.destroy() }) + }) + onTestFinished(async () => { + for (const socket of sockets) socket.destroy() + await new Promise((resolve, reject) => { proxy.close((error) => { if (error) reject(error); else resolve() }) }) + }) + proxy.listen(0, '127.0.0.1') + await once(proxy, 'listening') + const address = proxy.address() + if (address === null || typeof address === 'string') throw new Error('proxy did not bind a TCP port') + const gitConfig = join(scratch, 'git.config') + await writeFile(gitConfig, `[http "https://github.com/"]\n proxy = http://127.0.0.1:${String(address.port)}\n`) + const overlay = join(scratch, 'cordis.patch.yml') + await writeFile(overlay, '- id: ui-plugin-manager\n config: { registryProbeEnabled: false }\n') + const scaffold = await launchWebScaffold({ + profile: { packages: [], packageManager: { command: process.execPath, args: [], env: { GIT_CONFIG_GLOBAL: gitConfig, GIT_CONFIG_NOSYSTEM: '1' } } }, + extraOverlayPath: overlay, + }) + onTestFinished(() => scaffold.close()) + const browser = await chromium.launch() + onTestFinished(() => browser.close()) + const profile = join(scaffold.harnessHome, 'profiles', 'scaffold') + const manifestPath = join(profile, 'package.json') + const manifestBefore = await readFile(manifestPath, 'utf8') + await writeFile(join(profile, 'config'), 'console.log("https://registry.npmjs.org/")\n') + await writeFile(join(profile, '.attempts'), '') + await writeFile(join(profile, 'add'), ` + const fs = require('node:fs'); + fs.appendFileSync('.attempts', JSON.stringify(process.argv) + '\\n'); + console.error('pnpm must not start after the GitHub connection check fails'); + process.exitCode = 1; + `) + const page = await browser.newPage({ viewport: { width: 1280, height: 800 }, locale: ZH_BROWSER_LOCALE }) + const tripwire = watchConsole(page) + await page.goto(scaffold.authenticatedUrl) + await page.waitForSelector('[class*="frame"]') + if (await page.getByRole('dialog', { name: '设置' }).count() > 0) await page.keyboard.press('Escape') + await page.getByRole('navigation', { name: '全局面板' }).getByRole('button', { name: '插件', exact: true }).click() + await page.getByRole('button', { name: '添加插件', exact: true }).click() + const spec = 'https://github.com/example/dsh-plugin.git' + const title = failure === 'timeout' ? '连接 GitHub 超时' : '无法访问 GitHub' + let dialog = page.getByRole('dialog', { name: '添加插件', exact: true }) + await dialog.getByRole('button', { name: '安装源 默认安装源', exact: true }).waitFor() + await dialog.getByRole('textbox', { name: '包名或地址' }).fill(spec) + expect(await page.getByText(title, { exact: true }).count()).toBe(0) + await dialog.getByRole('button', { name: '安装', exact: true }).click() + dialog = page.getByRole('dialog', { name: title, exact: true }) + await dialog.waitFor() + expect(await page.getByRole('dialog').count()).toBe(1) + expect(await dialog.getByText('请尝试其他安装来源。', { exact: true }).count()).toBe(1) + await compareOrRefreshGolden( + fileURLToPath(new URL(`./expected/plugin-install-github/${failure === 'timeout' ? 'timeout' : 'failed'}.expected.md`, import.meta.url)), + await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd), webSnapshotMode(), + ) + const attempts = await readFile(join(profile, '.attempts'), 'utf8') + expect(attempts).toBe('') + expect(connections).toBeGreaterThan(0) + await expect.poll(() => sockets.size).toBe(0) + const checked = connections + await dialog.getByRole('button', { name: '改用国内镜像', exact: true }).click() + dialog = page.getByRole('dialog', { name: '添加插件', exact: true }) + const input = dialog.getByRole('textbox', { name: '插件包名', exact: true }) + await input.waitFor() + expect(await input.inputValue()).toBe('') + expect(await input.evaluate(element => element === document.activeElement)).toBe(true) + expect(await dialog.getByRole('button', { name: '安装', exact: true }).isDisabled()).toBe(true) + await dialog.getByRole('button', { name: '安装源 中国大陆镜像源', exact: true }).waitFor() + await compareOrRefreshGolden( + fileURLToPath(new URL('./expected/plugin-install-github/mirror.expected.md', import.meta.url)), + await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd), webSnapshotMode(), + ) + expect(await readFile(join(profile, '.attempts'), 'utf8')).toBe(attempts) + expect(connections).toBe(checked) + expect(await readFile(manifestPath, 'utf8')).toBe(manifestBefore) + await input.fill(spec) + await dialog.getByRole('button', { name: '安装', exact: true }).click() + dialog = page.getByRole('dialog', { name: title, exact: true }) + await dialog.getByRole('button', { name: '取消', exact: true }).click() + await dialog.waitFor({ state: 'detached' }) + expect(await readFile(join(profile, '.attempts'), 'utf8')).toBe('') + expect(connections).toBeGreaterThan(checked) + expect(tripwire.pageErrors).toEqual([]) +}) diff --git a/apps/web/tests/plugin-manager.e2e.ts b/apps/web/tests/plugin-manager.e2e.ts index 19a9c96e4d..f16e5adaa8 100644 --- a/apps/web/tests/plugin-manager.e2e.ts +++ b/apps/web/tests/plugin-manager.e2e.ts @@ -128,7 +128,7 @@ describe('web e2e: plugin manager', () => { expect(await panel.locator('[data-plugin-group="bundles"] [data-plugin-package]').count()).toBe(2) expect(await panel.locator('[data-plugin-group="official"] [data-plugin-package]').count()).toBe(2) expect(await panel.locator('[data-plugin-group="official"] [data-plugin-item]').count()).toBe(4) - expect(await panel.getByText('Beta', { exact: true }).count()).toBe(2) + expect(await panel.getByText('实验性', { exact: true }).count()).toBe(2) expect(await panel.getByRole('switch', { name: '启用 语音输入', exact: true }).getAttribute('aria-checked')).toBe('false') // A bundle that is off still shows the rows its patch declares, without switches. await panel.getByRole('button', { name: '查看 @fixture/bundle' }).click() @@ -278,9 +278,12 @@ describe('web e2e: plugin manager', () => { await expect.poll(() => teamRows().filter(entry => entry.fiber?.state === FiberState.ACTIVE).length, { timeout: 20_000 }).toBe(3) await expect.poll(() => toggle.getAttribute('aria-checked')).toBe('true') await action.waitFor({ timeout: 20_000 }) - await action.getByRole('button', { name: /Agent Team/iu }).click() - const teamPanel = teamPage.getByRole('dialog', { name: 'Agent Team', exact: true }) - await teamPanel.getByText('还没有共享任务').waitFor() + await action.getByRole('button', { name: '智能体团队', exact: true }).click() + const teamPanel = teamPage.getByRole('dialog', { name: '智能体团队', exact: true }) + await teamPanel.getByText('Team 暂不可用', { exact: true }).waitFor() + await teamPage.reload({ waitUntil: 'load' }) + await action.getByRole('button', { name: '智能体团队', exact: true }).click() + await teamPanel.getByText('暂无共享任务,可以通过对话创建').waitFor() await teamPanel.getByText('lead', { exact: true }).waitFor() const manifest = JSON.parse(await homeFile('profiles', 'scaffold', 'package.json')) as { dsh: { profile: { bundles: string[] } } diff --git a/apps/web/tests/present.e2e.ts b/apps/web/tests/present.e2e.ts index 146ff77b85..aaf4c1f7d5 100644 --- a/apps/web/tests/present.e2e.ts +++ b/apps/web/tests/present.e2e.ts @@ -14,7 +14,7 @@ import { compareOrRefreshGolden, fixtureUserPrompts, launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' -import { connectFreshWorkspace, expandTurnProcesses, newEnglishPage } from './support.ts' +import { connectFreshWorkspace, expandTurnProcesses, newEnglishPage, scrollIntoView } from './support.ts' const DIR = fileURLToPath(new URL('../../../snapshots/web/present', import.meta.url)) const FIXTURE = join(DIR, 'session.v3.jsonl') @@ -272,7 +272,7 @@ fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path, action, con expect(geometry.openFontSize).toBe('11px') await page.setViewportSize({ width: 480, height: 900 }) const row = page.locator('[data-presented-files-row]') - await row.scrollIntoViewIfNeeded() + await scrollIntoView(row) for (const card of await row.getByRole('button').all()) { const bounds = await card.boundingBox() expect(bounds).not.toBeNull() 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/apps/web/tests/queue-actions.e2e.ts b/apps/web/tests/queue-actions.e2e.ts index c5317cb3fb..9892033837 100644 --- a/apps/web/tests/queue-actions.e2e.ts +++ b/apps/web/tests/queue-actions.e2e.ts @@ -16,7 +16,7 @@ import { assertFixtureInventory, captureExpandedTurnProcessAria, captureStableAria, compareOrRefreshGolden, launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' -import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts' +import { connectFreshWorkspace, expectTooltipOnTop, newEnglishPage, saveFailureShot } from './support.ts' const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/queue-actions', import.meta.url)) const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/live-interactions/session.v3.jsonl', import.meta.url)) @@ -196,6 +196,9 @@ describe('web e2e: queue row actions', () => { await save.hover() const saveTooltip = page.getByRole('tooltip', { name: 'Save queued message', exact: true }) await saveTooltip.waitFor() + // The dock tucks under the input card, which paints later; the bubble must + // escape the panel's stacking context instead of landing behind it. + await expectTooltipOnTop(saveTooltip) const tooltipGeometry = await page.evaluate(() => { const element = document.querySelector('[role="tooltip"]') if (element === null) return null @@ -222,7 +225,9 @@ describe('web e2e: queue row actions', () => { const remainingEdit = page.getByRole('button', { name: 'Edit queued message', exact: true }) await expect.poll(() => remainingEdit.isEnabled(), { timeout: 10_000 }).toBe(true) await remainingEdit.hover() - await page.getByRole('tooltip', { name: 'Edit queued message', exact: true }).waitFor() + const editTooltip = page.getByRole('tooltip', { name: 'Edit queued message', exact: true }) + await editTooltip.waitFor() + await expectTooltipOnTop(editTooltip) const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd) await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE) diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index 6e18a9c3ed..9fde2ea346 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -334,6 +334,8 @@ export interface LaunchOptions { */ profile?: { hmr?: boolean + /** Launcher-owned invocation and environment for profile package operations. */ + packageManager?: ProfileContext['packageManager'] packages: { dir: string; enabled?: boolean }[] /** Additional selected names, including bundles unavailable after an upgrade. */ bundles?: readonly string[] @@ -772,6 +774,7 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise layer.packageName), overlays: processOverlays, telemetryDisabledEnv: undefined, diff --git a/apps/web/tests/seeded-history.e2e.ts b/apps/web/tests/seeded-history.e2e.ts index 1534c7c76d..8419aae826 100644 --- a/apps/web/tests/seeded-history.e2e.ts +++ b/apps/web/tests/seeded-history.e2e.ts @@ -227,6 +227,7 @@ describe('web e2e: seeded history renders through cold resume', () => { let page: Page let tripwire: ReturnType let seededThroughSeq = -1 + let openingWindow: unknown beforeAll(async () => { // The POSIX terminal fixture stays off Windows; the pinned desktop applies @@ -266,6 +267,27 @@ describe('web e2e: seeded history renders through cold resume', () => { browser = await chromium.launch() page = await newEnglishPage(browser) tripwire = watchConsole(page) + if (MODE !== 'record') { + // A one-message tail makes this short recording exercise the real Load earlier path. + let pagedOpening = false + await page.routeWebSocket('**/api/remote.mux', (socket) => { + const server = socket.connectToServer() + socket.onMessage((message) => { + const frame = JSON.parse(String(message)) as { + type: string + endpoint?: string + payload: { args: { request: { maxMessages: number; turnWindow?: { minMessages: number; minTurns: number } } } } + } + if (!pagedOpening && frame.type === 'open' && frame.endpoint === 'session/follow') { + pagedOpening = true + openingWindow = { ...frame.payload.args.request } + frame.payload.args.request.maxMessages = 1 + delete frame.payload.args.request.turnWindow + server.send(JSON.stringify(frame)) + } else server.send(message) + }) + }) + } await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) }, 120_000) @@ -330,6 +352,16 @@ describe('web e2e: seeded history renders through cold resume', () => { await sessionRow.click() // Settled barrier for history: the recorded final assistant text renders. await expect.poll(() => page.getByText('DONE', { exact: true }).count(), { timeout: 15_000 }).toBe(1) + expect(openingWindow).toMatchObject({ maxMessages: 500, turnWindow: { minMessages: 50, minTurns: 2 } }) + expect(await page.getByText(PROMPT, { exact: true }).count()).toBe(0) + const [paging] = await Promise.all([ + page.waitForRequest('**/api/session/page'), + page.getByRole('button', { name: 'Load earlier', exact: true }).click(), + ]) + expect(paging.postDataJSON()).toMatchObject({ + payload: { args: { request: { maxMessages: 500, turnWindow: { minMessages: 50, minTurns: 2 } } } }, + }) + await expect.poll(() => page.getByText(PROMPT, { exact: true }).count(), { timeout: 10_000 }).toBe(1) await expect.poll(() => page.getByText('compact', { exact: true }).count(), { timeout: 10_000 }).toBe(1) await expect.poll(() => page.getByText(/^Compacted \d+ history items \(~\d+ tokens\)$/).count(), { timeout: 10_000, diff --git a/apps/web/tests/server-restart.e2e.ts b/apps/web/tests/server-restart.e2e.ts index c3d5565e40..736165c2dc 100644 --- a/apps/web/tests/server-restart.e2e.ts +++ b/apps/web/tests/server-restart.e2e.ts @@ -14,16 +14,18 @@ class RestartableServer { private child: ChildProcess | undefined private closed: Promise | undefined private output = '' + readonly startupBlocked = Promise.withResolvers() constructor(private readonly world: string, private readonly modelUrl: string) {} - async start(port: number): Promise { + async start(port: number, holdStartup = false): Promise { if (this.child !== undefined) throw new Error('Server is already running') this.output = '' const ready = Promise.withResolvers() const child = spawn(process.execPath, [ join(REPO_ROOT, 'apps/cli/lib/bin.js'), '--profile', 'web', '--patch', fileURLToPath(new URL('./pin-browse-picker.overlay.yml', import.meta.url)), + '--patch', fileURLToPath(new URL('./fixtures/restart-startup.overlay.yml', import.meta.url)), '--no-open', '--port', String(port), ], { cwd: this.world, @@ -32,8 +34,9 @@ class RestartableServer { DSH_HOME: join(this.world, 'home'), DSH_AGENTS_HOME: join(this.world, 'agents'), DSH_TELEMETRY_DISABLED: '1', DEEPSEEK_API_KEY: 'keyless-server-restart-fixture', DEEPSEEK_BASE_URL: this.modelUrl, + DSH_WEB_RESTART_HOLD_STARTUP: holdStartup ? '1' : '0', }, - stdio: ['ignore', 'pipe', 'pipe'], + stdio: ['ignore', 'pipe', 'pipe', 'ipc'], }) this.child = child const receive = (data: Buffer): void => { @@ -43,6 +46,9 @@ class RestartableServer { } child.stdout?.on('data', receive) child.stderr?.on('data', receive) + child.on('message', (message) => { + if (message === 'startup-blocked') this.startupBlocked.resolve(undefined) + }) child.once('error', (error) => { ready.reject(error) }) this.closed = new Promise((resolve) => { child.once('close', (code, signal) => { @@ -58,6 +64,7 @@ class RestartableServer { async stop(): Promise { const child = this.child if (child === undefined) return + this.resumeStartup() let forced = false const timer = setTimeout(() => { forced = true; child.kill('SIGKILL') }, 15_000) try { @@ -70,10 +77,14 @@ class RestartableServer { if (forced) throw new Error('Server did not stop after SIGTERM\n' + this.logs()) } + resumeStartup(): void { + if (this.child?.connected) this.child.send('resume-startup') + } + logs(): string { return this.output.replace(/([?&]token=)[A-Za-z0-9_-]+/gu, '$1') } } -it('keeps an opened Session and draft when the server restarts on the same port', async () => { +it.each([false, true])('keeps the same revision, Session and page across a server restart (delayed startup: %s)', async (holdStartup) => { const world = await mkdtemp(join(tmpdir(), 'dsh-server-restart-')) onTestFinished(() => rm(world, { recursive: true, force: true })) const model = createServer((request, response) => { @@ -126,6 +137,7 @@ it('keeps an opened Session and draft when the server restarts on the same port' onTestFinished(() => browser.close()) const page = await newEnglishPage(browser) const errors: string[] = [] + const streamErrors: unknown[] = [] const graphs: { rev: string; entries: { id: string; rev: string }[] }[] = [] let readyFrames = 0 let sessionBaselines = 0 @@ -140,7 +152,8 @@ it('keeps an opened Session and draft when the server restarts on the same port' }) socket.on('framereceived', ({ payload }) => { if (typeof payload !== 'string') return - const message = JSON.parse(payload) as { type?: string; streamId: string; value?: { type?: string } } + const message = JSON.parse(payload) as { type?: string; streamId: string; value?: { type?: string }; error?: unknown } + if (message.type === 'error') streamErrors.push(message.error) if (message.type === 'item' && message.value?.type === 'ready') readyFrames++ if (message.type === 'item' && endpoints.get(message.streamId) === 'session/follow' && message.value?.type === 'snapshot') sessionBaselines++ @@ -154,7 +167,7 @@ it('keeps an opened Session and draft when the server restarts on the same port' }) onTestFailed(async () => { await saveFailureShot(page, 'web-server-restart-' + scenarioId) - await writeFile(join(REPO_ROOT, '.artifacts', 'server-restart-' + scenarioId + '.json'), JSON.stringify({ errors, graphs, server: server.logs() }, null, 2)) + await writeFile(join(REPO_ROOT, '.artifacts', 'server-restart-' + scenarioId + '.json'), JSON.stringify({ errors, streamErrors, graphs, server: server.logs() }, null, 2)) }) await page.goto(url, { waitUntil: 'load' }) await page.locator('[data-slot="root"]').waitFor({ state: 'attached', timeout: 20_000 }) @@ -177,7 +190,29 @@ it('keeps an opened Session and draft when the server restarts on the same port' page.on('framenavigated', () => { navigations++ }) await server.stop() - const restarted = await server.start(port) + let restarted: string + const restarting = server.start(port, holdStartup) + try { + if (holdStartup) { + await Promise.race([ + server.startupBlocked.promise, + restarting.then(() => { throw new Error('the restarted Host did not hold controller startup') }), + ]) + const opened = await page.evaluate(() => new Promise((resolve) => { + const endpoint = new URL('api/remote.mux', location.href) + endpoint.protocol = 'ws:' + const socket = new WebSocket(endpoint) + let accepted = false + socket.addEventListener('open', () => { accepted = true; socket.close() }) + socket.addEventListener('close', () => { resolve(accepted) }) + })) + expect(opened).toBe(false) + expect(readyFrames).toBe(beforeReady) + } + } finally { + server.resumeStartup() + restarted = await restarting + } expect(new URL(restarted).origin).toBe(new URL(url).origin) await expect.poll(() => readyFrames, { timeout: 30_000 }).toBeGreaterThan(beforeReady) await expect.poll(() => sessionBaselines, { timeout: 30_000 }).toBeGreaterThan(beforeBaseline) @@ -187,7 +222,8 @@ it('keeps an opened Session and draft when the server restarts on the same port' })) expect(errors).toEqual([]) - expect(graphs.at(-1)).toEqual(graphs[beforeGraphs - 1]) + expect(streamErrors).toEqual([]) + for (const graph of graphs.slice(beforeGraphs)) expect(graph).toEqual(graphs[beforeGraphs - 1]) expect(await originalRoot!.evaluate(element => element.isConnected)).toBe(true) expect(await composer.textContent()).toBe(draft) expect(await response.isVisible()).toBe(true) @@ -195,5 +231,6 @@ it('keeps an opened Session and draft when the server restarts on the same port' await composer.press('Enter') await expect.poll(() => response.count(), { timeout: 20_000 }).toBe(2) expect(errors).toEqual([]) + expect(streamErrors).toEqual([]) expect(navigations).toBe(0) }) diff --git a/apps/web/tests/sidebar-terminal.e2e.ts b/apps/web/tests/sidebar-terminal.e2e.ts index aac87b9f54..75ee794ebe 100644 --- a/apps/web/tests/sidebar-terminal.e2e.ts +++ b/apps/web/tests/sidebar-terminal.e2e.ts @@ -475,11 +475,16 @@ describe.skipIf(process.platform === 'win32')('Web sidebar terminal', () => { await page.context().setOffline(false) await page.reload({ waitUntil: 'load' }) await page.locator('[data-sidebar-right-expand]').click() - await expect.poll(() => page.getByRole('alert').innerText()).toContain('no longer exists') + const terminal = page.locator('[data-sidebar-terminal]') + let unavailableSnapshot = '' + // Mounting can refresh a failed recovered view; readiness and comparison use the same DOM sample. + await expect.poll(async () => { + unavailableSnapshot = await terminal.ariaSnapshot() + return unavailableSnapshot + }).toContain('no longer exists') expect(handles).toHaveLength(1) const unavailable = fileURLToPath(new URL('./expected/sidebar-terminal/unavailable.expected.md', import.meta.url)) - const terminal = page.locator('[data-sidebar-terminal]') - await compareOrRefreshGolden(unavailable, await terminal.ariaSnapshot(), webSnapshotMode()) + await compareOrRefreshGolden(unavailable, unavailableSnapshot, webSnapshotMode()) await terminal.screenshot({ path: `${shots}/unavailable.png`, animations: 'disabled' }) const tabCount = await page.locator('[data-dockkit-tab]').count() const create = terminal.getByRole('button', { name: 'New terminal', exact: true }) diff --git a/apps/web/tests/snapshots/agent-team-panel/resize.expected.md b/apps/web/tests/snapshots/agent-team-panel/resize.expected.md new file mode 100644 index 0000000000..19f90be9a3 --- /dev/null +++ b/apps/web/tests/snapshots/agent-team-panel/resize.expected.md @@ -0,0 +1,15 @@ +- dialog "Agent Team": + - heading "Members" [level=3] + - button "lead Current chat Inactive" [disabled] + - heading "Shared tasks 2" [level=3] + - article: + - strong: Agent task + - text: In progress + - paragraph: Created by the Team Lead + - text: "task-1Owner: leadWrite scopes: src/web" + - article: + - strong: Responsive task + - text: Pending + - paragraph: Check the task description before you start work. Check the task description before you start work. Check the task description before you start work. + - button "Show more" + - text: "task-2Owner: UnownedReady" diff --git a/apps/web/tests/snapshots/agent-team-panel/task.expected.md b/apps/web/tests/snapshots/agent-team-panel/task.expected.md index 5184f24d29..9e6f63532e 100644 --- a/apps/web/tests/snapshots/agent-team-panel/task.expected.md +++ b/apps/web/tests/snapshots/agent-team-panel/task.expected.md @@ -1,12 +1,9 @@ - dialog "Agent Team": - - strong: Agent Team - - button "Refresh Team" - - button "Close" - heading "Members" [level=3] - - 'button "lead Inactive · Model: deepseek-v4-flash" [disabled]' - - heading "Shared tasks" [level=3] + - button "lead Current chat Inactive" [disabled] + - heading "Shared tasks 1" [level=3] - article: - strong: Agent task - text: In progress - paragraph: Created by the Team Lead - - text: "task-1 Owner: lead Write scopes: src/web" + - text: "task-1Owner: leadWrite scopes: src/web" diff --git a/apps/web/tests/steering.e2e.ts b/apps/web/tests/steering.e2e.ts index 0006353491..8b83176777 100644 --- a/apps/web/tests/steering.e2e.ts +++ b/apps/web/tests/steering.e2e.ts @@ -11,6 +11,7 @@ import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' import { parseSessionLog } from '@deepseek-ai/dsh-llm-replay' import type { SessionEvent } from '@deepseek-ai/dsh-session' import { expandAssistantStream } from '@deepseek-ai/dsh-llm' +import { parseRemoteStreamServerMessage } from '@deepseek-ai/dsh-api-gateway/stream-protocol' import { acknowledgeReloadConnectionLoss, assertFixtureInventory, captureExpandedTurnProcessAria, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts, @@ -191,13 +192,21 @@ describe('web e2e: mid-turn steering lands durably and visibly', () => { }) }) -describe('web e2e: composer shortcut steers directly', () => { +describe.each(['reconnect', 'delayed-inbox'] as const)('web e2e: composer shortcut steers directly (%s)', (handoff) => { let scaffold: WebScaffold let browser: Browser let page: Page let tripwire: ReturnType let remoteSocket: WebSocketRoute | undefined const sessionEvents: SessionEvent[] = [] + let holdInbox = false + let heldStream: string | undefined + let heldControl: (() => void)[] = [] + const releaseInbox = (): void => { + holdInbox = false + for (const send of heldControl) send() + heldControl = [] + } beforeAll(async () => { scaffold = await launchWebScaffold({ replayFixture: FIXTURE, paceMs: REPLAY_PACE_MS, compareReplaySession: false }) @@ -206,7 +215,17 @@ describe('web e2e: composer shortcut steers directly', () => { page = await newEnglishPage(browser) await page.routeWebSocket('**/api/remote.mux', (route) => { remoteSocket = route - route.connectToServer() + const server = route.connectToServer() + server.onMessage((message) => { + const frame = parseRemoteStreamServerMessage(String(message)) + if (holdInbox && frame.type === 'item') { + const value = frame.value as { type?: string; key?: string } + if (value.type === 'projection' && value.key === 'inbox') heldStream = frame.streamId + } + // Hold the remaining control stream in FIFO order, while history keeps flowing. + if (holdInbox && frame.streamId === heldStream) heldControl.push(() => { route.send(message) }) + else route.send(message) + }) }) tripwire = watchConsole(page) await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) @@ -251,6 +270,7 @@ describe('web e2e: composer shortcut steers directly', () => { return next() }) try { + holdInbox = handoff === 'delayed-inbox' await composer.getByRole('radio', { name: 'Yes' }).click() await composer.getByRole('radio', { name: 'Yes' }).press('Enter') await expect.poll(() => claimed, { timeout: 10_000 }).toBe(true) @@ -259,16 +279,25 @@ describe('web e2e: composer shortcut steers directly', () => { })) expect(await page.getByText(STEER, { exact: true }).count()).toBe(1) expect(await page.locator('[data-submission-echo]').filter({ hasText: STEER }).count()).toBe(1) - const socket = remoteSocket - if (socket === undefined) throw new Error('steering page has no Remote connection') - const warningStart = tripwire.warnings.length - await socket.close({ code: 1012, reason: 'steering reconnect checkpoint' }) - await expect.poll(() => remoteSocket !== socket, { timeout: 10_000 }).toBe(true) - await expect.poll(() => page.locator('[data-submission-echo]').count(), { timeout: 10_000 }).toBe(0) - expect(await page.getByText(STEER, { exact: true }).count()).toBe(0) - acknowledgeReloadConnectionLoss(tripwire, warningStart) + if (handoff === 'reconnect') { + const socket = remoteSocket + if (socket === undefined) throw new Error('steering page has no Remote connection') + const warningStart = tripwire.warnings.length + await socket.close({ code: 1012, reason: 'steering reconnect checkpoint' }) + await expect.poll(() => remoteSocket !== socket, { timeout: 10_000 }).toBe(true) + await expect.poll(() => page.locator('[data-submission-echo]').count(), { timeout: 10_000 }).toBe(0) + expect(await page.getByText(STEER, { exact: true }).count()).toBe(0) + acknowledgeReloadConnectionLoss(tripwire, warningStart) + } else { + await expect.poll(() => heldControl.length, { timeout: 10_000 }).toBeGreaterThan(0) + admission.resolve(undefined) + await page.locator('[data-chat-flow-kind="steering"]').filter({ hasText: STEER }).waitFor({ timeout: 10_000 }) + expect(await page.getByText(STEER, { exact: true }).count()).toBe(1) + expect(await pendingSteering.count()).toBe(0) + } } finally { admission.resolve(undefined) + releaseInbox() stopHolding() await settled } diff --git a/apps/web/tests/subagent-conversation.e2e.ts b/apps/web/tests/subagent-conversation.e2e.ts index 1b8a432cef..14c368b72f 100644 --- a/apps/web/tests/subagent-conversation.e2e.ts +++ b/apps/web/tests/subagent-conversation.e2e.ts @@ -404,7 +404,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () = it('opens child history in the right Sidebar and releases it when closed', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-sidebar-chat')) await page.getByRole('button', { name: '2 subagents' }).hover() - await page.getByRole('button', { name: `Open ${LABEL} in sidebar` }).click() + await page.getByRole('treeitem', { name: new RegExp(LABEL) }).getByRole('button', { name: `Open ${LABEL} in sidebar`, exact: true }).click() const sidebarChat = page.locator('[data-sidebar-chat]') await sidebarChat.getByText(/^Explain event sourcing in one sentence\.Your parent agent id is /).waitFor({ timeout: 15_000 }) await compareOrRefreshGolden( @@ -416,7 +416,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () = await sidebarChat.waitFor({ state: 'detached' }) await page.getByRole('button', { name: '2 subagents' }).hover() - await page.getByRole('button', { name: `Open ${ONE_SHOT_LABEL} in sidebar` }).click() + await page.getByRole('treeitem', { name: new RegExp(ONE_SHOT_LABEL) }).getByRole('button', { name: `Open ${ONE_SHOT_LABEL} in sidebar`, exact: true }).click() await page.locator('[data-sidebar-chat]').getByText( 'One-shot tasks do not accept follow-ups; review the full execution record here.', ).waitFor({ timeout: 15_000 }) @@ -532,16 +532,16 @@ describe('web e2e: persisted subagent conversation and human continuation', () = expect(await nestedDisclosure.count()).toBe(0) const clickArea = nestedRow.locator(':scope > div') expect(await clickArea.count()).toBe(1) - const [treeBox, clickAreaBox] = await Promise.all([ - tree.boundingBox(), + const [menuBox, clickAreaBox] = await Promise.all([ + tree.locator('..').boundingBox(), clickArea.boundingBox(), ]) - expect(treeBox).not.toBeNull() + expect(menuBox).not.toBeNull() expect(clickAreaBox).not.toBeNull() expect([ - Math.round(clickAreaBox!.x - treeBox!.x), - Math.round(treeBox!.x + treeBox!.width - clickAreaBox!.x - clickAreaBox!.width), - // Compact menu padding alone insets the rows now that the border is gone. + Math.round(clickAreaBox!.x - menuBox!.x), + Math.round(menuBox!.x + menuBox!.width - clickAreaBox!.x - clickAreaBox!.width), + // The outer menu padding insets the scrollable tree rows. ]).toEqual([3, 3]) await compareOrRefreshGolden( BRANCHLESS_EXPECTED, diff --git a/apps/web/tests/support.ts b/apps/web/tests/support.ts index 9945da66a4..884f160418 100644 --- a/apps/web/tests/support.ts +++ b/apps/web/tests/support.ts @@ -5,6 +5,7 @@ import { createRequire } from 'node:module' import { join } from 'node:path' import { fileURLToPath } from 'node:url' import type { Browser, Locator, Page } from 'playwright' +import { expect } from 'vitest' /** The built page under test; `pnpm run test:web` rebuilds it before running. */ export const DIST_INDEX = fileURLToPath(new URL('../dist/index.html', import.meta.url)) @@ -55,6 +56,18 @@ export async function newEnglishPage(browser: Browser, height = 1000): Promise

{ + await expect.poll(() => target.evaluate((element) => { + if (!element.isConnected) return false + element.scrollIntoView({ block: 'center', inline: 'nearest', behavior: 'instant' }) + return true + }), { timeout: 10_000 }).toBe(true) +} + /** * Expand every eligible Turn process and secondary group so a Tool-focused * scenario can exercise the original row contract beneath product-default @@ -212,6 +225,30 @@ export async function saveFailureShot(page: Page, name: string): Promise { } } +/** + * Assert a visible tooltip paints above the element a user would read through + * it, at the bubble's center and bottom edge. The bubble ignores pointer events + * by design, so the measurement enables them for its own duration; each probe + * reports the bubble or the covering element, so a failure names its cover. + * @param tooltip - locator for the visible `[role="tooltip"]` bubble. + */ +export async function expectTooltipOnTop(tooltip: Locator): Promise { + const probes = await tooltip.evaluate((bubble) => { + const rect = bubble.getBoundingClientRect() + const previous = bubble.style.pointerEvents + bubble.style.pointerEvents = 'auto' + const probe = (y: number): string => { + const hit = document.elementFromPoint(rect.left + rect.width / 2, y) + if (hit === null) return 'none' + return bubble.contains(hit) ? 'tooltip' : `${hit.tagName}.${hit.classList.value}`.slice(0, 120) + } + const probes = { center: probe(rect.top + rect.height / 2), bottom: probe(rect.bottom - 1) } + bubble.style.pointerEvents = previous + return probes + }) + expect(probes).toEqual({ center: 'tooltip', bottom: 'tooltip' }) +} + /** * The conversation engine's Context key format, restated here rather than * imported: these specs live in the Host compiler aggregate, which must not diff --git a/apps/web/tests/thinking-markdown.e2e.ts b/apps/web/tests/thinking-markdown.e2e.ts index ea2a6efb76..4a8ac6b132 100644 --- a/apps/web/tests/thinking-markdown.e2e.ts +++ b/apps/web/tests/thinking-markdown.e2e.ts @@ -16,7 +16,7 @@ import { webSnapshotMode, type WebScaffold, } from './scaffold.ts' -import { expandTurnProcesses, newEnglishPage, saveFailureShot } from './support.ts' +import { expandTurnProcesses, newEnglishPage, saveFailureShot, scrollIntoView } from './support.ts' const EXPECTED_DIR = fileURLToPath(new URL('./expected/thinking-markdown', import.meta.url)) const UI_EXPECTED = fileURLToPath(new URL('./expected/thinking-markdown/ui.expected.md', import.meta.url)) @@ -159,7 +159,7 @@ describe('web e2e: secondary Thinking Markdown', () => { expect(await markdown.locator('hr').count()).toBe(1) expect(await markdown.locator('table').count()).toBe(1) expect(await markdown.locator('.katex').count()).toBe(7) - await markdown.locator('pre').scrollIntoViewIfNeeded() + await scrollIntoView(markdown.locator('pre')) await expect.poll(() => markdown.locator('pre.shiki').count(), { timeout: 15_000 }).toBe(1) const snapshot = (await captureStableAria(page, '[data-variant="think"][data-expanded]', scaffold.workspaceCwd)) .split(LONG_TOKEN).join('{{longToken}}') @@ -233,7 +233,7 @@ describe('web e2e: secondary Thinking Markdown', () => { expect(answerSize).toBeGreaterThan(Number.parseFloat(summaryStyle.fontSize)) await page.setViewportSize({ width: 1680, height: 1000 }) const groupScroll = thinking.locator('xpath=ancestor::*[@data-step-process-body][1]') - await groupScroll.scrollIntoViewIfNeeded() + await scrollIntoView(groupScroll) await groupScroll.evaluate((host) => { const row = host.querySelector('[data-variant="think"] tbody tr:nth-child(12)') if (row === null) throw new Error('tall Thinking table row missing') diff --git a/apps/web/tests/tool-details.e2e.ts b/apps/web/tests/tool-details.e2e.ts index a1a84780e9..4dcaa0f759 100644 --- a/apps/web/tests/tool-details.e2e.ts +++ b/apps/web/tests/tool-details.e2e.ts @@ -8,7 +8,7 @@ import { assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts, launchWebScaffold, seedSession, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' -import { expandOwningTurnProcess, newEnglishPage, saveFailureShot } from './support.ts' +import { expandOwningTurnProcess, newEnglishPage, saveFailureShot, scrollIntoView } from './support.ts' const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/tool-details', import.meta.url)) const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/tool-details/session.v3.jsonl', import.meta.url)) @@ -73,7 +73,7 @@ describe.skipIf(MODE === 'record')('web e2e: compact Tool details', () => { const card = page.locator('[data-tool="schedule_create"]') expect(await card.evaluate(element => element.scrollWidth <= element.clientWidth)).toBe(true) const reminders = page.locator('[data-tool="schedule_list"]') - await reminders.scrollIntoViewIfNeeded() + await scrollIntoView(reminders) const list = reminders.getByRole('list').first() await list.evaluate((element) => { const second = element.children[1]! diff --git a/apps/web/tests/voice-download.e2e.ts b/apps/web/tests/voice-download.e2e.ts index 32161bceb3..0acd3489bc 100644 --- a/apps/web/tests/voice-download.e2e.ts +++ b/apps/web/tests/voice-download.e2e.ts @@ -1,7 +1,7 @@ /** The real local provider publishes actionable download failures through the speech Remote to bundle details. */ import { once } from 'node:events' import { mkdtemp, rm, writeFile } from 'node:fs/promises' -import { createServer } from 'node:http' +import { createServer, type ServerResponse } from 'node:http' import { tmpdir } from 'node:os' import { join } from 'node:path' import { fileURLToPath } from 'node:url' @@ -45,7 +45,7 @@ it('shows the failed asset, actual download source and recovery advice, then ret await page.getByRole('button', { name: 'Plugins', exact: true }).click() await page.locator('[data-plugin-package="@deepseek-ai/dsh-experimental-voice-input-bundle"]').getByRole('button').click() await page.getByRole('button', { name: 'Download and prepare', exact: true }).click() - const alert = page.getByRole('alert').filter({ hasText: 'Cannot download model.int8.onnx' }) + const alert = page.getByRole('alert') await alert.waitFor() expect(await alert.textContent()).toContain('HTTP 503') expect(await alert.textContent()).toContain(`Download source: ${origin}`) @@ -59,3 +59,66 @@ it('shows the failed asset, actual download source and recovery advice, then ret expect(requests).toHaveLength(2) expect(tripwire.pageErrors).toEqual([]) }) + +it.each([{ winner: 0, manual: false }, { winner: 1, manual: false }, { winner: 0, manual: true }, { winner: 1, manual: true }])( + 'uses source $winner with manual selection $manual through the real preparation UI', async ({ winner, manual }) => { + const root = await mkdtemp(join(tmpdir(), 'dsh-voice-mirrors-')) + onTestFinished(async () => { await rm(root, { recursive: true, force: true }) }) + const resources: { scaffold?: WebScaffold; browser?: Browser } = {} + const probes = new Map(), downloads: number[] = [], requestedPaths: string[] = [] + const servers = [0, 1].map(index => createServer((request, response) => { + requestedPaths.push(request.url!) + if (request.method === 'HEAD') { + probes.set(index, response) + // Release the winner only after both real Host requests overlap. + if (probes.size === 2) { const selected = probes.get(winner)!; selected.writeHead(200); selected.end() } + } else { + downloads.push(index) + response.writeHead(index === winner ? 503 : 502); response.end() + } + })) + onTestFinished(async () => { + try { await resources.browser?.close() } finally { + try { await resources.scaffold?.close() } finally { + await Promise.all(servers.map(async (server) => { + const closed = new Promise((resolve, reject) => { server.close((error) => { if (error) reject(error); else resolve() }) }) + server.closeAllConnections(); await closed + })) + } + } + }) + const origins = await Promise.all(servers.map(async (server) => { + server.listen(0, '127.0.0.1'); await once(server, 'listening') + const address = server.address() + if (!address || typeof address === 'string') throw new Error('Expected a loopback listener') + return `http://127.0.0.1:${address.port}` + })) + const overlay = join(root, 'voice.patch.yml') + await writeFile(overlay, `- id: speech-to-text-sensevoice\n config: ${JSON.stringify({ + dataRoot: join(root, 'models'), modelOrigins: origins, modelProbeTimeoutMs: 10_000, + })}\n`) + const scaffold = await launchWebScaffold({ profile: { packages: [{ dir: bundle, enabled: true }] }, extraOverlayPath: overlay }) + resources.scaffold = scaffold + const browser = await chromium.launch(); resources.browser = browser + const page = await newEnglishPage(browser), tripwire = watchConsole(page) + await page.goto(scaffold.authenticatedUrl) + await page.getByRole('button', { name: 'Plugins', exact: true }).click() + await page.locator('[data-plugin-package="@deepseek-ai/dsh-experimental-voice-input-bundle"]').getByRole('button').click() + const picker = page.getByLabel('Model download source', { exact: true }) + await picker.waitFor() + if (manual) await picker.selectOption(origins[winner]!) + await page.getByRole('button', { name: 'Download and prepare', exact: true }).click() + const alert = page.getByRole('alert') + await alert.waitFor() + expect(downloads).toEqual(manual ? [winner] : [winner, 1 - winner]) + expect(requestedPaths).toHaveLength(manual ? 1 : 4) + expect(requestedPaths.every(path => path === '/csukuangfj/sherpa-onnx-sense-voice-zh-en-ja-ko-yue-2024-07-17/resolve/2365baeacb507f821a0c8120fcee3d484dba7a07/model.int8.onnx')).toBe(true) + const failedOrigin = origins[manual ? winner : 1 - winner]! + expect(await picker.inputValue()).toBe(manual ? origins[winner] : '') + expect(await alert.textContent()).toContain(manual ? 'HTTP 503' : 'HTTP 502') + expect(await alert.textContent()).toContain(`Download source: ${failedOrigin}`) + await compareOrRefreshGolden(manual ? expected : fileURLToPath(new URL('./expected/voice-download-fallback.expected.md', import.meta.url)), + (await captureStableAria(page, '[role="alert"]', scaffold.workspaceCwd)).replaceAll(failedOrigin, manual ? 'https://model-mirror.example' : 'https://fallback-model-source.example'), + webSnapshotMode()) + expect(tripwire.pageErrors).toEqual([]) + }) diff --git a/apps/web/tests/voice-setup.e2e.ts b/apps/web/tests/voice-setup.e2e.ts index 93eb86b5c5..0d594f59e0 100644 --- a/apps/web/tests/voice-setup.e2e.ts +++ b/apps/web/tests/voice-setup.e2e.ts @@ -40,5 +40,14 @@ it('guides a newly enabled voice plugin to installation and lets the user postpo await page.getByRole('button', { name: 'Download and prepare', exact: true }).waitFor() expect(await dialog.count()).toBe(0) expect(await page.getByText('Local models will be downloaded to the machine running DSH. No Python or compiler is required.', { exact: true }).count()).toBe(1) + expect(await page.getByText('Audio is recognized on the machine running DSH. If models need downloading, that machine must be able to reach the selected source and its file services. Configure a proxy on that machine if needed.', { exact: true }).count()).toBe(1) + const source = page.getByLabel('Model download source', { exact: true }) + expect(await source.inputValue()).toBe('') + expect(await source.getByRole('option').allTextContents()).toEqual(['Automatic (recommended)', 'Hugging Face', 'HF-Mirror (China mirror)']) + await compareOrRefreshGolden(fileURLToPath(new URL('./expected/voice-source-auto.expected.md', import.meta.url)), + await captureStableAria(page, '[data-speech-provider]', scaffold.workspaceCwd), webSnapshotMode()) + await source.selectOption('https://hf-mirror.com') + await compareOrRefreshGolden(fileURLToPath(new URL('./expected/voice-source-manual.expected.md', import.meta.url)), + await captureStableAria(page, '[data-speech-provider]', scaffold.workspaceCwd), webSnapshotMode()) expect(tripwire.pageErrors).toEqual([]) }) diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index a9fe91de1c..7f86c23740 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -64,6 +64,7 @@ "tests/plugin-install-cancel.e2e.ts", "tests/plugin-install-approve.e2e.ts", "tests/plugin-install-registry.e2e.ts", + "tests/plugin-install-github.e2e.ts", "tests/client-plugin-live.e2e.ts", "tests/settings-chrome.e2e.ts", "tests/desktop-locale.e2e.ts", @@ -122,6 +123,7 @@ "tests/markdown-inline-code-links.e2e.ts", "tests/clickable-links-gallery.e2e.ts", "tests/queue-actions.e2e.ts", + "tests/idle-submission-handoff.e2e.ts", "tests/cold-blank-session.e2e.ts", "tests/blank-writer-reuse.e2e.ts", "tests/queue-image.e2e.ts", diff --git a/benchmarks/package.json b/benchmarks/package.json index 87f0000190..975ea4a44a 100644 --- a/benchmarks/package.json +++ b/benchmarks/package.json @@ -7,36 +7,36 @@ "devDependencies": { "playwright": "^1.49.0", "@xterm/headless": "^6.0.0", - "@deepseek-ai/dsh-terminal": "workspace:^", - "@deepseek-ai/dsh-subprocess": "workspace:^", - "@deepseek-ai/dsh-llm-replay": "workspace:^", - "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-agent": "workspace:^", - "@deepseek-ai/dsh-agent-loop": "workspace:^", - "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^", - "@deepseek-ai/dsh-agent-preset-registry": "workspace:^", - "@deepseek-ai/dsh-api-session-controller": "workspace:^", - "@deepseek-ai/dsh-client-store": "workspace:^", - "@deepseek-ai/dsh-client-ui-chat": "workspace:^", - "@deepseek-ai/dsh-deque": "workspace:^", - "@deepseek-ai/dsh-lazy-require": "workspace:^", - "@deepseek-ai/dsh-llm": "workspace:^", - "@deepseek-ai/dsh-sdk-client": "workspace:^", - "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/dsh-session-persistence": "workspace:^", - "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", - "@deepseek-ai/dsh-session-projection": "workspace:^", - "@deepseek-ai/dsh-session-query": "workspace:^", - "@deepseek-ai/dsh-session-stats": "workspace:^", - "@deepseek-ai/dsh-session-title": "workspace:^", - "@deepseek-ai/dsh-session-turn-outline": "workspace:^", - "@deepseek-ai/dsh-subagent": "workspace:^", - "@deepseek-ai/dsh-tools": "workspace:^", - "@deepseek-ai/dsh-token-meter": "workspace:^", - "@deepseek-ai/dsh-typert-protocol": "workspace:^", - "@deepseek-ai/dsh-util-values": "workspace:^" + "@deepseek-ai/dsh-terminal": "workspace:*", + "@deepseek-ai/dsh-subprocess": "workspace:*", + "@deepseek-ai/dsh-llm-replay": "workspace:*", + "@deepseek-ai/cordis": "workspace:~", + "@deepseek-ai/dsh-agent": "workspace:*", + "@deepseek-ai/dsh-agent-loop": "workspace:*", + "@deepseek-ai/dsh-agent-loop-testkit": "workspace:*", + "@deepseek-ai/dsh-agent-preset-registry": "workspace:*", + "@deepseek-ai/dsh-api-session-controller": "workspace:*", + "@deepseek-ai/dsh-client-store": "workspace:*", + "@deepseek-ai/dsh-client-ui-chat": "workspace:*", + "@deepseek-ai/dsh-deque": "workspace:*", + "@deepseek-ai/dsh-lazy-require": "workspace:*", + "@deepseek-ai/dsh-llm": "workspace:*", + "@deepseek-ai/dsh-sdk-client": "workspace:*", + "@deepseek-ai/dsh-session": "workspace:*", + "@deepseek-ai/dsh-session-persistence": "workspace:*", + "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:*", + "@deepseek-ai/dsh-session-projection": "workspace:*", + "@deepseek-ai/dsh-session-query": "workspace:*", + "@deepseek-ai/dsh-session-stats": "workspace:*", + "@deepseek-ai/dsh-session-title": "workspace:*", + "@deepseek-ai/dsh-session-turn-outline": "workspace:*", + "@deepseek-ai/dsh-subagent": "workspace:*", + "@deepseek-ai/dsh-tools": "workspace:*", + "@deepseek-ai/dsh-token-meter": "workspace:*", + "@deepseek-ai/dsh-typert-protocol": "workspace:*", + "@deepseek-ai/dsh-util-values": "workspace:*" }, "peerDependencies": { - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:~" } } diff --git a/docs/capability-seams.i18n.yaml b/docs/capability-seams.i18n.yaml index ea2247432b..13405e808b 100644 --- a/docs/capability-seams.i18n.yaml +++ b/docs/capability-seams.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/capability-seams.md -capability-seams.md: 6550250f0cc89ea5ca1c67c07894bc04385f93f6 -capability-seams.zh.md: f71f021a32f07eec129e892f8d65445ad7b8d264 +capability-seams.md: 2914231ceaaa243b0c918a9477e554aa1641ad91 +capability-seams.zh.md: a1223a351c26d668705df327102ac5e7e521f9ec diff --git a/docs/capability-seams.md b/docs/capability-seams.md index 6550250f0c..2914231cea 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -232,7 +232,6 @@ flowchart LR pkg_experimental_agent_team["experimental-agent-team"] svc_agentTeams["ctx.agentTeams
Agent Teams coordination domain"] pkg_experimental_tool_agent_team["experimental-tool-agent-team"] - pkg_experimental_client_ui_agent_team["experimental-client-ui-agent-team"] pkg_inspector["inspector"] svc_inspector["ctx.inspector
Cross-realm runtime inspection"] pkg_jobs["jobs"] @@ -419,7 +418,6 @@ flowchart LR svc_agentDefaultModel --> pkg_headless svc_agentLoop --> pkg_base svc_agentLoop --> pkg_sdk_minimal - svc_agentTeams --> pkg_experimental_client_ui_agent_team svc_agentTeams --> pkg_experimental_tool_agent_team svc_agents --> pkg_acp svc_agents --> pkg_agent_loop @@ -643,7 +641,7 @@ flowchart LR | `ctx.compaction` | `seam` | [`compaction`](../packages/compaction/compaction) | [`compaction-basic`](../packages/compaction/compaction-basic) | [`compaction-basic`](../packages/compaction/compaction-basic) | - | The basic backend consumes post-step pressure and request-error recovery events; there is no model-facing compact tool. | | `ctx.subagents` | `seam` | [`subagent`](../packages/subagent/subagent) | [`subagent-spawn-in-process`](../packages/subagent/subagent-spawn-in-process), [`subagent-fork-in-process`](../packages/subagent/subagent-fork-in-process), [`subagent-acp`](../packages/subagent/subagent-acp), [`subagent-codex`](../packages/subagent/subagent-codex), [`subagent-claude-code`](../packages/subagent/subagent-claude-code), [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-subagent-control`](../packages/subagent/tool-subagent-control), [`tool-ralph`](../packages/workflow/tool-ralph) | - | Providers implement transports; the service also owns optional Activation-based continuation orchestration, tool-subagent selects one-shot or continuable delegation, tool-subagent-control delivers follow-ups, and tool-ralph requires one fresh structured-output route. | | `ctx.speechToText` | `seam` | [`experimental-speech-to-text`](../packages/experimental/speech-to-text) | [`experimental-speech-to-text-sensevoice`](../packages/experimental/speech-to-text-sensevoice) | [`experimental-api-speech-to-text`](../packages/experimental/api-speech-to-text) | - | Routes explicit recognizers; the browser uses the authenticated Remote and keeps transcripts in the draft until submission. | -| `ctx.agentTeams` | `core` | [`experimental-agent-team`](../packages/experimental/agent-team) | - | [`experimental-tool-agent-team`](../packages/experimental/tool-agent-team), [`experimental-client-ui-agent-team`](../packages/experimental/client-ui-agent-team) | - | Owns the implicit-root roster, durable peer mailbox, shared task DAG, continuable-child lifecycle, and generated Team Remote methods; tool-agent-team contributes model controls and client-ui-agent-team mounts the browser contribution. | +| `ctx.agentTeams` | `core` | [`experimental-agent-team`](../packages/experimental/agent-team) | - | [`experimental-tool-agent-team`](../packages/experimental/tool-agent-team) | - | Owns the implicit-root roster, durable peer mailbox, shared task DAG, and continuable-child lifecycle; tool-agent-team contributes model controls. | | `ctx.inspector` | `core` | `inspector` | - | - | - | Owns the Worker-hosted CDP target and the transport-independent Host and Client observation and Cordis-tree query API. | | `ctx.jobs` | `seam` | [`jobs`](../packages/jobs/jobs) | [`jobs-local`](../packages/jobs/jobs-local) | [`tool-bash`](../packages/shell/tool-bash), [`tool-pwsh`](../packages/shell/tool-pwsh), [`tool-terminal`](../packages/terminal/tool-terminal), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-jobs`](../packages/jobs/tool-jobs), [`api-job-controller`](../packages/api/job-controller) | - | Producers (background bash/pwsh, PTY sends, and subagent delegations) register running work; record-declaring jobs additionally stream raw output for non-consuming observers; tool-jobs is the model-facing controller that reads, lists, and kills it; jobs-local is the process-local registry. | | `ctx.web` | `seam` | [`web`](../packages/web/web) | [`web-search-exa`](../packages/web/web-search-exa), [`web-search-perplexity`](../packages/web/web-search-perplexity), [`web-search-deepseek`](../packages/web/web-search-deepseek), [`web-fetch-http`](../packages/web/web-fetch-http) | [`tool-web`](../packages/web/tool-web) | - | Search and fetch providers register into one ctx.web seam; tool-web owns the stable model-facing names. | diff --git a/docs/capability-seams.zh.md b/docs/capability-seams.zh.md index f71f021a32..a1223a351c 100644 --- a/docs/capability-seams.zh.md +++ b/docs/capability-seams.zh.md @@ -234,7 +234,6 @@ flowchart LR pkg_experimental_agent_team["experimental-agent-team"] svc_agentTeams["ctx.agentTeams
Agent Teams coordination domain"] pkg_experimental_tool_agent_team["experimental-tool-agent-team"] - pkg_experimental_client_ui_agent_team["experimental-client-ui-agent-team"] pkg_inspector["inspector"] svc_inspector["ctx.inspector
Cross-realm runtime inspection"] pkg_jobs["jobs"] @@ -421,7 +420,6 @@ flowchart LR svc_agentDefaultModel --> pkg_headless svc_agentLoop --> pkg_base svc_agentLoop --> pkg_sdk_minimal - svc_agentTeams --> pkg_experimental_client_ui_agent_team svc_agentTeams --> pkg_experimental_tool_agent_team svc_agents --> pkg_acp svc_agents --> pkg_agent_loop @@ -645,7 +643,7 @@ flowchart LR | `ctx.compaction` | `seam` | [`compaction`](../packages/compaction/compaction) | [`compaction-basic`](../packages/compaction/compaction-basic) | [`compaction-basic`](../packages/compaction/compaction-basic) | - | 基础后端消费步骤后的压力事件和请求错误恢复事件;不存在面向模型的压缩工具。 | | `ctx.subagents` | `seam` | [`subagent`](../packages/subagent/subagent) | [`subagent-spawn-in-process`](../packages/subagent/subagent-spawn-in-process), [`subagent-fork-in-process`](../packages/subagent/subagent-fork-in-process), [`subagent-acp`](../packages/subagent/subagent-acp), [`subagent-codex`](../packages/subagent/subagent-codex), [`subagent-claude-code`](../packages/subagent/subagent-claude-code), [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-subagent-control`](../packages/subagent/tool-subagent-control), [`tool-ralph`](../packages/workflow/tool-ralph) | - | 提供方实现传输;该服务还负责可选的、基于 Activation 的延续编排,tool-subagent 选择一次性或可延续委派,tool-subagent-control 传递后续消息,而 tool-ralph 要求一条全新的结构化输出路由。 | | `ctx.speechToText` | `seam` | [`experimental-speech-to-text`](../packages/experimental/speech-to-text) | [`experimental-speech-to-text-sensevoice`](../packages/experimental/speech-to-text-sensevoice) | [`experimental-api-speech-to-text`](../packages/experimental/api-speech-to-text) | - | 路由显式选择的识别器;浏览器使用带认证的 Remote,并在提交前将转写保留在草稿中。 | -| `ctx.agentTeams` | `core` | [`experimental-agent-team`](../packages/experimental/agent-team) | - | [`experimental-tool-agent-team`](../packages/experimental/tool-agent-team), [`experimental-client-ui-agent-team`](../packages/experimental/client-ui-agent-team) | - | 负责隐式 Root roster、持久 peer mailbox、共享任务 DAG、continuable child 生命周期与生成式 Team Remote method;tool-agent-team 提供模型控制工具,client-ui-agent-team 挂载浏览器 contribution。 | +| `ctx.agentTeams` | `core` | [`experimental-agent-team`](../packages/experimental/agent-team) | - | [`experimental-tool-agent-team`](../packages/experimental/tool-agent-team) | - | 负责隐式 Root roster、持久 peer mailbox、共享任务 DAG 与 continuable child 生命周期;tool-agent-team 提供模型控制工具。 | | `ctx.inspector` | `core` | `inspector` | - | - | - | 负责 Worker 托管的 CDP target,以及独立于传输的 Host 和 Client observation 与 Cordis tree query API。 | | `ctx.jobs` | `seam` | [`jobs`](../packages/jobs/jobs) | [`jobs-local`](../packages/jobs/jobs-local) | [`tool-bash`](../packages/shell/tool-bash), [`tool-pwsh`](../packages/shell/tool-pwsh), [`tool-terminal`](../packages/terminal/tool-terminal), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-jobs`](../packages/jobs/tool-jobs), [`api-job-controller`](../packages/api/job-controller) | - | 生产方(后台 bash/pwsh、PTY 发送和 subagent 委派)登记正在运行的工作;声明 record 的 job 还为非消费观察者流式提供原始输出;tool-jobs 是面向模型的控制器,用于读取、列出和终止这些工作;jobs-local 是进程本地注册表。 | | `ctx.web` | `seam` | [`web`](../packages/web/web) | [`web-search-exa`](../packages/web/web-search-exa), [`web-search-perplexity`](../packages/web/web-search-perplexity), [`web-search-deepseek`](../packages/web/web-search-deepseek), [`web-fetch-http`](../packages/web/web-fetch-http) | [`tool-web`](../packages/web/tool-web) | - | 搜索和抓取提供方注册到同一个 ctx.web seam;tool-web 负责稳定的面向模型名称。 | diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 16c39be505..b2f5692dec 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: 563c8d21896e93a76394937b55f841036ee74c42 -config-catalog.zh.md: f3505a577660a534e6c5a3d255d4c46a4cfaea05 +config-catalog.md: eb66160994cb6d7792fb8225aa07839f988e53dc +config-catalog.zh.md: 2fbadf572626b0c1938f88ed2189a25f0a6c7268 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 563c8d2189..eb66160994 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -194,7 +194,7 @@ export interface Config { } ``` -Source: [`packages/api/gateway/src/index.ts:144`](../packages/api/gateway/src/index.ts) +Source: [`packages/api/gateway/src/index.ts:145`](../packages/api/gateway/src/index.ts) @@ -746,7 +746,7 @@ export interface Config { } ``` -Source: [`packages/experimental/agent-team/src/types.ts:130`](../packages/experimental/agent-team/src/types.ts) +Source: [`packages/experimental/agent-team/src/types.ts:152`](../packages/experimental/agent-team/src/types.ts) @@ -1033,8 +1033,12 @@ export interface Config { vadModelPath?: string | undefined /** Weight precision; INT8 minimizes first-use download and model storage. */ precision: 'int8' | 'fp32' - /** Hugging Face-compatible origin for pinned model URLs, including private mirrors. */ - modelOrigin: string + /** Explicit Hugging Face-compatible origin; bypasses automatic selection and public fallback. */ + modelOrigin?: string | undefined + /** Hugging Face-compatible origins compared before downloading each missing asset. */ + modelOrigins: string[] + /** Deadline for concurrent HEAD probes, including redirects to the actual asset. */ + modelProbeTimeoutMs: number /** CPU intra-operation thread count. */ threads: number /** Maximum speech segment length passed to the recognizer. */ @@ -2165,16 +2169,20 @@ Source: [`packages/plan/plan-mode/src/index.ts:70`](../packages/plan/plan-mode/s Requires: `loader` · `profileContext` ```ts config-catalog -/** The pnpm executable, the registries asked, and the limits for package diagnostics and registry lookups. */ +/** The pnpm executable, registries, and limits for diagnostics, lookups and connection checks. */ export interface Config { /** The pnpm executable name or path; resolved through `PATH` like the `dsh plugin` command. */ pnpmCommand?: string - /** Maximum retained pnpm diagnostic bytes per operation. */ + /** Maximum retained package-operation diagnostic bytes. */ outputBytes?: number /** Maximum time to wait for another process's profile package operation. */ lockWaitMs?: number /** Bound on one registry lookup an inspection runs, in milliseconds. */ 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 /** @@ -2186,7 +2194,7 @@ export interface Config { } ``` -Source: [`packages/boot/plugin-manager/src/index.ts:37`](../packages/boot/plugin-manager/src/index.ts) +Source: [`packages/boot/plugin-manager/src/index.ts:38`](../packages/boot/plugin-manager/src/index.ts) @@ -2758,10 +2766,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 f3505a5776..2fbadf5726 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -196,7 +196,7 @@ export interface Config { } ``` -来源:[`packages/api/gateway/src/index.ts:144`](../packages/api/gateway/src/index.ts) +来源:[`packages/api/gateway/src/index.ts:145`](../packages/api/gateway/src/index.ts) @@ -748,7 +748,7 @@ export interface Config { } ``` -来源: [`packages/experimental/agent-team/src/types.ts:130`](../packages/experimental/agent-team/src/types.ts) +来源: [`packages/experimental/agent-team/src/types.ts:152`](../packages/experimental/agent-team/src/types.ts) @@ -1035,8 +1035,12 @@ export interface Config { vadModelPath?: string | undefined /** Weight precision; INT8 minimizes first-use download and model storage. */ precision: 'int8' | 'fp32' - /** Hugging Face-compatible origin for pinned model URLs, including private mirrors. */ - modelOrigin: string + /** Explicit Hugging Face-compatible origin; bypasses automatic selection and public fallback. */ + modelOrigin?: string | undefined + /** Hugging Face-compatible origins compared before downloading each missing asset. */ + modelOrigins: string[] + /** Deadline for concurrent HEAD probes, including redirects to the actual asset. */ + modelProbeTimeoutMs: number /** CPU intra-operation thread count. */ threads: number /** Maximum speech segment length passed to the recognizer. */ @@ -2167,16 +2171,20 @@ export interface PlanModeConfig { 需要: `loader` · `profileContext` ```ts config-catalog -/** The pnpm executable, the registries asked, and the limits for package diagnostics and registry lookups. */ +/** The pnpm executable, registries, and limits for diagnostics, lookups and connection checks. */ export interface Config { /** The pnpm executable name or path; resolved through `PATH` like the `dsh plugin` command. */ pnpmCommand?: string - /** Maximum retained pnpm diagnostic bytes per operation. */ + /** Maximum retained package-operation diagnostic bytes. */ outputBytes?: number /** Maximum time to wait for another process's profile package operation. */ lockWaitMs?: number /** Bound on one registry lookup an inspection runs, in milliseconds. */ 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 /** @@ -2188,7 +2196,7 @@ export interface Config { } ``` -来源: [`packages/boot/plugin-manager/src/index.ts:37`](../packages/boot/plugin-manager/src/index.ts) +来源: [`packages/boot/plugin-manager/src/index.ts:38`](../packages/boot/plugin-manager/src/index.ts) @@ -2760,10 +2768,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/cookbook/adding-a-remote-api.i18n.yaml b/docs/cookbook/adding-a-remote-api.i18n.yaml index 89781a29df..0aef34d3f2 100644 --- a/docs/cookbook/adding-a-remote-api.i18n.yaml +++ b/docs/cookbook/adding-a-remote-api.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/cookbook/adding-a-remote-api.md -adding-a-remote-api.md: 0f89101e9bc3ff961d465c109f033150e93e04f6 -adding-a-remote-api.zh.md: 0c5a57b539ecd98cb88790260a3fb8e7b41afff6 +adding-a-remote-api.md: a36fe1aecd1e134686d9f1b583c56e9648d21f23 +adding-a-remote-api.zh.md: c1f56a865bc49512a50eb67d53e4190d98f5a176 diff --git a/docs/cookbook/adding-a-remote-api.md b/docs/cookbook/adding-a-remote-api.md index 0f89101e9b..a36fe1aecd 100644 --- a/docs/cookbook/adding-a-remote-api.md +++ b/docs/cookbook/adding-a-remote-api.md @@ -97,8 +97,8 @@ export async function rename(noteId: string, title: string): Promise { "./typert": { "types": "./lib/typert.host.d.ts", "default": "./lib/typert.host.js" }, "./remote": { "types": "./lib/typert.remote-client.d.ts", "default": "./lib/typert.remote-client.js" } }, - "peerDependencies": { "@deepseek-ai/dsh-typert-protocol": "workspace:^" }, - "devDependencies": { "@deepseek-ai/dsh-typert-protocol": "workspace:^" } + "peerDependencies": { "@deepseek-ai/dsh-typert-protocol": "workspace:*" }, + "devDependencies": { "@deepseek-ai/dsh-typert-protocol": "workspace:*" } } ``` diff --git a/docs/cookbook/adding-a-remote-api.zh.md b/docs/cookbook/adding-a-remote-api.zh.md index 0c5a57b539..c1f56a865b 100644 --- a/docs/cookbook/adding-a-remote-api.zh.md +++ b/docs/cookbook/adding-a-remote-api.zh.md @@ -97,8 +97,8 @@ export async function rename(noteId: string, title: string): Promise { "./typert": { "types": "./lib/typert.host.d.ts", "default": "./lib/typert.host.js" }, "./remote": { "types": "./lib/typert.remote-client.d.ts", "default": "./lib/typert.remote-client.js" } }, - "peerDependencies": { "@deepseek-ai/dsh-typert-protocol": "workspace:^" }, - "devDependencies": { "@deepseek-ai/dsh-typert-protocol": "workspace:^" } + "peerDependencies": { "@deepseek-ai/dsh-typert-protocol": "workspace:*" }, + "devDependencies": { "@deepseek-ai/dsh-typert-protocol": "workspace:*" } } ``` diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml index 5f3fa3d374..e3971707b4 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: 13ac82b2c9fb0b2aeeaa2bec9c736db5b5b63992 -event-producer-consumer.zh.md: fce8463041181139e45fb5f3a9ab14c87117135f +event-producer-consumer.md: 54a5e36bb6d2c39606c2e0263cad89293e6bb613 +event-producer-consumer.zh.md: 248aacbafc115d2c7aea8480406d2a1842ed293d diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 13ac82b2c9..54a5e36bb6 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) | @@ -21,11 +21,11 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:353`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`compaction-image-offload`](../packages/compaction/compaction-image-offload), [`llm-retry`](../packages/llm/llm-retry) | | `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:280`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` | | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:381`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`workspace-changes`](../packages/deliverables/workspace-changes) | -| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:603`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:583`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:610`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:589`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:596`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:609`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:589`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:616`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:595`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:602`](../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) | | `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) | @@ -52,9 +52,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 fce8463041..248aacbafc 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) | @@ -23,11 +23,11 @@ | `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:353`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`compaction-image-offload`](../packages/compaction/compaction-image-offload), [`llm-retry`](../packages/llm/llm-retry) | | `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:280`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` | | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:381`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`workspace-changes`](../packages/deliverables/workspace-changes) | -| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:603`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:583`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:610`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:589`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:596`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:609`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:589`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:616`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:595`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:602`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `app-boot/config-reload` | `emit` | [`packages/boot/app-boot/src/index.ts:43`](../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) | @@ -54,9 +54,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) | 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/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index 6ae367c64a..a1c8765cd8 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.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/module-graph.md -module-graph.md: bfe3e5faa7168b03848ac76d2dd2584d4aa31f5e -module-graph.zh.md: bfa0f83e14cbcf89599fa207ec919195128db49e +module-graph.md: ef70bdf4a002d764a1fca6ffd5e67163e93027aa +module-graph.zh.md: 729b76c88e6d5f3dc1c2abcea0c5852d1b39348f diff --git a/docs/module-graph.md b/docs/module-graph.md index bfe3e5faa7..ef70bdf4a0 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -1249,7 +1249,6 @@ flowchart TD pkg_experimental_agent_team --> pkg_session_persistence pkg_experimental_agent_team --> pkg_session_projection pkg_experimental_agent_team --> pkg_subagent - pkg_experimental_agent_team --> pkg_typert_protocol pkg_sdk_protocol --> pkg_llm pkg_sdk_protocol --> pkg_session pkg_sdk_protocol --> pkg_subagent @@ -1274,7 +1273,6 @@ flowchart TD pkg_subagent_fork_in_process --> pkg_subagent_in_process_driver pkg_subagent_spawn_in_process --> pkg_subagent pkg_subagent_spawn_in_process --> pkg_subagent_in_process_driver - pkg_experimental_client_ui_agent_team --> pkg_api_remotes pkg_experimental_client_ui_agent_team --> pkg_api_session_controller pkg_experimental_client_ui_agent_team --> pkg_client_locale pkg_experimental_client_ui_agent_team --> pkg_client_ui_conversation @@ -1285,7 +1283,6 @@ flowchart TD pkg_experimental_client_ui_agent_team --> pkg_client_ui_workspace pkg_experimental_client_ui_agent_team --> pkg_experimental_agent_team pkg_experimental_client_ui_agent_team --> pkg_session - pkg_experimental_client_ui_agent_team --> pkg_typert_protocol pkg_experimental_client_ui_voice_input --> pkg_api_gateway pkg_experimental_client_ui_voice_input --> pkg_api_remotes pkg_experimental_client_ui_voice_input --> pkg_api_session_controller @@ -1640,13 +1637,13 @@ flowchart TD | [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-preset-registry`](../packages/preset/agent-preset-registry), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-file-upload`](../packages/client/file-upload), [`commands`](../packages/interaction/commands), [`file-reference`](../packages/context/file-reference), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`native-command`](../packages/util/native-command), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`skill`](../packages/skill/skill), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-time`](../packages/util/time), [`util-values`](../packages/util/values), [`util-workspace-path`](../packages/util/workspace-path), [`workspace`](../packages/workspace/workspace) | | [`compaction-basic`](../packages/compaction/compaction-basic) | `compaction` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | | [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compaction`](../packages/compaction/compaction), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`spill`](../packages/spill/spill), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`typert-protocol`](../packages/typert/protocol) | -| [`experimental-agent-team`](../packages/experimental/agent-team) | `experimental` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol) | +| [`experimental-agent-team`](../packages/experimental/agent-team) | `experimental` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent) | | [`sdk-protocol`](../packages/sdk/protocol) | `sdk` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`tool-ralph`](../packages/workflow/tool-ralph) | `workflow` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`workflow-ptc`](../packages/workflow/workflow-ptc) | `workflow` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`ptc-runtime`](../packages/ptc-runtime/ptc-runtime), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`subagent-fork-in-process`](../packages/subagent/subagent-fork-in-process) | `subagent` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver) | | [`subagent-spawn-in-process`](../packages/subagent/subagent-spawn-in-process) | `subagent` | [`subagent`](../packages/subagent/subagent), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver) | -| [`experimental-client-ui-agent-team`](../packages/experimental/client-ui-agent-team) | `experimental` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`experimental-agent-team`](../packages/experimental/agent-team), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | +| [`experimental-client-ui-agent-team`](../packages/experimental/client-ui-agent-team) | `experimental` | [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`experimental-agent-team`](../packages/experimental/agent-team), [`session`](../packages/core/session) | | [`experimental-client-ui-voice-input`](../packages/experimental/client-ui-voice-input) | `experimental` | [`api-gateway`](../packages/api/gateway), [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-store`](../packages/client/store), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-plugin-manager`](../packages/client/ui-plugin-manager), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-slots`](../packages/client/ui-slots), [`experimental-api-speech-to-text`](../packages/experimental/api-speech-to-text), [`experimental-speech-to-text`](../packages/experimental/speech-to-text), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | | [`experimental-tool-agent-team`](../packages/experimental/tool-agent-team) | `experimental` | [`agent`](../packages/core/agent), [`experimental-agent-team`](../packages/experimental/agent-team), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`sdk-client`](../packages/sdk/client) | `sdk` | [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index bfa0f83e14..729b76c88e 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -1251,7 +1251,6 @@ flowchart TD pkg_experimental_agent_team --> pkg_session_persistence pkg_experimental_agent_team --> pkg_session_projection pkg_experimental_agent_team --> pkg_subagent - pkg_experimental_agent_team --> pkg_typert_protocol pkg_sdk_protocol --> pkg_llm pkg_sdk_protocol --> pkg_session pkg_sdk_protocol --> pkg_subagent @@ -1276,7 +1275,6 @@ flowchart TD pkg_subagent_fork_in_process --> pkg_subagent_in_process_driver pkg_subagent_spawn_in_process --> pkg_subagent pkg_subagent_spawn_in_process --> pkg_subagent_in_process_driver - pkg_experimental_client_ui_agent_team --> pkg_api_remotes pkg_experimental_client_ui_agent_team --> pkg_api_session_controller pkg_experimental_client_ui_agent_team --> pkg_client_locale pkg_experimental_client_ui_agent_team --> pkg_client_ui_conversation @@ -1287,7 +1285,6 @@ flowchart TD pkg_experimental_client_ui_agent_team --> pkg_client_ui_workspace pkg_experimental_client_ui_agent_team --> pkg_experimental_agent_team pkg_experimental_client_ui_agent_team --> pkg_session - pkg_experimental_client_ui_agent_team --> pkg_typert_protocol pkg_experimental_client_ui_voice_input --> pkg_api_gateway pkg_experimental_client_ui_voice_input --> pkg_api_remotes pkg_experimental_client_ui_voice_input --> pkg_api_session_controller @@ -1642,13 +1639,13 @@ flowchart TD | [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-preset-registry`](../packages/preset/agent-preset-registry), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-file-upload`](../packages/client/file-upload), [`commands`](../packages/interaction/commands), [`file-reference`](../packages/context/file-reference), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`native-command`](../packages/util/native-command), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`skill`](../packages/skill/skill), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-time`](../packages/util/time), [`util-values`](../packages/util/values), [`util-workspace-path`](../packages/util/workspace-path), [`workspace`](../packages/workspace/workspace) | | [`compaction-basic`](../packages/compaction/compaction-basic) | `compaction` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | | [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compaction`](../packages/compaction/compaction), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`spill`](../packages/spill/spill), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`typert-protocol`](../packages/typert/protocol) | -| [`experimental-agent-team`](../packages/experimental/agent-team) | `experimental` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol) | +| [`experimental-agent-team`](../packages/experimental/agent-team) | `experimental` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent) | | [`sdk-protocol`](../packages/sdk/protocol) | `sdk` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`tool-ralph`](../packages/workflow/tool-ralph) | `workflow` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`workflow-ptc`](../packages/workflow/workflow-ptc) | `workflow` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`ptc-runtime`](../packages/ptc-runtime/ptc-runtime), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`subagent-fork-in-process`](../packages/subagent/subagent-fork-in-process) | `subagent` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver) | | [`subagent-spawn-in-process`](../packages/subagent/subagent-spawn-in-process) | `subagent` | [`subagent`](../packages/subagent/subagent), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver) | -| [`experimental-client-ui-agent-team`](../packages/experimental/client-ui-agent-team) | `experimental` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`experimental-agent-team`](../packages/experimental/agent-team), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | +| [`experimental-client-ui-agent-team`](../packages/experimental/client-ui-agent-team) | `experimental` | [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`experimental-agent-team`](../packages/experimental/agent-team), [`session`](../packages/core/session) | | [`experimental-client-ui-voice-input`](../packages/experimental/client-ui-voice-input) | `experimental` | [`api-gateway`](../packages/api/gateway), [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-store`](../packages/client/store), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-plugin-manager`](../packages/client/ui-plugin-manager), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-slots`](../packages/client/ui-slots), [`experimental-api-speech-to-text`](../packages/experimental/api-speech-to-text), [`experimental-speech-to-text`](../packages/experimental/speech-to-text), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | | [`experimental-tool-agent-team`](../packages/experimental/tool-agent-team) | `experimental` | [`agent`](../packages/core/agent), [`experimental-agent-team`](../packages/experimental/agent-team), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`sdk-client`](../packages/sdk/client) | `sdk` | [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session) | diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml index 207b82e845..32d3d4d57d 100644 --- a/docs/persistence-catalog.i18n.yaml +++ b/docs/persistence-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/persistence-catalog.md -persistence-catalog.md: 022944bea076f49e054e7194e6b475cf6dfca2bc -persistence-catalog.zh.md: 096bf746085d329427dfb5516db4c6387f0974fe +persistence-catalog.md: 200e7156f65c4cba6fab6808a84326e85df69ddd +persistence-catalog.zh.md: fa48684d4456c6338b67d58bc051c3672d6ee2c9 diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 022944bea0..200e7156f6 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -975,7 +975,7 @@ Source: [`packages/core/session/src/types.ts:330`](../packages/core/session/src/ Types: [TeamId](subsystems/agent-team.md) · [TeamMemberSnapshot](subsystems/agent-team.md) -Source: [`packages/experimental/agent-team/src/types.ts:210`](../packages/experimental/agent-team/src/types.ts) +Source: [`packages/experimental/agent-team/src/types.ts:232`](../packages/experimental/agent-team/src/types.ts) @@ -993,7 +993,7 @@ Source: [`packages/experimental/agent-team/src/types.ts:210`](../packages/experi Types: [TeamId](subsystems/agent-team.md) · [TeamMessageId](subsystems/agent-team.md) -Source: [`packages/experimental/agent-team/src/types.ts:216`](../packages/experimental/agent-team/src/types.ts) +Source: [`packages/experimental/agent-team/src/types.ts:238`](../packages/experimental/agent-team/src/types.ts) @@ -1006,7 +1006,7 @@ Source: [`packages/experimental/agent-team/src/types.ts:216`](../packages/experi Types: [TeamId](subsystems/agent-team.md) · [TeamMessageSnapshot](subsystems/agent-team.md) -Source: [`packages/experimental/agent-team/src/types.ts:214`](../packages/experimental/agent-team/src/types.ts) +Source: [`packages/experimental/agent-team/src/types.ts:236`](../packages/experimental/agent-team/src/types.ts) @@ -1019,7 +1019,7 @@ Source: [`packages/experimental/agent-team/src/types.ts:214`](../packages/experi Types: [TeamId](subsystems/agent-team.md) · [TeamTaskSnapshot](subsystems/agent-team.md) -Source: [`packages/experimental/agent-team/src/types.ts:212`](../packages/experimental/agent-team/src/types.ts) +Source: [`packages/experimental/agent-team/src/types.ts:234`](../packages/experimental/agent-team/src/types.ts) ### `todo/*` @@ -4713,7 +4713,7 @@ Sources: [`packages/experimental/agent-team/src/types.ts:47`](../packages/experi SHA-256: `2eafefdc6f79a4922d68a787a09eaf4dd2b2369c3777fba4c05e800b5678ac8e` -Sources: [`packages/experimental/agent-team/src/types.ts:106`](../packages/experimental/agent-team/src/types.ts) +Sources: [`packages/experimental/agent-team/src/types.ts:128`](../packages/experimental/agent-team/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -4733,7 +4733,7 @@ Sources: [`packages/experimental/agent-team/src/types.ts:106`](../packages/exper SHA-256: `2152caf1a71e588e4df2358a806dea24936c1c47131134f01a12939d12cdc0c0` -Sources: [`packages/experimental/agent-team/src/types.ts:115`](../packages/experimental/agent-team/src/types.ts) +Sources: [`packages/experimental/agent-team/src/types.ts:137`](../packages/experimental/agent-team/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -7373,7 +7373,7 @@ Sources: [`packages/web/web-search-deepseek/src/provider.ts:61`](../packages/web SHA-256: `b1ebd5cda47d90d980bb05bffc3f970c51537b9aff15b3bc3d639811727741b1` -Sources: [`packages/experimental/agent-team/src/types.ts:210`](../packages/experimental/agent-team/src/types.ts) +Sources: [`packages/experimental/agent-team/src/types.ts:232`](../packages/experimental/agent-team/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -7401,7 +7401,7 @@ Sources: [`packages/core/session/src/types.ts:330`](../packages/core/session/src SHA-256: `ea08ec1df0c33108fd03345b9af199cf04dea450aa045d3c683f216311403403` -Sources: [`packages/experimental/agent-team/src/types.ts:214`](../packages/experimental/agent-team/src/types.ts) +Sources: [`packages/experimental/agent-team/src/types.ts:236`](../packages/experimental/agent-team/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -7415,7 +7415,7 @@ Sources: [`packages/experimental/agent-team/src/types.ts:214`](../packages/exper SHA-256: `0bcfaf5566d271028429ac7b562cb59053c9d78b8ae14708c35fac835108d1b1` -Sources: [`packages/experimental/agent-team/src/types.ts:216`](../packages/experimental/agent-team/src/types.ts) +Sources: [`packages/experimental/agent-team/src/types.ts:238`](../packages/experimental/agent-team/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -7561,7 +7561,7 @@ Sources: [`packages/compaction/compaction-image-offload/src/projection.ts:25`](. SHA-256: `a40d12070f6f4a124f32fb3cc86e7857554702e32f0eda3080a25c4b9ac9b18c` -Sources: [`packages/experimental/agent-team/src/types.ts:212`](../packages/experimental/agent-team/src/types.ts) +Sources: [`packages/experimental/agent-team/src/types.ts:234`](../packages/experimental/agent-team/src/types.ts) | Property | Presence | Type | |---|---|---| diff --git a/docs/persistence-catalog.zh.md b/docs/persistence-catalog.zh.md index 096bf74608..fa48684d44 100644 --- a/docs/persistence-catalog.zh.md +++ b/docs/persistence-catalog.zh.md @@ -977,7 +977,7 @@ export type SessionEvent = { 类型:[TeamId](subsystems/agent-team.zh.md) · [TeamMemberSnapshot](subsystems/agent-team.zh.md) -来源:[`packages/experimental/agent-team/src/types.ts:210`](../packages/experimental/agent-team/src/types.ts) +来源:[`packages/experimental/agent-team/src/types.ts:232`](../packages/experimental/agent-team/src/types.ts) @@ -995,7 +995,7 @@ export type SessionEvent = { 类型:[TeamId](subsystems/agent-team.zh.md) · [TeamMessageId](subsystems/agent-team.zh.md) -来源:[`packages/experimental/agent-team/src/types.ts:216`](../packages/experimental/agent-team/src/types.ts) +来源:[`packages/experimental/agent-team/src/types.ts:238`](../packages/experimental/agent-team/src/types.ts) @@ -1008,7 +1008,7 @@ export type SessionEvent = { 类型:[TeamId](subsystems/agent-team.zh.md) · [TeamMessageSnapshot](subsystems/agent-team.zh.md) -来源:[`packages/experimental/agent-team/src/types.ts:214`](../packages/experimental/agent-team/src/types.ts) +来源:[`packages/experimental/agent-team/src/types.ts:236`](../packages/experimental/agent-team/src/types.ts) @@ -1021,7 +1021,7 @@ export type SessionEvent = { 类型:[TeamId](subsystems/agent-team.zh.md) · [TeamTaskSnapshot](subsystems/agent-team.zh.md) -来源:[`packages/experimental/agent-team/src/types.ts:212`](../packages/experimental/agent-team/src/types.ts) +来源:[`packages/experimental/agent-team/src/types.ts:234`](../packages/experimental/agent-team/src/types.ts) ### `todo/*` @@ -4715,7 +4715,7 @@ SHA-256: `6598cd6da253948d6b207c6374a45335804c79b576866d6215649c2d1bc85e25` SHA-256: `2eafefdc6f79a4922d68a787a09eaf4dd2b2369c3777fba4c05e800b5678ac8e` -来源:[`packages/experimental/agent-team/src/types.ts:106`](../packages/experimental/agent-team/src/types.ts) +来源:[`packages/experimental/agent-team/src/types.ts:128`](../packages/experimental/agent-team/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -4735,7 +4735,7 @@ SHA-256: `2eafefdc6f79a4922d68a787a09eaf4dd2b2369c3777fba4c05e800b5678ac8e` SHA-256: `2152caf1a71e588e4df2358a806dea24936c1c47131134f01a12939d12cdc0c0` -来源:[`packages/experimental/agent-team/src/types.ts:115`](../packages/experimental/agent-team/src/types.ts) +来源:[`packages/experimental/agent-team/src/types.ts:137`](../packages/experimental/agent-team/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -7375,7 +7375,7 @@ SHA-256: `930a6567a10bb62ddd157bd8abdf4b182810c8c3b49d91309b3b009a5fed9731` SHA-256: `b1ebd5cda47d90d980bb05bffc3f970c51537b9aff15b3bc3d639811727741b1` -来源:[`packages/experimental/agent-team/src/types.ts:210`](../packages/experimental/agent-team/src/types.ts) +来源:[`packages/experimental/agent-team/src/types.ts:232`](../packages/experimental/agent-team/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -7403,7 +7403,7 @@ SHA-256: `6bc8e51124d0874bc89e1d0ceff381a9c3cd0fc240667d69f841ce4f60f65fe5` SHA-256: `ea08ec1df0c33108fd03345b9af199cf04dea450aa045d3c683f216311403403` -来源:[`packages/experimental/agent-team/src/types.ts:214`](../packages/experimental/agent-team/src/types.ts) +来源:[`packages/experimental/agent-team/src/types.ts:236`](../packages/experimental/agent-team/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -7417,7 +7417,7 @@ SHA-256: `ea08ec1df0c33108fd03345b9af199cf04dea450aa045d3c683f216311403403` SHA-256: `0bcfaf5566d271028429ac7b562cb59053c9d78b8ae14708c35fac835108d1b1` -来源:[`packages/experimental/agent-team/src/types.ts:216`](../packages/experimental/agent-team/src/types.ts) +来源:[`packages/experimental/agent-team/src/types.ts:238`](../packages/experimental/agent-team/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -7563,7 +7563,7 @@ SHA-256: `477614bca595a9e7adb5a1870be446b9a8684f3ba8950259d6289f2d3b74f028` SHA-256: `a40d12070f6f4a124f32fb3cc86e7857554702e32f0eda3080a25c4b9ac9b18c` -来源:[`packages/experimental/agent-team/src/types.ts:212`](../packages/experimental/agent-team/src/types.ts) +来源:[`packages/experimental/agent-team/src/types.ts:234`](../packages/experimental/agent-team/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| diff --git a/docs/persistence-schema.json b/docs/persistence-schema.json index 4695717a2d..211f6c6fe6 100644 --- a/docs/persistence-schema.json +++ b/docs/persistence-schema.json @@ -22575,7 +22575,7 @@ }, "names": [], "sources": [ - "packages/experimental/agent-team/src/types.ts:216" + "packages/experimental/agent-team/src/types.ts:238" ] }, { @@ -31770,7 +31770,7 @@ "packages/experimental/agent-team/src/types.ts#TeamMessageSource" ], "sources": [ - "packages/experimental/agent-team/src/types.ts:115" + "packages/experimental/agent-team/src/types.ts:137" ] }, { @@ -33739,7 +33739,7 @@ "packages/experimental/agent-team/src/types.ts#TeamMessageSnapshot" ], "sources": [ - "packages/experimental/agent-team/src/types.ts:106" + "packages/experimental/agent-team/src/types.ts:128" ] }, { @@ -69181,7 +69181,7 @@ }, "names": [], "sources": [ - "packages/experimental/agent-team/src/types.ts:212" + "packages/experimental/agent-team/src/types.ts:234" ] }, { @@ -71163,7 +71163,7 @@ }, "names": [], "sources": [ - "packages/experimental/agent-team/src/types.ts:210" + "packages/experimental/agent-team/src/types.ts:232" ] }, { @@ -83593,7 +83593,7 @@ }, "names": [], "sources": [ - "packages/experimental/agent-team/src/types.ts:214" + "packages/experimental/agent-team/src/types.ts:236" ] }, { diff --git a/docs/rescope.i18n.yaml b/docs/rescope.i18n.yaml index ed6948f492..a02315a875 100644 --- a/docs/rescope.i18n.yaml +++ b/docs/rescope.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/rescope.md -rescope.md: 80a0b1dc67c8b3585f2c18cad3afff9dde5859f9 -rescope.zh.md: aaf65024520308c5d0013d85a128a42839a84573 +rescope.md: 0fcbdd476afb45c2fd9efe927c6b5643b9426683 +rescope.zh.md: bd6e3f233b46cb7c0ed5c1f624d2941f41665a6f diff --git a/docs/rescope.md b/docs/rescope.md index 80a0b1dc67..0fcbdd476a 100644 --- a/docs/rescope.md +++ b/docs/rescope.md @@ -23,7 +23,7 @@ Subpath exports keep their path: `@cordisjs/plugin-loader/repository` becomes `@ ## What the rename does not touch - **Directory names and upstream source versions.** `vendor/hmr/` stays `vendor/hmr/`, and the table records the upstream version of the pinned source snapshot, so the manifest reads as an upstream snapshot; the vendored `package.json`'s own `version` field is the harness's released manifest version, which `pnpm run release:vendor` bumps and a re-sync restores to the upstream version. -- **Dependency ranges.** Renaming changes dependency keys without changing ranges. Workspace manifests use `workspace:^` for repository-owned runtime dependencies, so pnpm resolves the pinned local packages and substitutes release ranges when publishing. +- **Dependency ranges.** Renaming changes dependency keys without changing ranges. Workspace manifests use the `workspace:` protocol; [repository rules](../AGENTS.md#conventions) distinguish exact DSH references from vendor/native tilde ranges. - **The Loader's `cordis:` builtin prefix.** `cordis:include` and `cordis:group` are a protocol prefix, not a package name. - **The `cordis.yml` configuration family**, including `*.cordis.yml`, `*.cordis.snapshot.yml`, and `cordis.patch.yml`. - **Harness packages whose own names contain the word**, such as `@deepseek-ai/dsh-tool-cordis`. diff --git a/docs/rescope.zh.md b/docs/rescope.zh.md index aaf6502452..bd6e3f233b 100644 --- a/docs/rescope.zh.md +++ b/docs/rescope.zh.md @@ -23,7 +23,7 @@ Cordis 框架及其基础库以源码形式 vendored 在 [`vendor/`](../vendor/R ## 改名不碰什么 - **目录名与上游源码版本。** `vendor/hmr/` 仍是 `vendor/hmr/`,清单表记录的是所钉住源码快照的上游版本,因此清单读作一份上游快照;而每个 vendored 包 `package.json` 自身的 `version` 字段是 harness 发布的清单版本,`pnpm run release:vendor` 会提升它,重新 sync 时会恢复成上游版本。 -- **依赖 range。** 改名只修改依赖键,不改变范围。Workspace 清单对仓库内的运行时依赖使用 `workspace:^`,因此 pnpm 会解析到固定的本地包,并在发布时替换为版本范围。 +- **依赖 range。** 改名只修改依赖键,不改变范围。Workspace 清单使用 `workspace:` 协议;[仓库规则](../AGENTS.md#conventions)区分精确的 DSH 引用与 vendor/native tilde 范围。 - **Loader 的 `cordis:` 内建前缀。** `cordis:include`、`cordis:group` 是协议前缀,不是包名。 - **`cordis.yml` 配置文件家族**,包括 `*.cordis.yml`、`*.cordis.snapshot.yml`、`cordis.patch.yml`。 - **名字里带这个词的 harness 包**,例如 `@deepseek-ai/dsh-tool-cordis`。 diff --git a/docs/subsystems/agent-team.i18n.yaml b/docs/subsystems/agent-team.i18n.yaml index 6a04e1377d..03abbfe5b9 100644 --- a/docs/subsystems/agent-team.i18n.yaml +++ b/docs/subsystems/agent-team.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/agent-team.md -agent-team.md: 2f49e164645e6b66bd0da4946c477a09c412ddba -agent-team.zh.md: 24d7d73fdace11ad5e00eb3fb4c90a4abde33810 +agent-team.md: 6cb503d130727b4c4694877f83c02f2e0983c8af +agent-team.zh.md: 083b9fafb6f45cd09ded84807da3c8602193b3d4 diff --git a/docs/subsystems/agent-team.md b/docs/subsystems/agent-team.md index 2f49e16464..6cb503d130 100644 --- a/docs/subsystems/agent-team.md +++ b/docs/subsystems/agent-team.md @@ -2,7 +2,7 @@ English | [中文](agent-team.zh.md) -Types shared by the experimental implicit-root Team domain, model tools, and host adapters. The [Agent Teams Agent Note](../../.agents/notes/implemented/feature/2026-08-05-agent-teams.md) owns identity, mailbox, task, and shared-checkout decisions; this page records the literal durable forms from [`packages/experimental/agent-team/src/types.ts`](../../packages/experimental/agent-team/src/types.ts). +Types shared by the experimental implicit-root Team domain, model tools, and host adapters. The [Agent Teams Agent Note](../../.agents/notes/implemented/feature/2026-08-05-agent-teams.md) owns identity, mailbox, task, and shared-checkout decisions; this page records the durable and client-visible forms from [`packages/experimental/agent-team/src/types.ts`](../../packages/experimental/agent-team/src/types.ts). ## Identity and roster @@ -73,9 +73,56 @@ interface TeamTaskSnapshot { `pending` is unstarted or released, `in_progress` carries an owner, `completed` satisfies blockers, and `deleted` is a retained tombstone. Views add owner name, readiness, and write-scope overlap warnings without changing the durable snapshot. + + +## Web projection + +The Lead Session publishes `SessionProjectionMap.agentTeam` with durable roster rows and non-deleted task views. `failure` reports a rejected persisted record beside the last valid state. Member activity comes from Session status; model labels come from each member's `modelSelection` projection. + +```ts type-equiv +/** One durable roster row published through the `agentTeam` Session projection. */ +interface TeamMemberProjection { + readonly id: SessionId + readonly name: string + readonly role: 'lead' | 'teammate' + /** Durable lifecycle; the Lead row is always `active`. Turn activity comes from Session status. */ + readonly phase: TeamMemberPhase + readonly error?: string +} +``` + +```ts type-equiv +/** Runtime-enriched task view returned to tools and hosts. */ +interface TeamTaskView { + readonly id: TeamTaskId + readonly revision: number + readonly subject: string + readonly description: string + readonly status: TeamTaskStatus + readonly blockedBy: TeamTaskId[] + readonly writeScopes: string[] + readonly ownerName?: string + readonly ready: boolean + readonly writeScopeWarnings: string[] +} +``` + +```ts type-equiv +/** + * Durable Team state published to browser clients through the Lead Session's + * `agentTeam` projection. `failure` names the first rejected persisted Team + * record; members and tasks then stay at the last valid state. + */ +interface TeamProjection { + readonly members: TeamMemberProjection[] + readonly tasks: TeamTaskView[] + readonly failure?: string +} +``` + ## Replay -`foldTeam()` replays one root Session into the roster, task board, and queued-minus-delivered mailbox that every Team operation reads. It selects records by `TeamId`, so events inherited by an ordinary fork retain the ancestor id and never enter the new root's state. Session event `seq` and `time` remain the ordering and timing record; Team snapshots do not duplicate them. Roster and task reads reach callers as views; pending mail stays internal to delivery and recovery. The package [README](../../packages/experimental/agent-team/README.md) owns operation, authorization, recovery, and limit behavior. +The `agentTeam` Session projection replays one root Session into the roster, task board, and queued-minus-delivered mailbox that every Team operation reads. It selects records by `TeamId`, so events inherited by an ordinary fork retain the ancestor id and never enter the new root's state. Session event `seq` and `time` remain the ordering and timing record; Team snapshots do not duplicate them. Roster and task reads reach callers as views; pending mail stays internal to delivery and recovery. The package [README](../../packages/experimental/agent-team/README.md) owns operation, authorization, recovery, and limit behavior. @@ -176,13 +223,6 @@ interrupt(caller: Agent, targetName: string): { previousStatus: 'running' | 'ina * @returns Team membership, or undefined for non-Team subagents and stale identities. */ tryMembership(agent: Agent): TeamMembership | undefined - -/** - * Read the current roster and non-deleted task board through the generated Remote API. - * @param agent - exact live Team member used as the authority credential. - * @returns detached current roster and task views. - */ -@Remote('view') remoteView(agent: Agent): TeamView ``` Types: [Agent](core.md) diff --git a/docs/subsystems/agent-team.zh.md b/docs/subsystems/agent-team.zh.md index 24d7d73fda..083b9fafb6 100644 --- a/docs/subsystems/agent-team.zh.md +++ b/docs/subsystems/agent-team.zh.md @@ -2,7 +2,7 @@ [English](agent-team.md) | 中文 -实验性隐式 Root Team 领域、模型工具与宿主适配器共享的类型。[Agent Teams Agent Note](../../.agents/notes/implemented/feature/2026-08-05-agent-teams.zh.md)负责身份、mailbox、task 与共享 checkout 决策;本页记录 [`packages/experimental/agent-team/src/types.ts`](../../packages/experimental/agent-team/src/types.ts) 中的字面持久形式。 +实验性隐式 Root Team 领域、模型工具与宿主适配器共享的类型。[Agent Teams Agent Note](../../.agents/notes/implemented/feature/2026-08-05-agent-teams.zh.md)负责身份、mailbox、task 与共享 checkout 决策;本页记录 [`packages/experimental/agent-team/src/types.ts`](../../packages/experimental/agent-team/src/types.ts) 中的持久与客户端可见形式。 ## 身份与 roster @@ -73,9 +73,56 @@ interface TeamTaskSnapshot { `pending` 表示尚未开始或已经释放,`in_progress` 携带 owner,`completed` 满足 blocker,`deleted` 是保留的 tombstone。view 会添加 owner name、readiness 和 write-scope 重叠警告,但不会改变持久快照。 + + +## Web 投影 + +Lead Session 通过 `SessionProjectionMap.agentTeam` 发布持久 roster 行与未删除任务视图。`failure` 在最后有效状态旁报告被拒绝的持久记录。成员活动来自 Session 状态;模型标签来自各成员的 `modelSelection` 投影。 + +```ts type-equiv +/** One durable roster row published through the `agentTeam` Session projection. */ +interface TeamMemberProjection { + readonly id: SessionId + readonly name: string + readonly role: 'lead' | 'teammate' + /** Durable lifecycle; the Lead row is always `active`. Turn activity comes from Session status. */ + readonly phase: TeamMemberPhase + readonly error?: string +} +``` + +```ts type-equiv +/** Runtime-enriched task view returned to tools and hosts. */ +interface TeamTaskView { + readonly id: TeamTaskId + readonly revision: number + readonly subject: string + readonly description: string + readonly status: TeamTaskStatus + readonly blockedBy: TeamTaskId[] + readonly writeScopes: string[] + readonly ownerName?: string + readonly ready: boolean + readonly writeScopeWarnings: string[] +} +``` + +```ts type-equiv +/** + * Durable Team state published to browser clients through the Lead Session's + * `agentTeam` projection. `failure` names the first rejected persisted Team + * record; members and tasks then stay at the last valid state. + */ +interface TeamProjection { + readonly members: TeamMemberProjection[] + readonly tasks: TeamTaskView[] + readonly failure?: string +} +``` + ## 回放 -`foldTeam()` 把一个 Root Session 回放成每个 Team 操作所读取的 roster、任务板与 queued-minus-delivered mailbox。它按 `TeamId` 选取记录,因此普通 fork 继承的 event 保留 ancestor id,绝不会进入新 Root 的状态。Session event 的 `seq` 与 `time` 继续负责顺序和时间记录,Team snapshot 不再重复保存它们。roster 与 task 读取以 view 形式到达调用方,而 pending 邮件仅供投递与恢复内部使用。包 [README](../../packages/experimental/agent-team/README.zh.md)负责 operation、authorization、recovery 和限制行为。 +`agentTeam` Session 投影把一个 Root Session 回放成每个 Team 操作所读取的 roster、任务板与 queued-minus-delivered mailbox。它按 `TeamId` 选取记录,因此普通 fork 继承的 event 保留 ancestor id,绝不会进入新 Root 的状态。Session event 的 `seq` 与 `time` 继续负责顺序和时间记录,Team snapshot 不再重复保存它们。roster 与 task 读取以 view 形式到达调用方,而 pending 邮件仅供投递与恢复内部使用。包 [README](../../packages/experimental/agent-team/README.zh.md)负责 operation、authorization、recovery 和限制行为。 @@ -176,13 +223,6 @@ interrupt(caller: Agent, targetName: string): { previousStatus: 'running' | 'ina * @returns Team membership, or undefined for non-Team subagents and stale identities. */ tryMembership(agent: Agent): TeamMembership | undefined - -/** - * Read the current roster and non-deleted task board through the generated Remote API. - * @param agent - exact live Team member used as the authority credential. - * @returns detached current roster and task views. - */ -@Remote('view') remoteView(agent: Agent): TeamView ``` Types: [Agent](core.zh.md) diff --git a/docs/subsystems/boot.i18n.yaml b/docs/subsystems/boot.i18n.yaml index 00cd046aaa..598649b415 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: a1e8917adaf80315d871081740e5a76e5cd1e6dd -boot.zh.md: 1ba455d6ba9ff0053ff9d6f6d1313e7ae871a2a1 +boot.md: 5faf482b0532058e480adf13bf7fee8bb882498d +boot.zh.md: 1f50732fd36a996c6bc15b406f7e9a4ac83bcb26 diff --git a/docs/subsystems/boot.md b/docs/subsystems/boot.md index a1e8917ada..5faf482b05 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. @@ -129,7 +129,9 @@ Manage profile files and apply their declared reload lifecycle. @Remote setBundleEnabled(name: string, enabled: boolean): Promise /** - * Install a package using the same pnpm implementation as dsh plugin. A run + * Install a package using the same pnpm implementation as dsh plugin. GitHub + * repositories get a connection check bounded by githubConnectionTimeoutMs before pnpm starts; + * only network failures or timeouts stop installation, while pnpm owns authentication and transport fallback. A run * that fails, is cancelled, or adds a package without a bundle patch restores * `package.json` and `pnpm-lock.yaml` as they were; downloaded files can stay. * @param spec One package spec, including local paths relative to the invocation directory. @@ -148,7 +150,7 @@ Manage profile files and apply their declared reload lifecycle. /** Stop an installation this manager owns and wait until its files are back. * @param requestId The id the installation was started with. - * @returns `cancelled` once pnpm exited and the files are restored, `too-late` once the bundle is being + * @returns `cancelled` once the Git check or pnpm exited and the files are restored, `too-late` once the bundle is being * applied, `not-running` for any other id. */ @Remote async cancelInstall(requestId: PluginInstallRequestId): Promise diff --git a/docs/subsystems/boot.zh.md b/docs/subsystems/boot.zh.md index 1ba455d6ba..1f50732fd3 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 自身拉取的主机。 @@ -129,7 +129,9 @@ Manage profile files and apply their declared reload lifecycle. @Remote setBundleEnabled(name: string, enabled: boolean): Promise /** - * Install a package using the same pnpm implementation as dsh plugin. A run + * Install a package using the same pnpm implementation as dsh plugin. GitHub + * repositories get a connection check bounded by githubConnectionTimeoutMs before pnpm starts; + * only network failures or timeouts stop installation, while pnpm owns authentication and transport fallback. A run * that fails, is cancelled, or adds a package without a bundle patch restores * `package.json` and `pnpm-lock.yaml` as they were; downloaded files can stay. * @param spec One package spec, including local paths relative to the invocation directory. @@ -148,7 +150,7 @@ Manage profile files and apply their declared reload lifecycle. /** Stop an installation this manager owns and wait until its files are back. * @param requestId The id the installation was started with. - * @returns `cancelled` once pnpm exited and the files are restored, `too-late` once the bundle is being + * @returns `cancelled` once the Git check or pnpm exited and the files are restored, `too-late` once the bundle is being * applied, `not-running` for any other id. */ @Remote async cancelInstall(requestId: PluginInstallRequestId): Promise 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/subsystems/voice-input.i18n.yaml b/docs/subsystems/voice-input.i18n.yaml index c1b12085c1..c26921adf8 100644 --- a/docs/subsystems/voice-input.i18n.yaml +++ b/docs/subsystems/voice-input.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/voice-input.md -voice-input.md: 1c128b268c8fbbcabc13f4ca52daaa62534a6366 -voice-input.zh.md: 5ce0626f4dc769cb12a842b7ad296eb915cb94ea +voice-input.md: 1d85e8226afddad187533cfe5b14b6750d46c158 +voice-input.zh.md: 78a70ab9c265df86c1d99aa863a4be0ae1abbd9e diff --git a/docs/subsystems/voice-input.md b/docs/subsystems/voice-input.md index 1c128b268c..1d85e8226a 100644 --- a/docs/subsystems/voice-input.md +++ b/docs/subsystems/voice-input.md @@ -18,6 +18,8 @@ The input facade captures a revision-bearing selection before recording. `InputA ## Preparation and settings +`SpeechProviderInfo.downloadSources` advertises preparation origins. `SpeechPreparationOptions.downloadSource` selects one for a task; omission preserves provider policy. SenseVoice validates the advertised choice, pins a manual source without fallback, and rejects source changes during active preparation. The UI keeps a choice for retries in the current card; it is not a persisted recognition preference. + The Host provider owns one preparation task across page and Session changes. The Client shares one `follow()` subscription across the composer, setup prompt and bundle details. Optional `SpeechSetupEstimate` metadata supplies provider-specific planning hints, separate from measured progress. `SpeechPreparationStepKind` identifies ordered resource operations; `SpeechPreparationStep` records each status and start time. The complete `SpeechPreparationState` retains these steps after cancellation or failure. The collapsed UI shows the current operation; expanding lists all steps, with byte progress or elapsed time only for the running step. Closing an observer never cancels preparation. Verified files survive retries and idle worker reclamation. Failed preparation can include `SpeechDownloadFailure` with the asset, source origin, classified reason and optional diagnostic code or HTTP status. The Client localizes recovery advice; raw download causes remain on the Host. @@ -66,8 +68,9 @@ Speech calls never activate or submit to an Agent. /** * Start or join one Host-owned preparation task. * @param providerId - selected recognizer. + * @param options - task-local source selection validated by the provider. */ -@Remote prepare(providerId: SpeechProviderId): void +@Remote prepare(providerId: SpeechProviderId, options?: SpeechPreparationOptions): void /** * Explicitly cancel resource preparation. @@ -130,8 +133,9 @@ async configure(patch: SpeechSelectionPatch): Promise /** * Start or join provider-owned preparation. * @param id - exact registered provider identity. + * @param options - task-local source selection validated by the provider. */ -prepare(id: SpeechProviderId): void +prepare(id: SpeechProviderId, options?: SpeechPreparationOptions): void /** * Explicitly cancel provider preparation without tying it to a browser connection. diff --git a/docs/subsystems/voice-input.zh.md b/docs/subsystems/voice-input.zh.md index 5ce0626f4d..78a70ab9c2 100644 --- a/docs/subsystems/voice-input.zh.md +++ b/docs/subsystems/voice-input.zh.md @@ -18,6 +18,8 @@ ## 准备与设置 +`SpeechProviderInfo.downloadSources` 公布准备阶段的源。`SpeechPreparationOptions.downloadSource` 为单次任务选择一个源;省略时保留提供方策略。SenseVoice 校验公布的选项,固定使用手动指定的源而不回退,并拒绝在准备期间改源。UI 为当前卡片中的重试保留选择,但不会将其保存为识别偏好。 + Host Provider 在页面和 Session 变化期间拥有同一个准备任务。Client 在输入框、安装引导弹窗、Bundle 详情之间共享一份 `follow()` 订阅。可选 `SpeechSetupEstimate` 元数据提供各 Provider 的资源预期,与实测进度分开。`SpeechPreparationStepKind` 标识有序资源操作;`SpeechPreparationStep` 记录各步骤状态与开始时间。完整 `SpeechPreparationState` 在取消或失败后保留这些步骤。折叠 UI 显示当前操作,展开后列出所有步骤,仅进行中步骤显示字节进度或等待时间。关闭观察者不会取消准备。已校验文件在重试及空闲进程回收后继续复用。 准备失败时可包含 `SpeechDownloadFailure`,提供文件、下载源、原因分类以及可选错误码或 HTTP 状态。Client 将恢复建议本地化;原始下载错误留在 Host。 @@ -66,8 +68,9 @@ Speech calls never activate or submit to an Agent. /** * Start or join one Host-owned preparation task. * @param providerId - selected recognizer. + * @param options - task-local source selection validated by the provider. */ -@Remote prepare(providerId: SpeechProviderId): void +@Remote prepare(providerId: SpeechProviderId, options?: SpeechPreparationOptions): void /** * Explicitly cancel resource preparation. @@ -130,8 +133,9 @@ async configure(patch: SpeechSelectionPatch): Promise /** * Start or join provider-owned preparation. * @param id - exact registered provider identity. + * @param options - task-local source selection validated by the provider. */ -prepare(id: SpeechProviderId): void +prepare(id: SpeechProviderId, options?: SpeechPreparationOptions): void /** * Explicitly cancel provider preparation without tying it to a browser connection. diff --git a/docs/testing.i18n.yaml b/docs/testing.i18n.yaml index a044baf3b1..583a4c1f73 100644 --- a/docs/testing.i18n.yaml +++ b/docs/testing.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/testing.md -testing.md: 95badfd66ba49151a64dad3f5959a12265449f97 -testing.zh.md: a715fb0a6f285a908857a90e848213f42cc040cf +testing.md: c726270b796f6de125a762436ccd5bc7c74ba3bc +testing.zh.md: 977960c76d6455389a4058ebac305e6690342278 diff --git a/docs/testing.md b/docs/testing.md index 95badfd66b..c726270b79 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -12,7 +12,7 @@ How this repo tests, tier by tier, and the rules that keep a green suite meaning - **Owner-local expected output** (`pnpm run test:expected`): keyless assembled CLI/process expectations without a recorded-session round trip. Drivers use `*.expected.e2e.ts` beside `tests/expected/`; CI runs built exports. Package/script expectations use `test`, while browser expectations use `test:web`. - **Performance benchmarks** (`pnpm run test:bench`; required Linux PR gate `node 24 / benchmarks`): `benchmarks/` groups user-path gates. It builds libraries and workers; timed code runs under plain Node, never TSX. Synthetic inputs enforce time, heap, and scaling budgets; package-local `.perf.ts` stays diagnostic ([rules](../.agents/notes/implemented/testing/2026-09-04-session-open-performance-gate.md)). - **Snapshot** (`pnpm run test:snapshot`): a top-level scenario's highest recorded parent generation supplies user input and model replay, then serves as the expected persisted result. Parent filenames are `session[.vN].jsonl`; child roles are `session.[.vN].jsonl`; v0 omits `.v0`, positive versions require lowercase `.vN`, and each filename must agree with its header. Process scenarios start through `dsh`: headless owns one-shot behavior, the SDK owns persistent control, ACP owns automation-protocol behavior, and Web retains browser/ARIA evidence beside the same Session. `snapshot.yml` declares the profile, composition/header class, recording policy, exceptional replay or input metadata, and workspace facts. Typed tokens preserve parent/child identity relationships; only header pins own prompt/schema sidecars. A mutating scenario independently compares the complete `workspace.expected/` tree, which record and refresh never rewrite. Use `test:snapshot:record` when a model transcript changes and `test:snapshot:refresh` when replay input remains valid; review every resulting diff. -- **Web browser snapshot** (`pnpm run test:web`; required Linux PR gate): Chromium compares session-driven output under `snapshots/web/` and UI-only output under `apps/web/tests/expected/`. CI forces read-only `DSH_SNAPSHOT=replay`, never writing expected outputs; record/refresh stay local and every diff is reviewed ([web e2e lane](../.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md), [CI gate decision](../.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.md)). `test:web` builds first for plugin CSS. +- **Web browser snapshot** (`pnpm run test:web`; required Linux PR gate): The command builds plugin CSS, then compares all session-driven `snapshots/web/` and UI-only `apps/web/tests/expected/` output in Chromium; the model/reasoning picker also runs in WebKit. CI enforces read-only `DSH_SNAPSHOT=replay`; record/refresh stay local, with every diff reviewed ([web lane](../.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md), [CI decision](../.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.md)). Session fixtures retain headers and payloads but omit body sequence/time envelopes; replay synthesizes them. Replay, record, and refresh select each parent/child role's highest generation. Current fixtures use the [writer format](session-format-status.md) in their filenames and headers, one row per event, and embedded compact Assistant streams. Historical fixtures retain their released representation; explicit `sessionFormat` owners preserve migration coverage. Follow the [format-version cookbook](cookbook/adding-a-session-format-version.md#snapshot-successors) to add successors without changing predecessors. diff --git a/docs/testing.zh.md b/docs/testing.zh.md index a715fb0a6f..977960c76d 100644 --- a/docs/testing.zh.md +++ b/docs/testing.zh.md @@ -12,7 +12,7 @@ - **所属位置的预期输出**(`pnpm run test:expected`):无录制会话往返的无密钥组装 CLI/进程预期。驱动使用 `*.expected.e2e.ts`,并与 `tests/expected/` 同属一处;CI 针对构建产物运行。包/脚本预期使用 `test`,浏览器预期使用 `test:web`。 - **性能基准**(`pnpm run test:bench`;必需的 Linux PR gate `node 24 / benchmarks`):`benchmarks/` 按用户路径组织门禁。它先构建 library 和 worker;被计时代码在纯 Node 下运行,不使用 TSX。合成输入执行耗时、堆和缩放预算;包内 `.perf.ts` 保留为诊断([规则](../.agents/notes/implemented/testing/2026-09-04-session-open-performance-gate.zh.md))。 - **快照**(`pnpm run test:snapshot`):顶层场景数值最高的已录制 parent generation 同时提供用户输入和模型回放,并作为持久化结果的预期值。parent 文件名是 `session[.vN].jsonl`;child 角色使用 `session.[.vN].jsonl`;v0 省略 `.v0`,正版本必须使用小写 `.vN`,且每个文件名必须与其 header 一致。进程级场景都通过 `dsh` 启动:headless 负责一次性行为,SDK 负责持久控制,ACP 负责自动化协议行为,Web 在同一 Session 旁保留浏览器与 ARIA 证据。`snapshot.yml` 声明 profile、组合与请求头类别、录制策略、例外回放或输入元数据以及 workspace 事实。带类型的 token 保留父子身份关系;只有请求头 pin 拥有 prompt/schema sidecar。变更 workspace 的场景会独立比较完整的 `workspace.expected/` 目录,record 与 refresh 绝不改写该目录。当模型 transcript(文本记录)变化时使用 `test:snapshot:record`,回放输入仍有效时使用 `test:snapshot:refresh`;请审查所有结果差异。 -- **Web 浏览器快照**(`pnpm run test:web`;必需的 Linux PR(Pull Request)门禁):Chromium 比较 `snapshots/web/` 下由会话驱动的输出,以及 `apps/web/tests/expected/` 下仅含 UI 的输出。CI 强制只读的 `DSH_SNAPSHOT=replay`,绝不写入预期输出;record/refresh 留在本地,每处 diff 都须评审([web e2e 车道](../.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md)、[CI 门禁决策](../.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.zh.md))。`test:web` 会先构建以交付插件 CSS。 +- **Web 浏览器快照**(`pnpm run test:web`;必需的 Linux PR(Pull Request)门禁):命令先构建插件 CSS,再用 Chromium 比较 `snapshots/web/` 下全部会话驱动输出和 `apps/web/tests/expected/` 下全部纯 UI 输出;模型与推理强度选择场景还在 WebKit 中运行。CI 强制只读的 `DSH_SNAPSHOT=replay`;record/refresh 留在本地,每处 diff 都须评审([web 车道](../.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md)、[CI 决策](../.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.zh.md))。 Session fixture 保留 header 与 payload,但省略正文 seq/time envelope;replay 会合成这些 envelope。Replay、record 与 refresh 会选择每个 parent/child 角色的最高 generation。当前 fixture 在文件名与 header 中使用[写入格式](session-format-status.zh.md),每个事件一行,并嵌入紧凑 Assistant stream。历史 fixture 保留其已发布表示;显式 `sessionFormat` 所有者保留迁移覆盖。按照[格式版本实操手册](cookbook/adding-a-session-format-version.zh.md#snapshot-successors)添加后继代际,不改动前代。 diff --git a/native/system/package.json b/native/system/package.json index f14407c86d..a4fd9bcf0c 100644 --- a/native/system/package.json +++ b/native/system/package.json @@ -26,7 +26,7 @@ "release:verify-packed-install": "node ./scripts/verify-packed-install.mjs" }, "devDependencies": { - "@deepseek-ai/node-addon-system": "workspace:*", + "@deepseek-ai/node-addon-system": "workspace:~", "@types/node": "^26.0.1", "tsx": "^4.20.6", "typescript": "^6.0.3" diff --git a/native/system/packages/entry/README.i18n.yaml b/native/system/packages/entry/README.i18n.yaml index c9b522958b..796ea55afc 100644 --- a/native/system/packages/entry/README.i18n.yaml +++ b/native/system/packages/entry/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 native/system/packages/entry/README.md -README.md: 8f6a4d341c8f9e4c104e5706e08d9a7d45c065e1 -README.zh.md: e401fc2ec71b1afb664d8e73bf3e9372054957a7 +README.md: 0ffd2b38a78c562dad7c6760e8dbd79fbf8151c2 +README.zh.md: fd4f9acc56ed59d69309c5b9e8f9f6d6ce0ca926 diff --git a/native/system/packages/entry/README.md b/native/system/packages/entry/README.md index 8f6a4d341c..0ffd2b38a7 100644 --- a/native/system/packages/entry/README.md +++ b/native/system/packages/entry/README.md @@ -10,6 +10,6 @@ The `./landlock-run` entry exports the Landlock launcher path, enforcement probe The lock operation attempts `LOCK_EX | LOCK_NB` asynchronously. Keep the caller-owned descriptor open until completion; contention rejects with `EAGAIN`/`EWOULDBLOCK`, other syscall failures also reject, and errors carry their code, positive errno, and `syscall: 'flock'`. Native setup errors reject the same promise. Closing the last descriptor for the open file description releases the lock. The binding does not open, duplicate, close, or explicitly unlock descriptors. -Optional OS/CPU platform packages carry the binaries. Linux has `bin/landlock-run` and separate `bin/glibc/system.node` / `bin/musl/system.node`; macOS has `bin/system.node`. Missing or unloadable flock bindings reject acquisition, without installation-time compilation. Landlock remains a separate executable with its existing fail-closed protocol; unsupported kernels/platforms probe unusable. +Optional OS/CPU platform packages carry the binaries. Their `~x.y.z` dependency ranges allow later patches within the same minor version. Linux has `bin/landlock-run` and separate `bin/glibc/system.node` / `bin/musl/system.node`; macOS has `bin/system.node`. Missing or unloadable flock bindings reject acquisition, without installation-time compilation. Landlock remains a separate executable with its existing fail-closed protocol; unsupported kernels/platforms probe unusable. The two C sources ship for auditability. See the workspace [architecture](../../docs/architecture.md), [support matrix](../../docs/support-matrix.md), and [CLI contract](../../docs/cli-contract.md). diff --git a/native/system/packages/entry/README.zh.md b/native/system/packages/entry/README.zh.md index e401fc2ec7..fd4f9acc56 100644 --- a/native/system/packages/entry/README.zh.md +++ b/native/system/packages/entry/README.zh.md @@ -10,6 +10,6 @@ kind: "package-library" 锁操作异步尝试 `LOCK_EX | LOCK_NB`。在完成前保持调用方拥有的描述符打开;竞争以 `EAGAIN`/`EWOULDBLOCK` 拒绝,其他系统调用失败也会拒绝,错误携带 code、值为正数的 errno 和 `syscall: 'flock'`。原生调用准备阶段的错误也会拒绝同一个 promise。关闭指向该打开文件描述的最后一个描述符即释放锁。绑定不打开、复制、关闭或显式解锁描述符。 -可选操作系统/CPU 平台包携带二进制。Linux 包含 `bin/landlock-run` 和分别用于两种 libc 的 `bin/glibc/system.node` / `bin/musl/system.node`;macOS 包含 `bin/system.node`。flock 绑定缺失或无法加载时,锁获取请求会被拒绝,不在安装时编译。Landlock 仍是遵循既有失败关闭协议的独立可执行文件;不支持的内核或平台探测结果为不可用。 +可选操作系统/CPU 平台包携带二进制。它们的 `~x.y.z` 依赖范围允许同一次版本内的后续补丁版本。Linux 包含 `bin/landlock-run` 和分别用于两种 libc 的 `bin/glibc/system.node` / `bin/musl/system.node`;macOS 包含 `bin/system.node`。flock 绑定缺失或无法加载时,锁获取请求会被拒绝,不在安装时编译。Landlock 仍是遵循既有失败关闭协议的独立可执行文件;不支持的内核或平台探测结果为不可用。 两个 C 源文件随包分发以供审计。参见工作区[架构](../../docs/architecture.md)、[支持矩阵](../../docs/support-matrix.md)和 [CLI 约定](../../docs/cli-contract.md)。 diff --git a/native/system/packages/entry/package.json b/native/system/packages/entry/package.json index 99080236d2..24c5992fdc 100644 --- a/native/system/packages/entry/package.json +++ b/native/system/packages/entry/package.json @@ -38,9 +38,9 @@ "access": "public" }, "optionalDependencies": { - "@deepseek-ai/node-addon-system-darwin-arm64": "workspace:*", - "@deepseek-ai/node-addon-system-darwin-x64": "workspace:*", - "@deepseek-ai/node-addon-system-linux-arm64": "workspace:*", - "@deepseek-ai/node-addon-system-linux-x64": "workspace:*" + "@deepseek-ai/node-addon-system-darwin-arm64": "workspace:~", + "@deepseek-ai/node-addon-system-darwin-x64": "workspace:~", + "@deepseek-ai/node-addon-system-linux-arm64": "workspace:~", + "@deepseek-ai/node-addon-system-linux-x64": "workspace:~" } } diff --git a/native/system/test/release-packing.test.js b/native/system/test/release-packing.test.js index bfca35fbc7..86ccf03078 100644 --- a/native/system/test/release-packing.test.js +++ b/native/system/test/release-packing.test.js @@ -46,7 +46,7 @@ function fixture(t) { writeJson('packages/entry/package.json', { name: '@fixture/native', version: '1.2.3', type: 'module', repository: repository('entry'), exports: { '.': './lib/index.js' }, files: ['lib/'], - optionalDependencies: { '@fixture/native-linux-x64': 'workspace:*' }, + optionalDependencies: { '@fixture/native-linux-x64': 'workspace:~' }, scripts: { prepack: 'node ../../scripts/verify-entry-lib.mjs' }, }); fs.mkdirSync(path.join(dir, 'packages/entry/lib')); @@ -102,7 +102,7 @@ for (const workflow of [false, true]) { } assert.equal(manifests[0].repository.directory, 'native/system/packages/linux-x64'); assert.equal(manifests[1].repository.directory, 'native/system/packages/entry'); - assert.equal(manifests[1].optionalDependencies['@fixture/native-linux-x64'], '1.2.3'); + assert.equal(manifests[1].optionalDependencies['@fixture/native-linux-x64'], '~1.2.3'); assert.equal(tarFile(f.dir, files[1], 'LICENSE').toString(), workspaceLicense); assert.deepEqual(tarFile(f.dir, files[0], 'bin/landlock-run'), f.binary); const extracted = path.join(f.dir, 'extracted'); diff --git a/package.json b/package.json index a7acc3ab12..986af9f481 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-root", - "version": "0.1.7-alpha.1", + "version": "0.1.7-alpha.2", "license": "MIT", "private": true, "type": "module", @@ -204,19 +204,19 @@ "postinstall": "node scripts/install-lefthook.mjs" }, "devDependencies": { - "@deepseek-ai/dsh-agent": "workspace:^", - "@deepseek-ai/dsh-browser-use": "workspace:^", - "@deepseek-ai/dsh-experimental-browser-use-runtime": "workspace:^", - "@deepseek-ai/dsh-experimental-browser-use-playwright-mcp": "workspace:^", - "@deepseek-ai/dsh-experimental-browser-use-chrome-devtools-mcp": "workspace:^", - "@deepseek-ai/dsh-experimental-browser-use-stagehand-native": "workspace:^", - "@deepseek-ai/dsh-computer-use": "workspace:^", - "@deepseek-ai/dsh-experimental-computer-use-cua-driver-mcp": "workspace:^", - "@deepseek-ai/dsh-experimental-computer-use-cua-driver-native": "workspace:^", - "@deepseek-ai/dsh-llm-replay": "workspace:^", - "@deepseek-ai/dsh-mcp-client": "workspace:^", - "@deepseek-ai/dsh-tool-session-query": "workspace:^", - "@deepseek-ai/dsh-web-fetch-http": "workspace:^", + "@deepseek-ai/dsh-agent": "workspace:*", + "@deepseek-ai/dsh-browser-use": "workspace:*", + "@deepseek-ai/dsh-experimental-browser-use-runtime": "workspace:*", + "@deepseek-ai/dsh-experimental-browser-use-playwright-mcp": "workspace:*", + "@deepseek-ai/dsh-experimental-browser-use-chrome-devtools-mcp": "workspace:*", + "@deepseek-ai/dsh-experimental-browser-use-stagehand-native": "workspace:*", + "@deepseek-ai/dsh-computer-use": "workspace:*", + "@deepseek-ai/dsh-experimental-computer-use-cua-driver-mcp": "workspace:*", + "@deepseek-ai/dsh-experimental-computer-use-cua-driver-native": "workspace:*", + "@deepseek-ai/dsh-llm-replay": "workspace:*", + "@deepseek-ai/dsh-mcp-client": "workspace:*", + "@deepseek-ai/dsh-tool-session-query": "workspace:*", + "@deepseek-ai/dsh-web-fetch-http": "workspace:*", "@stylistic/eslint-plugin": "^5.10.0", "@testing-library/dom": "^10.4.1", "@testing-library/react": "^16.3.2", @@ -255,7 +255,7 @@ "typescript": "^6.0.3", "vite-tsconfig-paths": "^6.1.1", "vitest": "^4.1.8", - "@deepseek-ai/dsh-skill-office": "workspace:^", - "@deepseek-ai/dsh-tool-workspace-dependencies": "workspace:^" + "@deepseek-ai/dsh-skill-office": "workspace:*", + "@deepseek-ai/dsh-tool-workspace-dependencies": "workspace:*" } } diff --git a/packages/acp/acp/package.json b/packages/acp/acp/package.json index 9edecb9090..086843b9b5 100644 --- a/packages/acp/acp/package.json +++ b/packages/acp/acp/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-acp", "description": "Automation-only Agent Client Protocol server for driving DeepSeek Harness agents over JSON-RPC stdio", - "version": "0.1.7-alpha.1", + "version": "0.1.7-alpha.2", "publishConfig": { "access": "public" }, @@ -28,19 +28,19 @@ "license": "MIT", "dependencies": { "@agentclientprotocol/sdk": "1.4.0", - "@deepseek-ai/dsh-brand": "workspace:^", - "@deepseek-ai/schemastery": "workspace:^" + "@deepseek-ai/dsh-brand": "workspace:*", + "@deepseek-ai/schemastery": "workspace:~" }, "peerDependencies": { - "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-agent": "workspace:^", - "@deepseek-ai/dsh-attachment": "workspace:^", - "@deepseek-ai/dsh-llm": "workspace:^", - "@deepseek-ai/dsh-mcp-client": "workspace:^", - "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/dsh-session-persistence": "workspace:^", - "@deepseek-ai/dsh-token-meter": "workspace:^", - "@deepseek-ai/dsh-user-approval": "workspace:^" + "@deepseek-ai/cordis": "workspace:~", + "@deepseek-ai/dsh-agent": "workspace:*", + "@deepseek-ai/dsh-attachment": "workspace:*", + "@deepseek-ai/dsh-llm": "workspace:*", + "@deepseek-ai/dsh-mcp-client": "workspace:*", + "@deepseek-ai/dsh-session": "workspace:*", + "@deepseek-ai/dsh-session-persistence": "workspace:*", + "@deepseek-ai/dsh-token-meter": "workspace:*", + "@deepseek-ai/dsh-user-approval": "workspace:*" }, "peerDependenciesMeta": { "@deepseek-ai/dsh-token-meter": { @@ -48,19 +48,19 @@ } }, "devDependencies": { - "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-agent": "workspace:^", - "@deepseek-ai/dsh-agent-loop": "workspace:^", - "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^", - "@deepseek-ai/dsh-attachment": "workspace:^", - "@deepseek-ai/dsh-llm": "workspace:^", - "@deepseek-ai/dsh-mcp-client": "workspace:^", - "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/dsh-session-persistence": "workspace:^", - "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", - "@deepseek-ai/dsh-session-projection": "workspace:^", - "@deepseek-ai/dsh-token-meter": "workspace:^", - "@deepseek-ai/dsh-tools": "workspace:^", - "@deepseek-ai/dsh-user-approval": "workspace:^" + "@deepseek-ai/cordis": "workspace:~", + "@deepseek-ai/dsh-agent": "workspace:*", + "@deepseek-ai/dsh-agent-loop": "workspace:*", + "@deepseek-ai/dsh-agent-loop-testkit": "workspace:*", + "@deepseek-ai/dsh-attachment": "workspace:*", + "@deepseek-ai/dsh-llm": "workspace:*", + "@deepseek-ai/dsh-mcp-client": "workspace:*", + "@deepseek-ai/dsh-session": "workspace:*", + "@deepseek-ai/dsh-session-persistence": "workspace:*", + "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:*", + "@deepseek-ai/dsh-session-projection": "workspace:*", + "@deepseek-ai/dsh-token-meter": "workspace:*", + "@deepseek-ai/dsh-tools": "workspace:*", + "@deepseek-ai/dsh-user-approval": "workspace:*" } } diff --git a/packages/api/account-controller/package.json b/packages/api/account-controller/package.json index 122558236b..714c2c4e40 100644 --- a/packages/api/account-controller/package.json +++ b/packages/api/account-controller/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-api-account-controller", "description": "Expose safe account operations over authenticated Remote", - "version": "0.1.7-alpha.1", + "version": "0.1.7-alpha.2", "publishConfig": { "access": "public" }, @@ -47,13 +47,13 @@ "zod": "^4.4.3" }, "peerDependencies": { - "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-deepseek-account": "workspace:^", - "@deepseek-ai/dsh-typert-protocol": "workspace:^" + "@deepseek-ai/cordis": "workspace:~", + "@deepseek-ai/dsh-deepseek-account": "workspace:*", + "@deepseek-ai/dsh-typert-protocol": "workspace:*" }, "devDependencies": { - "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-deepseek-account": "workspace:^", - "@deepseek-ai/dsh-typert-protocol": "workspace:^" + "@deepseek-ai/cordis": "workspace:~", + "@deepseek-ai/dsh-deepseek-account": "workspace:*", + "@deepseek-ai/dsh-typert-protocol": "workspace:*" } } diff --git a/packages/api/gateway/README.i18n.yaml b/packages/api/gateway/README.i18n.yaml index e08b3475e6..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: b5b64d3fa073c4f992529b63bc2e02117a28cecf -README.zh.md: 87919703fdb2a1f9e32603bf6a1d152d4cc5404e +README.md: e0c986e290b1744017fca99e3a2553a68d754ef6 +README.zh.md: 8be11a7c5aea1e299c4ca45cde4b1c9dfa08ec4b diff --git a/packages/api/gateway/README.md b/packages/api/gateway/README.md index b5b64d3fa0..e0c986e290 100644 --- a/packages/api/gateway/README.md +++ b/packages/api/gateway/README.md @@ -34,8 +34,12 @@ A cancellation-aware Remote method declares `signal: AbortSignal` as its final H A stream Remote uses `@Remote({ mode: 'stream' })` and returns an `Iterable` or `AsyncIterable`. `ctx.typertGateway.stream()` applies the same endpoint, argument, lookup, and cancellation checks as unary invocation, then returns a cancellation-aware iterable over the business items. The Client opens the Gateway-owned `/api/remote.mux` WebSocket when its plugin activates and keeps it connected while idle. Connection owns the retry schedule; before each retry it asks the mux to cancel any candidate or active socket and make exactly one fresh physical attempt. The Host sends Ping control frames at the configured `websocketHeartbeatIntervalMs` interval (two seconds by default), and the browser answers Pong at the WebSocket protocol layer, so idle network intermediaries see traffic without any Remote stream frame. A socket that has not answered the previous Ping is terminated at the next interval. Independently cancellable logical streams share that socket; an in-process Connection carrier provides equivalent streams directly without opening it. +When the launcher supplies `ctx.appReady`, Gateway registers the WebSocket upgrade route only after successful application startup. Earlier connection attempts remain carrier failures handled by Connection's retry policy, so a restarting Host cannot accept streams while its controllers are still initializing. Unloading cancels a pending readiness subscription and closes any registered carrier. Embedded Hosts without `appReady` register immediately and own their startup ordering; in-process invocation is unchanged. + 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 87919703fd..8be11a7c5a 100644 --- a/packages/api/gateway/README.zh.md +++ b/packages/api/gateway/README.zh.md @@ -34,8 +34,12 @@ Connection 可用时,Host 入口会在 Connection 共享的 `/api` FetchHandle 流式 Remote 使用 `@Remote({ mode: 'stream' })` 并返回 `Iterable` 或 `AsyncIterable`。`ctx.typertGateway.stream()` 执行与一元调用相同的 endpoint、参数、lookup 和取消校验,再返回可取消的业务项 iterable。Client 插件激活时打开 Gateway 自有的 `/api/remote.mux` WebSocket,并让它在空闲时保持连接。Connection 拥有重试调度;每次 retry 前,它要求 mux 取消候选或活动 socket,并且只做一次全新的物理连接尝试。Host 按配置的 `websocketHeartbeatIntervalMs` 间隔(默认 2 秒)发送 Ping 控制帧,浏览器在 WebSocket 协议层自动回复 Pong,使空闲网络中间层持续看到流量,而不新增 Remote 流帧。若 socket 尚未回复上一次 Ping,Host 会在下一间隔终止它。可独立取消的逻辑流共享这条连接;进程内 Connection 载体直接提供等价的流,不打开该 WebSocket。 +启动器提供 `ctx.appReady` 时,Gateway 仅在应用成功启动后注册 WebSocket 升级路由。此前的连接尝试仍属于载体故障,由 Connection 的重试策略处理,因此重启中的 Host 不会在控制器仍在初始化时接受流。卸载会取消尚未触发的就绪订阅,并关闭已注册的载体。不提供 `appReady` 的嵌入式 Host 会立即注册,并自行负责启动顺序;进程内调用不变。 + 每条流还在同一条逻辑流上携带从 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/package.json b/packages/api/gateway/package.json index 9985966d8b..48dccab35f 100644 --- a/packages/api/gateway/package.json +++ b/packages/api/gateway/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-api-gateway", "description": "Typert Remote Host dispatcher and Client API endpoint", - "version": "0.1.7-alpha.1", + "version": "0.1.7-alpha.2", "publishConfig": { "access": "public" }, @@ -55,25 +55,26 @@ ], "license": "MIT", "dependencies": { - "@deepseek-ai/dsh-deque": "workspace:^", - "@deepseek-ai/dsh-timeout": "workspace:^", - "@deepseek-ai/schemastery": "workspace:^", + "@deepseek-ai/dsh-deque": "workspace:*", + "@deepseek-ai/dsh-timeout": "workspace:*", + "@deepseek-ai/schemastery": "workspace:~", "ws": "^8.21.0", - "@deepseek-ai/dsh-typert-protocol": "workspace:^" + "@deepseek-ai/dsh-typert-protocol": "workspace:*" }, "peerDependencies": { - "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-client-connection": "workspace:^" + "@deepseek-ai/cordis": "workspace:~", + "@deepseek-ai/dsh-client-connection": "workspace:*" }, "devDependencies": { - "@deepseek-ai/dsh-brand": "workspace:^", - "@deepseek-ai/dsh-client-connection": "workspace:^", - "@deepseek-ai/dsh-host-webserver": "workspace:^", - "@deepseek-ai/dsh-remote-mock": "workspace:^", - "@deepseek-ai/dsh-typert-registry": "workspace:^", - "@deepseek-ai/dsh-util-crypto": "workspace:^", + "@deepseek-ai/dsh-brand": "workspace:*", + "@deepseek-ai/dsh-cmdline": "workspace:*", + "@deepseek-ai/dsh-client-connection": "workspace:*", + "@deepseek-ai/dsh-host-webserver": "workspace:*", + "@deepseek-ai/dsh-remote-mock": "workspace:*", + "@deepseek-ai/dsh-typert-registry": "workspace:*", + "@deepseek-ai/dsh-util-crypto": "workspace:*", "@types/ws": "^8.18.1", - "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/cordis": "workspace:~", "zod": "^4.4.3" } } 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 d9bc91d52a..7c7a29b66a 100644 --- a/packages/api/gateway/src/index.ts +++ b/packages/api/gateway/src/index.ts @@ -15,6 +15,7 @@ import { import { Deque } from '@deepseek-ai/dsh-deque' import type { WebUpgradeRoute } from '@deepseek-ai/dsh-host-webserver' import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' +import type {} from '@deepseek-ai/dsh-cmdline' import z from '@deepseek-ai/schemastery' export type { TypertGatewayFaultDetails } from './remote-error-codes.ts' import { @@ -217,6 +218,8 @@ export class TypertGatewayService extends Service implements TypertGateway { /** * Register the Gateway against the active Typert registry. + * WebSocket admission waits for launcher-owned application readiness when supplied; + * direct invocation and in-process streams remain available independently. * @param ctx - owning Host Context with Typert registry access. * @param config - validated Gateway transport configuration. */ @@ -234,31 +237,42 @@ export class TypertGatewayService extends Service implements TypertGateway { ) }) ctx.inject(['connection', 'webServer'], (webCtx) => { - const mux = new RemoteStreamMuxServer( - (endpoint, payload, uplink, peer, control) => - this.openWireStream(endpoint, payload, uplink, peer, control.signal, control), - this.wireStream.failure, - resolved.websocketHeartbeatIntervalMs, - resolved.streamInboxBytes, - ) - webCtx.effect(() => { - const route: WebUpgradeRoute = { - path: REMOTE_STREAM_MUX_PATH, - handler: (req, socket, head) => { - const admission = webCtx.connection.admit(req) - if ('rejection' in admission) { - rejectRemoteStreamUpgrade(socket, admission.rejection) - return - } - mux.handleUpgrade(req, socket, head, admission.peer) - }, + const listen = (): void => { + const mux = new RemoteStreamMuxServer( + (endpoint, payload, uplink, peer, control) => + this.openWireStream(endpoint, payload, uplink, peer, control.signal, control), + this.wireStream.failure, + resolved.websocketHeartbeatIntervalMs, + resolved.streamInboxBytes, + ) + webCtx.effect(function* () { + yield () => mux.close() + const route: WebUpgradeRoute = { + path: REMOTE_STREAM_MUX_PATH, + handler: (req, socket, head) => { + const admission = webCtx.connection.admit(req) + if ('rejection' in admission) { + rejectRemoteStreamUpgrade(socket, admission.rejection) + return + } + mux.handleUpgrade(req, socket, head, admission.peer) + }, + } + yield webCtx.webServer.registerUpgrade(route) + }, `api-gateway: ${REMOTE_STREAM_MUX_PATH} WebSocket`) + } + // Existing pages reconnect before the new Host prints its URL. No stream + // may enter until the launcher has activated and audited its controllers. + const ready = webCtx.get('appReady') + if (ready === undefined) listen() + else webCtx.effect(() => { + let closed = false + const cancel = ready.onReady(() => { if (!closed) listen() }) + return () => { + closed = true + cancel() } - const unregister = webCtx.webServer.registerUpgrade(route) - return async () => { - unregister() - await mux.close() - } - }, `api-gateway: ${REMOTE_STREAM_MUX_PATH} WebSocket`) + }, 'api-gateway: application readiness') }) } @@ -1150,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. @@ -1195,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 @@ -1209,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 }) } @@ -1223,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 @@ -1256,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 7ed908f620..87aaaa9f75 100644 --- a/packages/api/gateway/tests/gateway-stream.host.spec.ts +++ b/packages/api/gateway/tests/gateway-stream.host.spec.ts @@ -1,11 +1,13 @@ 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' import { apply as applyConnection, inject as connectionInject } from '@deepseek-ai/dsh-client-connection' import WebServer from '@deepseek-ai/dsh-host-webserver' import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' +import type { AppReady } from '@deepseek-ai/dsh-cmdline' import { Remote, remoteErrorOf, @@ -55,6 +57,7 @@ class FeedService extends TypertRemoteService { readonly peeked: unknown[] = [] readonly peers: PeerScope[] = [] leftover: AsyncIterator | undefined + source: Iterable = [] returns = 0 constructor(ctx: Context) { @@ -81,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 @@ -229,6 +237,30 @@ class FeedService extends TypertRemoteService { const roots: Context[] = [] +class StartupProbe implements AppReady { + private ready = false + private readonly listeners = new Set<() => void>() + lastListener: (() => void) | undefined + + get pending(): number { return this.listeners.size } + + onReady(listener: () => void): () => void { + this.lastListener = listener + if (this.ready) { + listener() + return () => {} + } + this.listeners.add(listener) + return () => { this.listeners.delete(listener) } + } + + commit(): void { + this.ready = true + for (const listener of this.listeners) listener() + this.listeners.clear() + } +} + class RemoteEventSourceProbe { readonly source = (signal: AbortSignal): AsyncIterable => { this.signal = signal @@ -307,6 +339,30 @@ afterEach(async () => { }) describe('Typert Remote streams', () => { + it.each([false, true])('accepts WebSockets only after application readiness (already ready: %s)', async (alreadyReady) => { + const startup = new StartupProbe() + if (alreadyReady) startup.commit() + const { ctx } = await setup(true, {}, startup) + if (!alreadyReady) { + expect(startup.pending).toBe(1) + expect(await acceptsSocket(ctx)).toBe(false) + startup.commit() + } + expect(await acceptsSocket(ctx)).toBe(true) + expect(startup.pending).toBe(0) + }) + + it('withdraws a pending WebSocket startup subscription when the Gateway unloads', async () => { + const startup = new StartupProbe() + const { ctx } = await setup(true, {}, startup) + expect(startup.pending).toBe(1) + await ctx.fiber.dispose() + expect(startup.pending).toBe(0) + // A launcher commit can already hold a copy of the cancelled listener. + expect(() => { startup.lastListener?.() }).not.toThrow() + expect(() => { startup.commit() }).not.toThrow() + }) + it('validates the WebSocket heartbeat timer range and the stream inbox bound', () => { expect(TypertGatewayService.Config({})).toEqual({ websocketHeartbeatIntervalMs: 2_000, streamInboxBytes: 262_144 }) expect(TypertGatewayService.Config({ websocketHeartbeatIntervalMs: MAX_TIMER_DELAY_MS, streamInboxBytes: 1 })) @@ -492,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({ @@ -1341,9 +1501,11 @@ describe('Typert Remote streams', () => { async function setup( transport: boolean, gatewayConfig: GatewayConfig = {}, + ready?: AppReady, ): Promise<{ readonly ctx: Context; readonly service: FeedService }> { const ctx = new Context() roots.push(ctx) + if (ready !== undefined) ctx.provide('appReady', ready) if (transport) { await ctx.plugin(WebServer, { host: '127.0.0.1', port: 0 }) provideBrowserCredentials(ctx) @@ -1365,6 +1527,17 @@ async function setup( return { ctx, service: receiver[symbols.original] ?? receiver } } +async function acceptsSocket(ctx: Context): Promise { + const socket = new WebSocket(`ws://127.0.0.1:${String(ctx.webServer.port)}/api/remote.mux`, { + headers: { cookie: browserCookie(ctx) }, + }) + const closed = new Promise((resolve) => { socket.once('close', () => { resolve() }) }) + const opened = await once(socket, 'open').then(() => true, () => false) + if (opened) socket.close() + await closed + return opened +} + function descriptors(): InvocationDescriptor[] { const label = { name: 'label', @@ -1399,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/api/gateway/tsconfig.host.json b/packages/api/gateway/tsconfig.host.json index 0b3353158f..5cb68a7183 100644 --- a/packages/api/gateway/tsconfig.host.json +++ b/packages/api/gateway/tsconfig.host.json @@ -13,6 +13,9 @@ "src/types.ts" ], "references": [ + { + "path": "../../boot/cmdline" + }, { "path": "../../../vendor/cosmokit" }, diff --git a/packages/api/job-controller/package.json b/packages/api/job-controller/package.json index dde1d2000b..7cf32571b1 100644 --- a/packages/api/job-controller/package.json +++ b/packages/api/job-controller/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-api-job-controller", "description": "Job Remote observation stream and the reference-counted client job-output service", - "version": "0.1.7-alpha.1", + "version": "0.1.7-alpha.2", "publishConfig": { "access": "public" }, @@ -65,22 +65,22 @@ ], "license": "MIT", "dependencies": { - "@deepseek-ai/dsh-typert-protocol": "workspace:^", - "@deepseek-ai/schemastery": "workspace:^", + "@deepseek-ai/dsh-typert-protocol": "workspace:*", + "@deepseek-ai/schemastery": "workspace:~", "zod": "^4.4.3" }, "peerDependencies": { - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:~" }, "devDependencies": { - "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-agent": "workspace:^", - "@deepseek-ai/dsh-api-gateway": "workspace:^", - "@deepseek-ai/dsh-client-connection": "workspace:^", - "@deepseek-ai/dsh-client-store": "workspace:^", - "@deepseek-ai/dsh-jobs": "workspace:^", - "@deepseek-ai/dsh-jobs-local": "workspace:^", - "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/dsh-typert-registry": "workspace:^" + "@deepseek-ai/cordis": "workspace:~", + "@deepseek-ai/dsh-agent": "workspace:*", + "@deepseek-ai/dsh-api-gateway": "workspace:*", + "@deepseek-ai/dsh-client-connection": "workspace:*", + "@deepseek-ai/dsh-client-store": "workspace:*", + "@deepseek-ai/dsh-jobs": "workspace:*", + "@deepseek-ai/dsh-jobs-local": "workspace:*", + "@deepseek-ai/dsh-session": "workspace:*", + "@deepseek-ai/dsh-typert-registry": "workspace:*" } } diff --git a/packages/api/remotes/package.json b/packages/api/remotes/package.json index 5b423ce0cf..dc33e2f2c0 100644 --- a/packages/api/remotes/package.json +++ b/packages/api/remotes/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-api-remotes", "description": "Remote BFF assembly for application-selected Host capabilities", - "version": "0.1.7-alpha.1", + "version": "0.1.7-alpha.2", "publishConfig": { "access": "public" }, @@ -50,53 +50,53 @@ "lib/types/**/*.d.ts" ], "dependencies": { - "@deepseek-ai/dsh-deque": "workspace:^", - "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/dsh-util-values": "workspace:^" + "@deepseek-ai/dsh-deque": "workspace:*", + "@deepseek-ai/dsh-session": "workspace:*", + "@deepseek-ai/dsh-util-values": "workspace:*" }, "peerDependencies": { - "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-scope": "workspace:^" + "@deepseek-ai/cordis": "workspace:~", + "@deepseek-ai/dsh-scope": "workspace:*" }, "devDependencies": { - "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-agent": "workspace:^", - "@deepseek-ai/dsh-agent-preset-registry": "workspace:^", - "@deepseek-ai/dsh-api-gateway": "workspace:^", - "@deepseek-ai/dsh-api-job-controller": "workspace:^", - "@deepseek-ai/dsh-api-session-controller": "workspace:^", - "@deepseek-ai/dsh-api-settings-controller": "workspace:^", - "@deepseek-ai/dsh-api-workspace-controller": "workspace:^", - "@deepseek-ai/dsh-client-connection": "workspace:^", - "@deepseek-ai/dsh-client-file-upload": "workspace:^", - "@deepseek-ai/dsh-commands": "workspace:^", - "@deepseek-ai/dsh-cordis-host-runner": "workspace:^", - "@deepseek-ai/dsh-credentials": "workspace:^", - "@deepseek-ai/dsh-file-reference": "workspace:^", - "@deepseek-ai/dsh-goal": "workspace:^", - "@deepseek-ai/dsh-host-plugin-inventory": "workspace:^", - "@deepseek-ai/dsh-llm": "workspace:^", - "@deepseek-ai/dsh-message-feedback": "workspace:^", - "@deepseek-ai/dsh-scope": "workspace:^", - "@deepseek-ai/dsh-permission-presets": "workspace:^", - "@deepseek-ai/dsh-session-reference": "workspace:^", - "@deepseek-ai/dsh-settings": "workspace:^", - "@deepseek-ai/dsh-subagent": "workspace:^", - "@deepseek-ai/dsh-typert-protocol": "workspace:^", - "@deepseek-ai/dsh-user-approval": "workspace:^", - "@deepseek-ai/dsh-user-questions": "workspace:^", - "@deepseek-ai/dsh-client-connection": "workspace:^", - "@deepseek-ai/dsh-client-file-upload": "workspace:^", - "@deepseek-ai/dsh-typert-protocol": "workspace:^", - "@deepseek-ai/dsh-scope": "workspace:^", - "@deepseek-ai/dsh-api-session-controller": "workspace:^", - "@deepseek-ai/dsh-api-workspace-files": "workspace:^", + "@deepseek-ai/cordis": "workspace:~", + "@deepseek-ai/dsh-agent": "workspace:*", + "@deepseek-ai/dsh-agent-preset-registry": "workspace:*", + "@deepseek-ai/dsh-api-gateway": "workspace:*", + "@deepseek-ai/dsh-api-job-controller": "workspace:*", + "@deepseek-ai/dsh-api-session-controller": "workspace:*", + "@deepseek-ai/dsh-api-settings-controller": "workspace:*", + "@deepseek-ai/dsh-api-workspace-controller": "workspace:*", + "@deepseek-ai/dsh-client-connection": "workspace:*", + "@deepseek-ai/dsh-client-file-upload": "workspace:*", + "@deepseek-ai/dsh-commands": "workspace:*", + "@deepseek-ai/dsh-cordis-host-runner": "workspace:*", + "@deepseek-ai/dsh-credentials": "workspace:*", + "@deepseek-ai/dsh-file-reference": "workspace:*", + "@deepseek-ai/dsh-goal": "workspace:*", + "@deepseek-ai/dsh-host-plugin-inventory": "workspace:*", + "@deepseek-ai/dsh-llm": "workspace:*", + "@deepseek-ai/dsh-message-feedback": "workspace:*", + "@deepseek-ai/dsh-scope": "workspace:*", + "@deepseek-ai/dsh-permission-presets": "workspace:*", + "@deepseek-ai/dsh-session-reference": "workspace:*", + "@deepseek-ai/dsh-settings": "workspace:*", + "@deepseek-ai/dsh-subagent": "workspace:*", + "@deepseek-ai/dsh-typert-protocol": "workspace:*", + "@deepseek-ai/dsh-user-approval": "workspace:*", + "@deepseek-ai/dsh-user-questions": "workspace:*", + "@deepseek-ai/dsh-client-connection": "workspace:*", + "@deepseek-ai/dsh-client-file-upload": "workspace:*", + "@deepseek-ai/dsh-typert-protocol": "workspace:*", + "@deepseek-ai/dsh-scope": "workspace:*", + "@deepseek-ai/dsh-api-session-controller": "workspace:*", + "@deepseek-ai/dsh-api-workspace-files": "workspace:*", "zod": "^4.4.3", - "@deepseek-ai/dsh-command-feedback": "workspace:^", - "@deepseek-ai/dsh-api-terminal-controller": "workspace:^", - "@deepseek-ai/dsh-plugin-manager": "workspace:^", - "@deepseek-ai/dsh-client-ui-plugin-manager": "workspace:^", - "@deepseek-ai/dsh-office-to-pdf": "workspace:^", - "@deepseek-ai/dsh-api-account-controller": "workspace:^" + "@deepseek-ai/dsh-command-feedback": "workspace:*", + "@deepseek-ai/dsh-api-terminal-controller": "workspace:*", + "@deepseek-ai/dsh-plugin-manager": "workspace:*", + "@deepseek-ai/dsh-client-ui-plugin-manager": "workspace:*", + "@deepseek-ai/dsh-office-to-pdf": "workspace:*", + "@deepseek-ai/dsh-api-account-controller": "workspace:*" } } diff --git a/packages/api/session-controller/README.i18n.yaml b/packages/api/session-controller/README.i18n.yaml index a6a1d20012..c283462c19 100644 --- a/packages/api/session-controller/README.i18n.yaml +++ b/packages/api/session-controller/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/session-controller/README.md -README.md: e12ad7623eefc73c94e99cc3078e0ae78d6a969a -README.zh.md: 5f4ceec9a8e8303cad6651dbbe7af27fc07bccec +README.md: fa2a251e94cfa2855790d7d71457ac4bdf2ff752 +README.zh.md: d1a73ed5843fddadfc2f401fee1a86eece4e5272 diff --git a/packages/api/session-controller/README.md b/packages/api/session-controller/README.md index e12ad7623e..fa2a251e94 100644 --- a/packages/api/session-controller/README.md +++ b/packages/api/session-controller/README.md @@ -43,12 +43,12 @@ Projection reads retain loading and failure state independently of their values. Accepted prompts and observed running keep their Client display conversion across list refresh, reconnect, and Session-object replacement. The Manager retains these observations by Session id; they do not establish durable history. Drafts, projection stores, and sidebar display rules retain their existing behavior. The [blank rollback decision](../../../.agents/notes/implemented/bug-fix/2026-09-15-client-session-blank-reconciliation.md) defines retention and late-response handling. -The Client adapter exposes `SessionEventStream`, a Gateway `RemoteJournalStream` bound to one ordinary or direct-subagent address. It opens follow before the initial page, publishes only contiguous `replace`, `prepend`, `append`, and `settle-assistant` changes, and repairs reconnect or sequence gaps through a tail page. Backwards paging has two verbs: `loadOlder()` pulls one 50-message page, and `loadThrough(seq)` — the turn-jump loader — loops 200-message pages until the window covers the target seq, lowering a shared target on repeated calls, stopping on a page that makes no progress, and reporting busy through the same `loadingOlder` snapshot bit. The Web adapter explicitly opts into cursorless Assistant frames: each opening carries the active attempt's `startedAfterSeq`, `nextIndex`, and compact stream, and every stream member becomes a Client-only `assistant/live-chunk` entry ordered between durable cursors. The Host captures a follower-local arrival ordinal with that baseline and suppresses buffered frames at or before the cut; a replacement Agent may restart frame revision at one. A durable `assistant/message` or `assistant/attempt` arriving after an active opening stays staged only when its seq follows `startedAfterSeq` and its Turn and Step match; the matching end type, seq, and index publishes one named settlement delta that retires the attempt's transient rows and adds the durable entry while earlier same-step retries remain visible. Revision, dense-index, or settlement gaps for a known attempt reopen follow, while a controller that missed the start ignores unknown-attempt frames and publishes their durable settlement normally. An abandoned end publishes a settlement delta without a durable entry so its transient rows retire immediately. A durable gap-repair page has no Assistant baseline, so its held notification reopens follow once for a paired page and baseline. Every history record covers exactly its event seq. A business, persistence, or unresolved continuity failure terminates the stream, while only physical carrier loss selects automatic resumption. `SessionControlStream` is a Gateway `RemoteSnapshotStream`; every generation opens with a complete process-local baseline, so reconnect replaces projection state instead of treating transient values as durable events. At each ready Host generation, a synchronous Client subscription clears retained projection values and watermarks before refreshing queries and restarting the control stream, including Sessions absent from the control baseline. The first control stream waits for generation readiness, so its opening values cannot precede invalidation. Outstanding list responses from the previous generation cannot republish those values. Within a generation, a delayed control baseline cannot overwrite or clear newer sequenced values, whether they arrived through a live Session's list block, a history page, or a frame; a cached list block, viewed from the projection cache for a cold Session, yields to the connected Session's baseline whatever watermark it carries. The durable `inbox` projection carries both pending lists through the same cold-read and reconnect path as other projections. Client Agent contexts provide the identity used by the independent [`fileUpload`](../../client/file-upload/README.md) service; Session objects expose lifecycle, prompt, queue, and history operations rather than file transfer. +The Client adapter exposes `SessionEventStream`, a Gateway `RemoteJournalStream` bound to one ordinary or direct-subagent address. It opens follow before the initial page, publishes only contiguous `replace`, `prepend`, `append`, and `settle-assistant` changes, and repairs reconnect or sequence gaps through a tail page. Backwards paging has two verbs: `loadOlder()` pulls one Turn-aligned page, and `loadThrough(seq)` — the turn-jump loader — loops Turn-aligned pages until the window covers the target seq, lowering a shared target on repeated calls, stopping on a page that makes no progress, and reporting busy through the same `loadingOlder` snapshot bit. The Web adapter explicitly opts into cursorless Assistant frames: each opening carries the active attempt's `startedAfterSeq`, `nextIndex`, and compact stream, and every stream member becomes a Client-only `assistant/live-chunk` entry ordered between durable cursors. The Host captures a follower-local arrival ordinal with that baseline and suppresses buffered frames at or before the cut; a replacement Agent may restart frame revision at one. A durable `assistant/message` or `assistant/attempt` arriving after an active opening stays staged only when its seq follows `startedAfterSeq` and its Turn and Step match; the matching end type, seq, and index publishes one named settlement delta that retires the attempt's transient rows and adds the durable entry while earlier same-step retries remain visible. Revision, dense-index, or settlement gaps for a known attempt reopen follow, while a controller that missed the start ignores unknown-attempt frames and publishes their durable settlement normally. An abandoned end publishes a settlement delta without a durable entry so its transient rows retire immediately. A durable gap-repair page has no Assistant baseline, so its held notification reopens follow once for a paired page and baseline. Every history record covers exactly its event seq. A business, persistence, or unresolved continuity failure terminates the stream, while only physical carrier loss selects automatic resumption. `SessionControlStream` is a Gateway `RemoteSnapshotStream`; every generation opens with a complete process-local baseline, so reconnect replaces projection state instead of treating transient values as durable events. At each ready Host generation, a synchronous Client subscription clears retained projection values and watermarks before refreshing queries and restarting the control stream, including Sessions absent from the control baseline. The first control stream waits for generation readiness, so its opening values cannot precede invalidation. Outstanding list responses from the previous generation cannot republish those values. Within a generation, a delayed control baseline cannot overwrite or clear newer sequenced values, whether they arrived through a live Session's list block, a history page, or a frame; a cached list block, viewed from the projection cache for a cold Session, yields to the connected Session's baseline whatever watermark it carries. The durable `inbox` projection carries both pending lists through the same cold-read and reconnect path as other projections. Client Agent contexts provide the identity used by the independent [`fileUpload`](../../client/file-upload/README.md) service; Session objects expose lifecycle, prompt, queue, and history operations rather than file transfer. -The Session object also carries local submission echoes: `session.beginSubmission` inserts one into `SessionSnapshot.pendingSubmissions` synchronously, before the caller serializes and prompts, so a conversation UI can show the message on the submit click's own frame. The echo stores ordered image previews and durable file references. Session derives its `transcript`, `queued`, or `steering` placement from the current running state and requested delivery mode, then retains that placement while serialization is in flight. The prompt's `requestId` is the correlation identity: the Host echoes it as the durable user source's `rpcId`, including pending messages in the `inbox` projection. An echo retires one animation frame after its durable event or queue occurrence is observed, immediately when its identified prompt fails or is abandoned, and as failed on disposal. Each retirement fires `onRetire` exactly once; an observed retirement includes the ordered durable attachment references so the composer can release successful cards while preserving failed drafts. Echoes are Client memory only; reload and reconnect rebuild the conversation from durable events alone. +The Session object also carries local submission echoes: `session.beginSubmission` inserts one into `SessionSnapshot.pendingSubmissions` synchronously, before the caller serializes and prompts, so a conversation UI can show the message on the submit click's own frame. The echo stores ordered image previews and durable file references. Session derives its `transcript`, `queued`, or `steering` placement from the current running state and requested delivery mode, then retains that placement until the display handoff. The prompt's `requestId` is the correlation identity: the Host echoes it as the durable user source's `rpcId`, including pending messages in the `inbox` projection. Queued echoes retire one animation frame after queue acceptance; Chat echoes follow the admission rule below. Identified failures and abandonment retire unadmitted echoes immediately. Disposal preserves observed admission and fails other unsettled submissions. Each retirement fires `onRetire` exactly once; an observed retirement includes the ordered durable attachment references so the composer can release successful cards while preserving failed drafts. Echoes are Client memory only; reload and reconnect rebuild the conversation from durable events alone. -During uninterrupted following, steering echoes stay mounted through Inbox acceptance and claim until their durable messages arrive. A replacement follow baseline withdraws earlier receipt-confirmed steering echoes as observed, using their accepted attachment references; unconfirmed submissions remain pending. Host data then supplies the pending or admitted rows. Reconnecting between claim and admission can briefly omit that bubble; withdrawal confirms acceptance, not execution or failure. +During uninterrupted following, transcript and steering echoes stay in Chat through Inbox acceptance and claim until their durable messages arrive. After a local Chat message is admitted, its identity remains until the Inbox watermark reaches its next-turn or next-step claim seq: Chat already renders the durable Node, while Chat and QueueDock exclude matching stale Inbox rows. Other queued messages remain visible. A replacement follow baseline withdraws receipt-confirmed echoes as observed, using their accepted attachment references; unconfirmed submissions remain pending. Host data then supplies the pending or admitted rows. Reconnecting between claim and admission can briefly omit that bubble; withdrawal confirms acceptance, not execution or failure. The user-invocable `skills/list` metadata includes the winning provider’s optional instruction-file `path`. The composer can preview that file without loading every skill body or activating a cold Agent. @@ -56,7 +56,9 @@ Fork copies the exact inclusive event prefix selected by `atSeq`, including a cu A resume blocked by an existing write handle returns `session/writer-held` with the Session id; other resume failures retain `gateway/internal`. -`loadThrough(seq)` retains older pages privately until its shared target is covered or loading ends, then publishes the successful pages as one ordered prepend. Live events remain visible while history loads. A later page failure retains the successful prefix; replacing the history window discards buffered pages from the replaced window. Ordinary `loadOlder()` still publishes its single page directly. +`loadThrough(seq)` retains older pages privately until its shared target is covered or loading ends, then publishes the successful pages as one ordered prepend. Live events remain visible while history loads. A later page failure retains the successful prefix; replacing the history window discards buffered pages from the replaced window. Ordinary `loadOlder()` publishes one Host-selected page directly. + +The Client's initial `follow`, reconnect openings, and `loadOlder()` request at least 50 append-origin `user/message` and `assistant/message` events and cross at least two `turn/start` events, including the partial Turn at the loaded window's beginning. The Host stops at the first Turn start satisfying both minima, or at 500 counted messages or history exhaustion, whichever comes first. Steering does not add a Turn boundary. Intervening events accompany the page without counting toward the message budget. The optional `turnWindow` on page and follow requests supplies these minima under `maxMessages`. `loadThrough()` uses the same rules with a 200-message minimum per page; the 500-message cap does not limit the whole jump. Requests without `turnWindow` retain message-aligned paging. Queue edits replace pending content with non-empty text only. diff --git a/packages/api/session-controller/README.zh.md b/packages/api/session-controller/README.zh.md index 5f4ceec9a8..d1a73ed584 100644 --- a/packages/api/session-controller/README.zh.md +++ b/packages/api/session-controller/README.zh.md @@ -43,12 +43,12 @@ Client 列表刷新保留未变化的行对象,并在顺序和值均相同时 已受理的提示词与已观察到的运行,会使客户端展示转换跨列表刷新、重连及 Session 对象替换保留。Manager 按 Session id 保留这些观察;它们不证明持久历史已经产生。草稿、投影存储和侧边栏展示规则保留原有行为。[blank 回退修复决策](../../../.agents/notes/implemented/bug-fix/2026-09-15-client-session-blank-reconciliation.zh.md) 定义保留期限及迟到响应处理。 -Client 适配器提供 `SessionEventStream`,即绑定到一个普通 Session 或 direct subagent address 的 Gateway `RemoteJournalStream`。它在读取首个 page 前打开 follow,只发布连续的 `replace`、`prepend`、`append` 与 `settle-assistant` 变更,并通过 tail page 修复重连或 seq 缺口。向后分页有两个动词:`loadOlder()` 拉一页 50 条消息,而 `loadThrough(seq)`——轮次跳转加载器——按每页 200 条消息循环拉取直到窗口覆盖目标 seq,重复调用会下调共享目标,遇到无进展的页即停止,忙碌状态复用同一个 `loadingOlder` 快照位。Web 适配器显式选择接收无 cursor 的 Assistant frame:每个 opening 携带活跃 attempt 的 `startedAfterSeq`、`nextIndex` 与紧凑 stream,每个 stream member 都成为排在持久 cursor 之间的 Client-only `assistant/live-chunk` 条目。Host 会随该 baseline 捕获 follower 本地到达序号,并抑制该 cut 及之前的 buffered frame;replacement Agent 可以从 revision 一重新开始。活跃 opening 之后到达的持久 `assistant/message` 或 `assistant/attempt` 只有在其 seq 晚于 `startedAfterSeq` 且轮次与步骤匹配时才会保持暂存;匹配的 end type、seq 与 index 会发布一个具名 settlement delta,删除该 attempt 的瞬态 row、加入持久条目,并保留同一步骤中更早的 retry。已知 attempt 的 revision、密集 index 或 settlement 缺口会重新打开 follow;若 controller 错过 start,则忽略 unknown-attempt frame,并正常发布其持久 settlement。Abandoned end 会发布不含持久条目的 settlement delta,使瞬态 row 立即退出。持久缺口修复 page 不携带 Assistant baseline,因此 held notification 会重新打开 follow 一次,以取得配对的 page 与 baseline。每条历史 record 只覆盖自身的事件 seq。业务、persistence 或无法恢复的连续性错误会终止 stream,只有物理载体断开才触发自动恢复。`SessionControlStream` 是 Gateway `RemoteSnapshotStream`;每代都以完整的进程本地 baseline 开始,因此重连会替换 projection 状态,而不会把瞬态值当作 durable event。每次 Host generation 就绪时,同步的 Client 订阅会先清除保留的投影值及其水位,再刷新查询并重新打开 control stream,其中也包括 control baseline 中没有列出的 Session。首次 control stream 会等待 generation 就绪,确保其 opening 值不会先于旧状态清理到达。上一代尚未完成的 list 响应无法重新发布这些值。同一 generation 内,延迟到达的 control baseline 不能覆盖或清除较新的 sequenced 值,无论它们来自活 Session 的 list block、history page 还是 frame;冷 Session 从 projection cache 看出来的 cached list block 则不论水位都让位于建连 Session 的 baseline。持久 `inbox` 投影通过与其他投影相同的冷读取和重连路径传输两份待处理列表。Client Agent 上下文提供独立 [`fileUpload`](../../client/file-upload/README.zh.md) 服务使用的身份;Session 对象提供生命周期、prompt、queue 与历史操作,不提供文件传输。 +Client 适配器提供 `SessionEventStream`,即绑定到一个普通 Session 或 direct subagent address 的 Gateway `RemoteJournalStream`。它在读取首个 page 前打开 follow,只发布连续的 `replace`、`prepend`、`append` 与 `settle-assistant` 变更,并通过 tail page 修复重连或 seq 缺口。向后分页有两个动词:`loadOlder()` 拉一页按轮次开头对齐的历史,而 `loadThrough(seq)`——轮次跳转加载器——按轮次开头对齐页面循环拉取直到窗口覆盖目标 seq,重复调用会下调共享目标,遇到无进展的页即停止,忙碌状态复用同一个 `loadingOlder` 快照位。Web 适配器显式选择接收无 cursor 的 Assistant frame:每个 opening 携带活跃 attempt 的 `startedAfterSeq`、`nextIndex` 与紧凑 stream,每个 stream member 都成为排在持久 cursor 之间的 Client-only `assistant/live-chunk` 条目。Host 会随该 baseline 捕获 follower 本地到达序号,并抑制该 cut 及之前的 buffered frame;replacement Agent 可以从 revision 一重新开始。活跃 opening 之后到达的持久 `assistant/message` 或 `assistant/attempt` 只有在其 seq 晚于 `startedAfterSeq` 且轮次与步骤匹配时才会保持暂存;匹配的 end type、seq 与 index 会发布一个具名 settlement delta,删除该 attempt 的瞬态 row、加入持久条目,并保留同一步骤中更早的 retry。已知 attempt 的 revision、密集 index 或 settlement 缺口会重新打开 follow;若 controller 错过 start,则忽略 unknown-attempt frame,并正常发布其持久 settlement。Abandoned end 会发布不含持久条目的 settlement delta,使瞬态 row 立即退出。持久缺口修复 page 不携带 Assistant baseline,因此 held notification 会重新打开 follow 一次,以取得配对的 page 与 baseline。每条历史 record 只覆盖自身的事件 seq。业务、persistence 或无法恢复的连续性错误会终止 stream,只有物理载体断开才触发自动恢复。`SessionControlStream` 是 Gateway `RemoteSnapshotStream`;每代都以完整的进程本地 baseline 开始,因此重连会替换 projection 状态,而不会把瞬态值当作 durable event。每次 Host generation 就绪时,同步的 Client 订阅会先清除保留的投影值及其水位,再刷新查询并重新打开 control stream,其中也包括 control baseline 中没有列出的 Session。首次 control stream 会等待 generation 就绪,确保其 opening 值不会先于旧状态清理到达。上一代尚未完成的 list 响应无法重新发布这些值。同一 generation 内,延迟到达的 control baseline 不能覆盖或清除较新的 sequenced 值,无论它们来自活 Session 的 list block、history page 还是 frame;冷 Session 从 projection cache 看出来的 cached list block 则不论水位都让位于建连 Session 的 baseline。持久 `inbox` 投影通过与其他投影相同的冷读取和重连路径传输两份待处理列表。Client Agent 上下文提供独立 [`fileUpload`](../../client/file-upload/README.zh.md) 服务使用的身份;Session 对象提供生命周期、prompt、queue 与历史操作,不提供文件传输。 -Session 对象还承载本地提交回显:`session.beginSubmission` 在调用方序列化与提示词之前,同步把一条回显写入 `SessionSnapshot.pendingSubmissions`,会话 UI 因此能在点击提交的当帧显示消息。回显按顺序存放图片预览与持久文件引用。Session 根据当前运行状态与请求的投递模式推导其 `transcript`、`queued` 或 `steering` 位置,并在序列化期间保留该位置。提示词的 `requestId` 是关联标识:Host 把它回显为 durable user source 的 `rpcId`,`inbox` 投影中的待处理消息也保留同一 source。回显在观察到其 durable event 或 queue occurrence 后延迟一个动画帧退休,带标识的提示词失败或被放弃时立即退休,销毁时按 failed 退休。每次退休恰好触发一次 `onRetire`;observed 退休还会携带有序的持久附件引用,让 composer 释放成功卡片并保留失败草稿。回显只存在于 Client 内存;刷新与重连只从持久事件重建会话。 +Session 对象还承载本地提交回显:`session.beginSubmission` 在调用方序列化与提示词之前,同步把一条回显写入 `SessionSnapshot.pendingSubmissions`,会话 UI 因此能在点击提交的当帧显示消息。回显按顺序存放图片预览与持久文件引用。Session 根据当前运行状态与请求的投递模式推导其 `transcript`、`queued` 或 `steering` 位置,并保留该位置直到展示接管。提示词的 `requestId` 是关联标识:Host 把它回显为 durable user source 的 `rpcId`,`inbox` 投影中的待处理消息也保留同一 source。排队回显在队列接受后延迟一个动画帧退休;Chat 回显遵循下述入档规则。尚未入档的回显在带标识的提示词失败或被放弃时立即退休。销毁时保留已观察到的入档结果,其余未结算提交按 failed 退休。每次退休恰好触发一次 `onRetire`;observed 退休还会携带有序的持久附件引用,让 composer 释放成功卡片并保留失败草稿。回显只存在于 Client 内存;刷新与重连只从持久事件重建会话。 -正常在线时,steering 回显在 Inbox 接受与领取期间保持挂载,直到持久消息到达。新的 follow 基线会将此前已有接收回执的 steering 回显按 observed 撤去,使用已接受的附件引用完成结算;尚未确认接收的提交继续保留。待处理或已入档的消息随后由 Host 数据呈现。在领取与入档之间重连时,气泡可能短暂消失;撤去回显只确认接收,不代表执行或失败。 +正常在线时,transcript 与 steering 回显在 Inbox 接受与领取期间留在 Chat,直到持久消息到达。本地 Chat 消息入档后,其身份保留到 Inbox 水位覆盖 next-turn 或 next-step 领取序号:Chat 已显示真实 Node,而 Chat 与 QueueDock 排除匹配的旧 Inbox 行。其他排队消息照常显示。新的 follow 基线会将已有接收回执的回显按 observed 撤去,使用已接受的附件引用完成结算;尚未确认接收的提交继续保留。待处理或已入档的消息随后由 Host 数据呈现。在领取与入档之间重连时,气泡可能短暂消失;撤去回显只确认接收,不代表执行或失败。 面向用户调用的 `skills/list` 元数据包含胜出提供方可选的指令文件 `path`。输入框可据此预览文件,无需加载每个 skill 的正文或激活冷态 Agent。 @@ -56,7 +56,9 @@ Fork 复制 `atSeq` 所选的精确事件前缀,包含切点事件,允许在 恢复会话时若已有写句柄占用,返回 `session/writer-held`,并携带会话 id;其他恢复失败仍返回 `gateway/internal`。 -`loadThrough(seq)` 在共享目标被覆盖或加载结束前私下保留较早页面,随后把成功取得的页面按顺序作为一次前插发布。历史加载期间实时事件仍然可见。后续页面失败时保留已成功取得的部分;历史窗口被替换时丢弃被替换窗口的暂存页面。普通 `loadOlder()` 仍直接发布其单页结果。 +`loadThrough(seq)` 在共享目标被覆盖或加载结束前私下保留较早页面,随后把成功取得的页面按顺序作为一次前插发布。历史加载期间实时事件仍然可见。后续页面失败时保留已成功取得的部分;历史窗口被替换时丢弃被替换窗口的暂存页面。普通 `loadOlder()` 直接发布 Host 选取的一页结果。 + +Client 的首次 `follow`、重连首屏与 `loadOlder()` 至少请求 50 条以 append 方式追加的 `user/message` 和 `assistant/message` 事件,并向前跨过至少两个 `turn/start`,其中包含已加载窗口开头尚未补齐的轮次。Host 在首次同时满足两个下限的轮次开头停止;若先达到 500 条计数消息或历史已耗尽,则立即返回。Steering 不增加轮次分界。区间内的其他事件随页返回,但不计入消息预算。分页和 follow 请求可选的 `turnWindow` 在 `maxMessages` 上限内指定这两个下限。`loadThrough()` 复用相同规则,仅将每页消息数下限设为 200;500 条上限不限制整次跳转。不带 `turnWindow` 的请求保留按消息对齐的分页方式。 队列编辑仅允许用非空文本替换待处理内容。 diff --git a/packages/api/session-controller/package.json b/packages/api/session-controller/package.json index f1525e8bf3..08704ed09d 100644 --- a/packages/api/session-controller/package.json +++ b/packages/api/session-controller/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-api-session-controller", "description": "Session Remote commands, cold reads, and live control transport", - "version": "0.1.7-alpha.1", + "version": "0.1.7-alpha.2", "publishConfig": { "access": "public" }, @@ -69,41 +69,41 @@ ], "license": "MIT", "dependencies": { - "@deepseek-ai/dsh-brand": "workspace:^", - "@deepseek-ai/dsh-deque": "workspace:^", - "@deepseek-ai/schemastery": "workspace:^", + "@deepseek-ai/dsh-brand": "workspace:*", + "@deepseek-ai/dsh-deque": "workspace:*", + "@deepseek-ai/schemastery": "workspace:~", "mime-types": "^3.0.2", "zod": "^4.4.3" }, "peerDependencies": { - "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-agent": "workspace:^", - "@deepseek-ai/dsh-agent-default-model": "workspace:^", - "@deepseek-ai/dsh-agent-preset-registry": "workspace:^", - "@deepseek-ai/dsh-api-gateway": "workspace:^", - "@deepseek-ai/dsh-attachment": "workspace:^", - "@deepseek-ai/dsh-client-connection": "workspace:^", - "@deepseek-ai/dsh-client-file-upload": "workspace:^", - "@deepseek-ai/dsh-commands": "workspace:^", - "@deepseek-ai/dsh-file-reference": "workspace:^", - "@deepseek-ai/dsh-fs": "workspace:^", - "@deepseek-ai/dsh-llm": "workspace:^", - "@deepseek-ai/dsh-native-command": "workspace:^", - "@deepseek-ai/dsh-scope": "workspace:^", - "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/dsh-session-persistence": "workspace:^", - "@deepseek-ai/dsh-session-projection": "workspace:^", - "@deepseek-ai/dsh-session-projection-cache": "workspace:^", - "@deepseek-ai/dsh-session-query": "workspace:^", - "@deepseek-ai/dsh-session-title": "workspace:^", - "@deepseek-ai/dsh-skill": "workspace:^", - "@deepseek-ai/dsh-subagent": "workspace:^", - "@deepseek-ai/dsh-typert-protocol": "workspace:^", - "@deepseek-ai/dsh-typert-registry": "workspace:^", - "@deepseek-ai/dsh-util-time": "workspace:^", - "@deepseek-ai/dsh-util-values": "workspace:^", - "@deepseek-ai/dsh-util-workspace-path": "workspace:^", - "@deepseek-ai/dsh-workspace": "workspace:^" + "@deepseek-ai/cordis": "workspace:~", + "@deepseek-ai/dsh-agent": "workspace:*", + "@deepseek-ai/dsh-agent-default-model": "workspace:*", + "@deepseek-ai/dsh-agent-preset-registry": "workspace:*", + "@deepseek-ai/dsh-api-gateway": "workspace:*", + "@deepseek-ai/dsh-attachment": "workspace:*", + "@deepseek-ai/dsh-client-connection": "workspace:*", + "@deepseek-ai/dsh-client-file-upload": "workspace:*", + "@deepseek-ai/dsh-commands": "workspace:*", + "@deepseek-ai/dsh-file-reference": "workspace:*", + "@deepseek-ai/dsh-fs": "workspace:*", + "@deepseek-ai/dsh-llm": "workspace:*", + "@deepseek-ai/dsh-native-command": "workspace:*", + "@deepseek-ai/dsh-scope": "workspace:*", + "@deepseek-ai/dsh-session": "workspace:*", + "@deepseek-ai/dsh-session-persistence": "workspace:*", + "@deepseek-ai/dsh-session-projection": "workspace:*", + "@deepseek-ai/dsh-session-projection-cache": "workspace:*", + "@deepseek-ai/dsh-session-query": "workspace:*", + "@deepseek-ai/dsh-session-title": "workspace:*", + "@deepseek-ai/dsh-skill": "workspace:*", + "@deepseek-ai/dsh-subagent": "workspace:*", + "@deepseek-ai/dsh-typert-protocol": "workspace:*", + "@deepseek-ai/dsh-typert-registry": "workspace:*", + "@deepseek-ai/dsh-util-time": "workspace:*", + "@deepseek-ai/dsh-util-values": "workspace:*", + "@deepseek-ai/dsh-util-workspace-path": "workspace:*", + "@deepseek-ai/dsh-workspace": "workspace:*" }, "peerDependenciesMeta": { "@deepseek-ai/dsh-session-persistence": { @@ -114,45 +114,45 @@ } }, "devDependencies": { - "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-agent": "workspace:^", - "@deepseek-ai/dsh-agent-default-model": "workspace:^", - "@deepseek-ai/dsh-agent-loop": "workspace:^", - "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^", - "@deepseek-ai/dsh-agent-preset-registry": "workspace:^", - "@deepseek-ai/dsh-api-gateway": "workspace:^", - "@deepseek-ai/dsh-attachment": "workspace:^", - "@deepseek-ai/dsh-client-connection": "workspace:^", - "@deepseek-ai/dsh-client-file-upload": "workspace:^", - "@deepseek-ai/dsh-client-store": "workspace:^", - "@deepseek-ai/dsh-client-test-runtime": "workspace:^", - "@deepseek-ai/dsh-commands": "workspace:^", - "@deepseek-ai/dsh-file-reference": "workspace:^", - "@deepseek-ai/dsh-fs": "workspace:^", - "@deepseek-ai/dsh-fs-local": "workspace:^", - "@deepseek-ai/dsh-llm": "workspace:^", - "@deepseek-ai/dsh-native-command": "workspace:^", - "@deepseek-ai/dsh-permission-presets": "workspace:^", - "@deepseek-ai/dsh-remote-mock": "workspace:^", - "@deepseek-ai/dsh-scope": "workspace:^", - "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/dsh-session-persistence": "workspace:^", - "@deepseek-ai/dsh-session-projection": "workspace:^", - "@deepseek-ai/dsh-session-projection-cache": "workspace:^", - "@deepseek-ai/dsh-session-query": "workspace:^", - "@deepseek-ai/dsh-session-title": "workspace:^", - "@deepseek-ai/dsh-skill": "workspace:^", - "@deepseek-ai/dsh-storage": "workspace:^", - "@deepseek-ai/dsh-storage-domain": "workspace:^", - "@deepseek-ai/dsh-storage-json": "workspace:^", - "@deepseek-ai/dsh-subagent": "workspace:^", - "@deepseek-ai/dsh-typert-protocol": "workspace:^", - "@deepseek-ai/dsh-typert-registry": "workspace:^", - "@deepseek-ai/dsh-util-crypto": "workspace:^", - "@deepseek-ai/dsh-util-time": "workspace:^", - "@deepseek-ai/dsh-util-values": "workspace:^", - "@deepseek-ai/dsh-util-workspace-path": "workspace:^", - "@deepseek-ai/dsh-workspace": "workspace:^", + "@deepseek-ai/cordis": "workspace:~", + "@deepseek-ai/dsh-agent": "workspace:*", + "@deepseek-ai/dsh-agent-default-model": "workspace:*", + "@deepseek-ai/dsh-agent-loop": "workspace:*", + "@deepseek-ai/dsh-agent-loop-testkit": "workspace:*", + "@deepseek-ai/dsh-agent-preset-registry": "workspace:*", + "@deepseek-ai/dsh-api-gateway": "workspace:*", + "@deepseek-ai/dsh-attachment": "workspace:*", + "@deepseek-ai/dsh-client-connection": "workspace:*", + "@deepseek-ai/dsh-client-file-upload": "workspace:*", + "@deepseek-ai/dsh-client-store": "workspace:*", + "@deepseek-ai/dsh-client-test-runtime": "workspace:*", + "@deepseek-ai/dsh-commands": "workspace:*", + "@deepseek-ai/dsh-file-reference": "workspace:*", + "@deepseek-ai/dsh-fs": "workspace:*", + "@deepseek-ai/dsh-fs-local": "workspace:*", + "@deepseek-ai/dsh-llm": "workspace:*", + "@deepseek-ai/dsh-native-command": "workspace:*", + "@deepseek-ai/dsh-permission-presets": "workspace:*", + "@deepseek-ai/dsh-remote-mock": "workspace:*", + "@deepseek-ai/dsh-scope": "workspace:*", + "@deepseek-ai/dsh-session": "workspace:*", + "@deepseek-ai/dsh-session-persistence": "workspace:*", + "@deepseek-ai/dsh-session-projection": "workspace:*", + "@deepseek-ai/dsh-session-projection-cache": "workspace:*", + "@deepseek-ai/dsh-session-query": "workspace:*", + "@deepseek-ai/dsh-session-title": "workspace:*", + "@deepseek-ai/dsh-skill": "workspace:*", + "@deepseek-ai/dsh-storage": "workspace:*", + "@deepseek-ai/dsh-storage-domain": "workspace:*", + "@deepseek-ai/dsh-storage-json": "workspace:*", + "@deepseek-ai/dsh-subagent": "workspace:*", + "@deepseek-ai/dsh-typert-protocol": "workspace:*", + "@deepseek-ai/dsh-typert-registry": "workspace:*", + "@deepseek-ai/dsh-util-crypto": "workspace:*", + "@deepseek-ai/dsh-util-time": "workspace:*", + "@deepseek-ai/dsh-util-values": "workspace:*", + "@deepseek-ai/dsh-util-workspace-path": "workspace:*", + "@deepseek-ai/dsh-workspace": "workspace:*", "@types/mime-types": "^3.0.1" } } diff --git a/packages/api/session-controller/src/client/contract/session.ts b/packages/api/session-controller/src/client/contract/session.ts index 246d2c8a92..2c8f16c4ae 100644 --- a/packages/api/session-controller/src/client/contract/session.ts +++ b/packages/api/session-controller/src/client/contract/session.ts @@ -68,9 +68,10 @@ export interface ISession { /** * Register one local submission echo in `snapshot.pendingSubmissions`, * synchronously, before the caller serializes and sends the prompt. The - * echo retires when a durable `user/message` event or queue occurrence - * carrying the returned identity arrives, or when the identified prompt - * call fails. + * Chat echoes persist until durable admission; transcript identities also + * wait for the Inbox claim watermark so stale queue rows stay suppressed. + * Queued echoes retire on queue acceptance. Identified failures retire + * submissions that have not already reached durable admission. * @param input - echo content and the optional settlement callback. * @returns the minted identity for {@link prompt} plus the pre-prompt abandon path. */ @@ -118,7 +119,9 @@ export interface ISession { */ rename(title: string): Promise> /** - * Extend the history window backwards (older messages pagination). + * Extend history by at least 50 messages and two Turn starts, including a + * partial Turn at the window's beginning. Stop at 500 messages or history + * exhaustion even when those minima cannot be met. Publish one prepend. * @returns completion; failures land in snapshot.openState/loadingOlder. */ loadOlder(): Promise @@ -127,6 +130,8 @@ export interface ISession { * turn-jump loader. Repeated calls while a jump is paging lower its shared * target and return the in-flight completion; `snapshot.loadingOlder` is * the busy signal for the whole jump. + * Each page uses the same Turn alignment as loadOlder, with a 200-message + * minimum. The 500-message limit applies per page, not to the whole jump. * Older pages publish together at completion, including successful pages * before a later failure; live events remain independently visible. * @param seq - durable event seq the window must reach (a turn's `turn/start` seq). diff --git a/packages/api/session-controller/src/client/contract/snapshot.ts b/packages/api/session-controller/src/client/contract/snapshot.ts index c9ce1be4f5..161cd858b4 100644 --- a/packages/api/session-controller/src/client/contract/snapshot.ts +++ b/packages/api/session-controller/src/client/contract/snapshot.ts @@ -46,7 +46,7 @@ export type PendingSubmissionPlacement = 'transcript' | 'queued' | 'steering' export interface PendingSubmission { /** The prompt RPC identity; the durable `user/message` source echoes it as `rpcId`. */ readonly requestId: SessionRequestId - /** Expected surface until the Host reports the admitted queue or durable occurrence. */ + /** Surface fixed at submission time; Inbox acceptance does not move a Chat echo into the dock. */ readonly placement: PendingSubmissionPlacement /** Client wall-clock ms when the submission began. */ readonly time: number diff --git a/packages/api/session-controller/src/client/sessions/projection-store.ts b/packages/api/session-controller/src/client/sessions/projection-store.ts index faafa4404f..ae36cea19c 100644 --- a/packages/api/session-controller/src/client/sessions/projection-store.ts +++ b/packages/api/session-controller/src/client/sessions/projection-store.ts @@ -117,6 +117,16 @@ export class ProjectionValueStore { return this.rows.get(key)?.value } + /** + * Read the accepted Host watermark without subscribing or copying a value. + * @param key - projection key. + * @returns the current sequence, or undefined for absent and cached values. + */ + seqOf(key: string): SessionSeqCursor | undefined { + const row = this.rows.get(key) + return row?.kind === 'sequenced' ? row.seq : undefined + } + /** * Read every current projection value as one reference-stable snapshot. * @returns The same frozen value map until a row changes. diff --git a/packages/api/session-controller/src/client/sessions/session.ts b/packages/api/session-controller/src/client/sessions/session.ts index c1d568e59c..7ad5c637be 100644 --- a/packages/api/session-controller/src/client/sessions/session.ts +++ b/packages/api/session-controller/src/client/sessions/session.ts @@ -1,7 +1,7 @@ // Sessions remain resident after creation so their open Remote sources keep running off-screen. import type { Context } from '@deepseek-ai/cordis' -import type { InboxState } from '@deepseek-ai/dsh-agent/types' +import type { InboxState, InboxTarget } from '@deepseek-ai/dsh-agent/types' import { randomUUID } from '@deepseek-ai/dsh-util-crypto' import type { AttachmentIdType, FileAttachmentRef, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import type { SubagentAddress } from '@deepseek-ai/dsh-subagent/client' @@ -48,12 +48,19 @@ function projectionsBaseline(value: SessionProjectionBaseline): ProjectionsBasel } } -/** Messages requested per history page. */ +/** Minimum message count for ordinary history windows. */ export const PAGE_MESSAGES = 50 -/** Messages requested per page while a turn jump loops backwards (fewer, larger round trips). */ +const HISTORY_PAGE_OPTIONS = { maxMessages: 500, turnWindow: { minMessages: PAGE_MESSAGES, minTurns: 2 } } + +/** Minimum messages per page while a turn jump loops backwards. */ export const JUMP_PAGE_MESSAGES = 200 +const JUMP_PAGE_OPTIONS = { + ...HISTORY_PAGE_OPTIONS, + turnWindow: { ...HISTORY_PAGE_OPTIONS.turnWindow, minMessages: JUMP_PAGE_MESSAGES }, +} + interface PendingHistory { beforeSeq: SessionLogOffset hasMore: boolean @@ -126,12 +133,15 @@ export class Session implements SessionFace { * Inbox projection and its durable event cannot both retire one echo. */ private readonly submissionSettlements = new Map void) | undefined retiring: boolean }>() @@ -392,7 +402,7 @@ export class Session implements SessionFace { return promise } - /** Page up: pull one earlier page with the window's first seq as beforeSeq and prepend. */ + /** Prepend one Turn-aligned page: at least 50 messages and two Turn starts, capped at 500 messages. */ async loadOlder(): Promise { if (this.openState !== 'open' || !this.hasMore || this.loadingOlder) return const events = this.events @@ -400,7 +410,10 @@ export class Session implements SessionFace { this.loadingOlder = true this.notifier.markDirty() try { - await events.prepend({ beforeSeq: this.baseSeq, maxMessages: PAGE_MESSAGES }) + await events.prepend({ + beforeSeq: this.baseSeq, + ...HISTORY_PAGE_OPTIONS, + }) } catch (error) { if (!isRemoteFailure(error)) { console.error('[session-controller] loadOlder failed:', error) @@ -444,7 +457,7 @@ export class Session implements SessionFace { while (pending.hasMore && this.jumpTargetSeq !== null && pending.beforeSeq > this.jumpTargetSeq) { if (generation !== this.openGeneration) return const before = pending.beforeSeq - await events.prepend({ beforeSeq: before, maxMessages: JUMP_PAGE_MESSAGES }) + await events.prepend({ beforeSeq: before, ...JUMP_PAGE_OPTIONS }) // No-progress guard: an empty or dropped page that still claims more // history must end the loop, not spin it. if (pending.beforeSeq >= before) return @@ -586,10 +599,10 @@ export class Session implements SessionFace { async dispose(): Promise { this.stopObservingInbox() // Unsettled echoes retire as failed so their owners can restore or - // release browser resources; echoes already scheduled as observed keep - // that settlement. - for (const requestId of [...this.submissionSettlements.keys()]) { - this.retireFailedSubmission(requestId) + // release browser resources; admitted echoes keep their observed outcome. + for (const [requestId, settlement] of [...this.submissionSettlements]) { + if (settlement.admitted !== undefined) this.scheduleObservedRetirement(requestId, settlement.admitted) + else this.retireFailedSubmission(requestId) } this.openGeneration++ const events = this.events @@ -615,7 +628,7 @@ export class Session implements SessionFace { }) this.events = events try { - await events.open({ maxMessages: PAGE_MESSAGES }) + await events.open(HISTORY_PAGE_OPTIONS) if (generation !== this.openGeneration || this.events !== events) return this.openState = 'open' } catch (error) { @@ -672,7 +685,7 @@ export class Session implements SessionFace { if (visible.some(entry => entry.event.type === 'turn/start')) this.firstPromptPendingTurn = false if (projections !== undefined) this.projections.seed(projections) this.eventSource.replace(visible, hasMore) - // A new follow baseline replaces optimistic steering with Host-owned rows. + // A new follow baseline replaces confirmed optimistic inputs with Host-owned rows. // Receipt-backed inputs are accepted, not failed, even if their history is outside this window. if (projections !== undefined) { for (const [requestId, { receipt }] of this.submissionSettlements) { @@ -684,7 +697,9 @@ export class Session implements SessionFace { for (const entry of visible) this.observeSubmissionEvent(entry.event) if (projections !== undefined) { const inbox = projections.values.inbox as InboxState | undefined - this.observeSteeringInsertions(inbox?.['next-step'] ?? [], 0, projections.asOfSeq) + for (const target of ['next-turn', 'next-step'] as const) { + this.observeSubmissionInsertions(target, inbox?.[target] ?? [], 0, projections.asOfSeq) + } } this.notifier.markDirty() } @@ -748,55 +763,76 @@ export class Session implements SessionFace { if (this.submissionSettlements.size === 0) return if (event.type === 'agent/inbox/spliced') { const { target, start, removedCount = 0, inserted, outcome } = event.data - if (target === 'next-step') { - for (const [requestId, settlement] of this.submissionSettlements) { - const receipt = settlement.receipt - if (receipt === undefined || receipt.index === null || receipt.seq >= event.seq) continue - const removed = receipt.index >= start && receipt.index < start + removedCount - if (removed && outcome === 'canceled') this.retireFailedSubmission(requestId) - else settlement.receipt = { - ...receipt, - seq: event.seq, - index: removed ? null : receipt.index < start ? receipt.index : receipt.index + inserted.length - removedCount, - } + for (const [requestId, settlement] of this.submissionSettlements) { + const receipt = settlement.receipt + if (receipt?.target !== target || receipt.index === null || receipt.seq >= event.seq) continue + const removed = receipt.index >= start && receipt.index < start + removedCount + if (removed && outcome === 'canceled') this.retireFailedSubmission(requestId) + else settlement.receipt = { + ...receipt, + seq: event.seq, + index: removed ? null : receipt.index < start ? receipt.index : receipt.index + inserted.length - removedCount, } - this.observeSteeringInsertions(inserted, start, event.seq) } + this.observeSubmissionInsertions(target, inserted, start, event.seq) for (const message of inserted) this.observeSubmissionMessage(message, false) return } if (event.type === 'request/context' || event.type === 'turn/end') { for (const [requestId, settlement] of this.submissionSettlements) { - if (settlement.receipt?.index === null && settlement.receipt.seq < event.seq) this.retireFailedSubmission(requestId) + if (settlement.admitted === undefined && settlement.receipt?.index === null + && settlement.receipt.seq < event.seq) this.retireFailedSubmission(requestId) } return } if (event.type === 'user/message') this.observeSubmissionMessage(event.data, true) } - private observeSteeringInsertions(messages: readonly UserMessage[], start: number, seq: number): void { + private observeSubmissionInsertions(target: InboxTarget, messages: readonly UserMessage[], start: number, seq: number): void { for (const [index, message] of messages.entries()) { const source = message.source if (source.kind !== 'user' || !('rpcId' in source)) continue const settlement = this.submissionSettlements.get(source.rpcId) - if (settlement?.placement !== 'steering' || settlement.retiring || (settlement.receipt?.seq ?? -1) > seq) continue - settlement.receipt = { seq, index: start + index, attachments: attachmentRefsIn(message.content) } + if (settlement === undefined || settlement.placement === 'queued' + || settlement.retiring || (settlement.receipt?.seq ?? -1) > seq) continue + settlement.receipt = { target, seq, index: start + index, attachments: attachmentRefsIn(message.content) } } } private observeSubmissionMessage(message: UserMessage, admitted: boolean): void { const source = message.source if (source.kind !== 'user' || !('rpcId' in source)) return - if (!admitted && this.submissionSettlements.get(source.rpcId)?.placement === 'steering') return - this.scheduleObservedRetirement(source.rpcId, attachmentRefsIn(message.content)) + const settlement = this.submissionSettlements.get(source.rpcId) + if (settlement === undefined || settlement.retiring) return + if (!admitted) { + if (settlement.placement === 'queued') this.scheduleObservedRetirement(source.rpcId, attachmentRefsIn(message.content)) + return + } + settlement.admitted = attachmentRefsIn(message.content) + this.retireAdmittedSubmission(source.rpcId) } - /** Retire non-steering echoes when the Inbox accepts their queue occurrences. */ + /** Retire admitted Chat identities only after stale Inbox rows can no longer reappear. */ + private retireAdmittedSubmission(requestId: SessionRequestId): void { + const settlement = this.submissionSettlements.get(requestId) + if (settlement?.admitted === undefined) return + const receipt = settlement.receipt + if (receipt?.index === null + && (this.projections.seqOf('inbox') ?? -1) < receipt.seq) return + this.scheduleObservedRetirement(requestId, settlement.admitted) + } + + /** Inbox acceptance retires queued echoes; its watermark completes admitted Chat handoffs. */ private observeSubmissionInbox(): void { if (this.submissionSettlements.size === 0) return const inbox = this.projections.get('inbox') as InboxState | undefined if (inbox === undefined) return - for (const message of [...inbox['next-turn'], ...inbox['next-step']]) this.observeSubmissionMessage(message, false) + const seq = this.projections.seqOf('inbox') + for (const target of ['next-turn', 'next-step'] as const) { + if (seq !== undefined) this.observeSubmissionInsertions(target, inbox[target], 0, seq) + for (const message of inbox[target]) this.observeSubmissionMessage(message, false) + } + for (const requestId of this.submissionSettlements.keys()) this.retireAdmittedSubmission(requestId) } /** @@ -818,7 +854,7 @@ export class Session implements SessionFace { /** Remove one unsettled echo immediately (prompt rejection, abort, or disposal). */ private retireFailedSubmission(requestId: SessionRequestId): void { const settlement = this.submissionSettlements.get(requestId) - if (settlement === undefined || settlement.retiring) return + if (settlement === undefined || settlement.retiring || settlement.admitted !== undefined) return settlement.retiring = true this.finishSubmission(requestId, { reason: 'failed' }) } diff --git a/packages/api/session-controller/src/client/transport.ts b/packages/api/session-controller/src/client/transport.ts index 7df6362fcf..fc2a11a0bb 100644 --- a/packages/api/session-controller/src/client/transport.ts +++ b/packages/api/session-controller/src/client/transport.ts @@ -179,7 +179,7 @@ export class SessionEventStream extends RemoteJournalStream< for await (const frame of this.remote.session.follow({ address: this.address, assistantStream: true, - ...(request.maxMessages === undefined ? {} : { maxMessages: request.maxMessages }), + ...this.repairRequest(request), }, signal)) { if (frame.type === 'snapshot') { for (const record of frame.records) assertSessionWireEvent(record.event) @@ -238,6 +238,9 @@ export class SessionEventStream extends RemoteJournalStream< protected override repairRequest( request: ClientSessionPageRequest, ): ClientSessionPageRequest { - return request.maxMessages === undefined ? {} : { maxMessages: request.maxMessages } + return { + ...(request.maxMessages === undefined ? {} : { maxMessages: request.maxMessages }), + ...(request.turnWindow === undefined ? {} : { turnWindow: request.turnWindow }), + } } } diff --git a/packages/api/session-controller/src/history.ts b/packages/api/session-controller/src/history.ts index 069ee3bf32..07fbbfe6be 100644 --- a/packages/api/session-controller/src/history.ts +++ b/packages/api/session-controller/src/history.ts @@ -102,6 +102,7 @@ export class SessionHistoryController { beforeSeq, request.maxMessages ?? DEFAULT_MAX_MESSAGES, throughSeq, + request.turnWindow, ) const records = pageRecords(page.events) return { @@ -117,7 +118,7 @@ export class SessionHistoryController { * @returns a complete opening snapshot followed by gap-free durable events and opted-in assistant frames. */ async *follow(request: SessionFollowRequest, signal: AbortSignal): AsyncIterable { - validateFollowRequest(request) + validateHistoryWindow(request) const { address } = request const target = addressId(address) const buffered = new Deque< @@ -180,7 +181,7 @@ export class SessionHistoryController { signal.throwIfAborted() const cursor = source.cursor snapshotCursor = cursor - const page = paginate(events, undefined, request.maxMessages ?? DEFAULT_MAX_MESSAGES) + const page = paginate(events, undefined, request.maxMessages ?? DEFAULT_MAX_MESSAGES, cursor, request.turnWindow) const assistantStream = request.assistantStream === true ? this.assistantStreams.get(target)?.snapshot() ?? { revision: 0 } : undefined @@ -313,16 +314,23 @@ function validatePageRequest(request: SessionPageRequest): void { || Object.is(request.beforeSeq, -0))) { throw new RemoteError('gateway/bad-request', 'beforeSeq must be a non-negative safe integer', {}) } + validateHistoryWindow(request) +} + +function validateHistoryWindow(request: Pick): void { if (request.maxMessages !== undefined && (!Number.isSafeInteger(request.maxMessages) || request.maxMessages <= 0)) { throw new RemoteError('gateway/bad-request', 'maxMessages must be a positive safe integer', {}) } -} - -function validateFollowRequest(request: SessionFollowRequest): void { - if (request.maxMessages !== undefined - && (!Number.isSafeInteger(request.maxMessages) || request.maxMessages <= 0)) { - throw new RemoteError('gateway/bad-request', 'maxMessages must be a positive safe integer', {}) + const window = request.turnWindow + if (window !== undefined) { + if (!Number.isSafeInteger(window.minMessages) || window.minMessages <= 0 + || window.minMessages > (request.maxMessages ?? DEFAULT_MAX_MESSAGES)) { + throw new RemoteError('gateway/bad-request', 'turnWindow.minMessages must be a positive safe integer no greater than maxMessages', {}) + } + if (!Number.isSafeInteger(window.minTurns) || window.minTurns <= 0) { + throw new RemoteError('gateway/bad-request', 'turnWindow.minTurns must be a positive safe integer', {}) + } } } @@ -385,13 +393,22 @@ function paginate( events: readonly SessionEvent[], beforeSeq: SessionLogOffsetType | undefined, maxMessages: number, - throughSeq: SessionSeqCursor = events.at(-1)?.seq ?? -1, + throughSeq: SessionSeqCursor, + turnWindow?: SessionPageRequest['turnWindow'], ): { readonly events: SessionEvent[]; readonly hasMore: boolean } { const end = SessionLogOffset(Math.min(throughSeq + 1, beforeSeq ?? throughSeq + 1)) let count = 0 + let turns = 0 let cut = SessionLogOffset(0) for (let index = end - 1; index >= 0; index--) { const event = events[index] as SessionEvent + if (turnWindow !== undefined && event.type === 'turn/start') { + turns++ + if (count >= turnWindow.minMessages && turns >= turnWindow.minTurns) { + cut = SessionLogOffset(index) + break + } + } if (!MESSAGE_TYPES.has(event.type) || !isAppendSurfaceEvent(event)) continue count++ const sources = event.sourceEventSeqs diff --git a/packages/api/session-controller/src/types.ts b/packages/api/session-controller/src/types.ts index 9e3da329ee..84378cc34d 100644 --- a/packages/api/session-controller/src/types.ts +++ b/packages/api/session-controller/src/types.ts @@ -472,12 +472,18 @@ export interface SessionPageRequest { readonly throughSeq: number readonly beforeSeq?: number readonly maxMessages?: number + /** Stop at a Turn start after both minima, unless maxMessages or history exhaustion wins. */ + readonly turnWindow?: { + /** Minimum append-origin user/assistant messages; must not exceed maxMessages. */ + readonly minMessages: number + /** Minimum Turn starts crossed, including the partial Turn at beforeSeq. */ + readonly minTurns: number + } } /** One live event request for a durable Session address. */ -export interface SessionFollowRequest { +export interface SessionFollowRequest extends Pick { readonly address: SessionAddress - readonly maxMessages?: number /** Include process-local assistant presentation frames for the Web client. */ readonly assistantStream?: true } diff --git a/packages/api/session-controller/tests/manager.client.spec.ts b/packages/api/session-controller/tests/manager.client.spec.ts index 4d91e0b77f..4cab61367e 100644 --- a/packages/api/session-controller/tests/manager.client.spec.ts +++ b/packages/api/session-controller/tests/manager.client.spec.ts @@ -777,7 +777,8 @@ describe('subagent catalogs', () => { mode: 'continuable', }, assistantStream: true, - maxMessages: 50, + maxMessages: 500, + turnWindow: { minMessages: 50, minTurns: 2 }, }, ]) expect(mock.log.requests('session/page')).toEqual([]) diff --git a/packages/api/session-controller/tests/projection-store.client.spec.ts b/packages/api/session-controller/tests/projection-store.client.spec.ts index c3a14febf3..40e1f3c95f 100644 --- a/packages/api/session-controller/tests/projection-store.client.spec.ts +++ b/packages/api/session-controller/tests/projection-store.client.spec.ts @@ -44,6 +44,18 @@ function makeManager(mock: RemoteMock, remote: ClientTestFixtures['remote']): Se } describe('Session projection value semantics', () => { + it('exposes a watermark only for current Host-sequenced values', () => { + const store = new ProjectionValueStore() + expect(store.seqOf('inbox')).toBeUndefined() + store.applyCached({ inbox: { 'next-turn': [], 'next-step': [] } }) + expect(store.seqOf('inbox')).toBeUndefined() + store.apply('inbox', { 'next-turn': [], 'next-step': [] }, SessionSeq(4)) + store.apply('inbox', { 'next-turn': [], 'next-step': [] }, SessionSeq(2)) + expect(store.seqOf('inbox')).toBe(4) + store.seed({ asOfSeq: SessionSeq(6), values: {} }) + expect(store.seqOf('inbox')).toBeUndefined() + }) + it('reads undefined until a value lands (capability absence)', () => { const store = new ProjectionValueStore() expect(store.get('test/marks')).toBeUndefined() diff --git a/packages/api/session-controller/tests/session-history-journal.host.spec.ts b/packages/api/session-controller/tests/session-history-journal.host.spec.ts index d836aa9734..86ed42a678 100644 --- a/packages/api/session-controller/tests/session-history-journal.host.spec.ts +++ b/packages/api/session-controller/tests/session-history-journal.host.spec.ts @@ -1,6 +1,6 @@ /** Raw Session journal transport and message-aligned pagination coverage. */ -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, it, onTestFinished, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import AgentRegistry, { type Agent, type AssistantStreamFrame } from '@deepseek-ai/dsh-agent' import SessionStore from '@deepseek-ai/dsh-session' @@ -25,9 +25,9 @@ function appendUserText(session: Session, text: string): SessionEvent { } /** Append a production-shaped assistant message to the session surface. */ -function appendAssistantText(session: Session, text: string, step: number): SessionEvent { +function appendAssistantText(session: Session, text: string, step: number, turn = 1): SessionEvent { return session.append('assistant/message', { - turn: 1, + turn, step, message: createMessage({ role: 'assistant', @@ -748,6 +748,74 @@ describe('Session history raw journal', () => { ]) }) + it.each([ + { name: 'two long Turns', replies: [9, 39, 39], beforeMessage: undefined, firstTurn: 2, steering: false }, + { name: 'the partial upper Turn and its predecessor', replies: [9, 39, 99], beforeMessage: 20, firstTurn: 2, steering: false }, + { name: 'an upper window beginning at its user message', replies: [9, 59, 59], beforeMessage: 0, firstTurn: 2, steering: false }, + { name: 'short Turns until 50 messages', replies: [9, 9, 9, 9, 9, 9], beforeMessage: undefined, firstTurn: 2, steering: false }, + { name: 'navigation until 200 messages', replies: [9, 49, 49, 49, 49], beforeMessage: undefined, firstTurn: 2, steering: false, minMessages: 200 }, + { name: 'steering within one Turn', replies: [9, 39, 39], beforeMessage: undefined, firstTurn: 2, steering: true }, + { name: 'exhausted history below both minima', replies: [9], beforeMessage: undefined, firstTurn: 1, steering: false }, + { name: 'the 500-message cap inside one Turn', replies: [9, 600], beforeMessage: undefined, firstTurn: undefined, steering: false }, + ])('paginates $name in one contiguous Turn window', async ({ replies, beforeMessage, firstTurn, steering, minMessages = 50 }) => { + const { ctx } = await harness() + onTestFinished(() => ctx.fiber.dispose()) + const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) + const session = ctx.sessions.create(undefined, { meta: { cwd: '/workspace' } }) + const starts: SessionEvent[] = [] + const messages: SessionEvent[][] = [] + for (const [index, count] of replies.entries()) { + const turn = index + 1 + starts.push(session.append('turn/start', { turn })) + const rows = [appendUserText(session, `prompt ${turn}`)] + for (let step = 1; step <= count; step++) { + rows.push(appendAssistantText(session, `reply ${turn}/${step}`, step, turn)) + if (steering && index === replies.length - 1 && step % 3 === 0) { + rows.push(appendUserText(session, `steering ${step}`)) + } + } + messages.push(rows) + session.append('turn/end', { turn, reason: { kind: 'completed' } }) + } + const beforeSeq = beforeMessage === undefined ? session.seq : messages.at(-1)![beforeMessage]!.seq + const response = await remote.page({ + address: { kind: 'session', sessionId: session.id }, + throughSeq: session.seq - 1, + beforeSeq, + maxMessages: 500, + turnWindow: { minMessages, minTurns: 2 }, + }) + if (!response.ok) throw new Error('expected a history page') + const page = pageEvents(response.value) + const expectedStart = firstTurn === undefined ? messages.at(-1)!.at(-500)!.seq : starts[firstTurn - 1]!.seq + expect(page.map(event => event.seq)).toEqual(Array.from({ length: beforeSeq - expectedStart }, (_, index) => expectedStart + index)) + expect(response.value.hasMore).toBe(expectedStart > 0) + if (firstTurn === undefined) { + expect(page.filter(event => event.type === 'user/message' || event.type === 'assistant/message')).toHaveLength(500) + expect(page.some(event => event.type === 'user/message')).toBe(false) + } else { + expect(page[0]?.type).toBe('turn/start') + expect(page.some(event => event.seq === messages[firstTurn - 1]![0]!.seq)).toBe(true) + } + if (beforeMessage === undefined) { + const abort = new AbortController() + const stream = remote.follow({ + address: { kind: 'session', sessionId: session.id }, + maxMessages: 500, + turnWindow: { minMessages, minTurns: 2 }, + }, abort.signal)[Symbol.asyncIterator]() + try { + await expect(stream.next()).resolves.toMatchObject({ + done: false, + value: { type: 'snapshot', records: response.value.records, hasMore: response.value.hasMore }, + }) + } finally { + abort.abort() + await stream.return?.() + } + } + }) + it('counts only append-origin messages toward maxMessages and keeps each compaction summary with its replacement', async () => { const { ctx } = await harness() const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) diff --git a/packages/api/session-controller/tests/session-pending-submissions.client.spec.ts b/packages/api/session-controller/tests/session-pending-submissions.client.spec.ts index b215ec557b..af3c06a0b6 100644 --- a/packages/api/session-controller/tests/session-pending-submissions.client.spec.ts +++ b/packages/api/session-controller/tests/session-pending-submissions.client.spec.ts @@ -105,6 +105,19 @@ describe('beginSubmission', () => { ]) }) + it('keeps rapid ordinary submissions in Chat until the running update arrives', async ({ mock, start }) => { + const session = await sessionBench(mock, start, SID) + for (const text of ['A', 'B', 'C']) session.beginSubmission({ mode: 'queue', text, attachments: [] }) + expect(session.getSnapshot().pendingSubmissions.map(input => input.placement)).toEqual([ + 'transcript', 'transcript', 'transcript', + ]) + session.handleRunning(true) + session.beginSubmission({ mode: 'queue', text: 'D', attachments: [] }) + expect(session.getSnapshot().pendingSubmissions.map(input => input.placement)).toEqual([ + 'transcript', 'transcript', 'transcript', 'queued', + ]) + }) + it('abandon retires the echo as failed exactly once', async ({ mock, start }) => { const session = await sessionBench(mock, start, SID) const retirements: PendingSubmissionRetirement[] = [] @@ -176,7 +189,7 @@ describe('observed retirement', () => { expect(retirements).toEqual([{ reason: 'observed', attachments: refs }]) }) - it('retires an accepted echo when a claim clears the projection before its notification', async ({ mock, start }) => { + it('retains an idle echo when a claim clears the projection before its notification', async ({ mock, start }) => { const session = await sessionBench(mock, start, SID) await session.open() const onRetire = vi.fn() @@ -190,10 +203,140 @@ describe('observed retirement', () => { data: { target: 'next-step', start: 0, inserted: [message] }, }) await settleFrames() + expect(session.getSnapshot().pendingSubmissions).toHaveLength(1) + expect(onRetire).not.toHaveBeenCalled() + await pushEvent(mock, { + type: 'agent/inbox/spliced', seq: SessionSeq(1), time: 2, + data: { target: 'next-step', start: 0, removedCount: 1, inserted: [] }, + }) + await pushEvent(mock, promptEvent(SessionSeq(2), handle.requestId, refs)) + await settleFrames() expect(session.getSnapshot().pendingSubmissions).toEqual([]) expect(onRetire).toHaveBeenCalledExactlyOnceWith({ reason: 'observed', attachments: refs }) }) + for (const { mode, target, order } of [ + { mode: 'queue', target: 'next-turn', order: 'inbox-first' }, + { mode: 'queue', target: 'next-turn', order: 'transcript-first' }, + { mode: 'steer', target: 'next-step', order: 'inbox-first' }, + { mode: 'steer', target: 'next-step', order: 'transcript-first' }, + ] as const) { + it(`keeps a ${mode} Chat identity until admission and the Inbox claim watermark (${order})`, async ({ mock, start }) => { + const session = await sessionBench(mock, start, SID) + await session.open() + session.projections.apply('inbox', { 'next-turn': [], 'next-step': [] }, -1) + const frames: FrameRequestCallback[] = [] + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => frames.push(callback)) + const onRetire = vi.fn() + session.handleRunning(mode === 'steer') + const handle = session.beginSubmission({ mode, text: 'local input', attachments: [], onRetire }) + const refs = [imageRef('idle-image'), fileRef('idle-file')] + const message = queuedItem(handle.requestId, refs) + const queued = { 'next-turn': [], 'next-step': [], [target]: [message] } + const empty = { 'next-turn': [], 'next-step': [] } + if (order === 'inbox-first') session.projections.apply('inbox', queued, SessionSeq(0)) + await pushEvent(mock, { + type: 'agent/inbox/spliced', seq: SessionSeq(0), time: 1, + data: { target, start: 0, inserted: [message] }, + }) + await pushEvent(mock, { + type: 'agent/inbox/spliced', seq: SessionSeq(1), time: 2, + data: { target, start: 0, removedCount: 1, inserted: [] }, + }) + expect(session.getSnapshot().pendingSubmissions).toHaveLength(1) + expect(frames).toHaveLength(0) + if (order === 'inbox-first') session.projections.apply('inbox', empty, SessionSeq(1)) + await pushEvent(mock, promptEvent(SessionSeq(2), handle.requestId, refs)) + if (order === 'transcript-first') { + await pushEvent(mock, ev.turnEnd(SessionSeq(3), 1)) + handle.abandon() + session.projections.apply('inbox', queued, SessionSeq(0)) + await Promise.resolve() + expect(frames).toHaveLength(0) + expect(session.getSnapshot().pendingSubmissions).toHaveLength(1) + expect(onRetire).not.toHaveBeenCalled() + session.projections.apply('inbox', empty, SessionSeq(1)) + await Promise.resolve() + } + expect(frames).toHaveLength(1) + frames[0]?.(0) + expect(session.getSnapshot().pendingSubmissions).toEqual([]) + expect(onRetire).toHaveBeenCalledExactlyOnceWith({ reason: 'observed', attachments: refs }) + session.projections.apply('inbox', queued, SessionSeq(0)) + expect(session.projections.get('inbox')).toEqual(empty) + }) + } + + for (const projectionFirst of [true, false]) { + it(`settles overlapping opening, queued and steering inputs independently (${projectionFirst ? 'projection' : 'history'} first)`, async ({ mock, start }) => { + const session = await sessionBench(mock, start, SID) + await session.open() + const frames: FrameRequestCallback[] = [] + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => frames.push(callback)) + const flush = () => { for (const callback of frames.splice(0)) callback(0) } + const normalRetired = vi.fn() + const queueRetired = vi.fn() + const steerRetired = vi.fn() + const normal = session.beginSubmission({ mode: 'queue', text: 'opening', attachments: [], onRetire: normalRetired }) + session.handleRunning(true) + const queued = session.beginSubmission({ mode: 'queue', text: 'queued', attachments: [], onRetire: queueRetired }) + const steer = session.beginSubmission({ mode: 'steer', text: 'steering', attachments: [], onRetire: steerRetired }) + const normalMessage = queuedItem(normal.requestId, [fileRef('opening-file')]) + const queuedMessage = queuedItem(queued.requestId, [imageRef('queued-image')]) + const steerMessage = queuedItem(steer.requestId, [imageRef('steer-image'), fileRef('steer-file')]) + const pending = () => session.getSnapshot().pendingSubmissions.map(input => input.text) + const accepted = { 'next-turn': [normalMessage, queuedMessage], 'next-step': [steerMessage] } + if (projectionFirst) session.projections.apply('inbox', accepted, SessionSeq(1)) + await pushEvent(mock, { type: 'agent/inbox/spliced', seq: SessionSeq(0), time: 1, + data: { target: 'next-turn', start: 0, inserted: [normalMessage, queuedMessage] } }) + await pushEvent(mock, { type: 'agent/inbox/spliced', seq: SessionSeq(1), time: 2, + data: { target: 'next-step', start: 0, inserted: [steerMessage] } }) + flush() + expect(pending()).toEqual(['opening', 'steering']) + expect(queueRetired).toHaveBeenCalledExactlyOnceWith({ reason: 'observed', attachments: [imageRef('queued-image')] }) + await pushEvent(mock, ev.turnStart(SessionSeq(2), 1)) + await pushEvent(mock, { type: 'agent/inbox/spliced', seq: SessionSeq(3), time: 4, + data: { target: 'next-step', start: 0, removedCount: 1, inserted: [] } }) + await pushEvent(mock, { type: 'agent/inbox/spliced', seq: SessionSeq(4), time: 5, + data: { target: 'next-turn', start: 0, removedCount: 1, inserted: [] } }) + const claimed = { 'next-turn': [queuedMessage], 'next-step': [] } + if (projectionFirst) session.projections.apply('inbox', claimed, SessionSeq(4)) + await pushEvent(mock, ev.stepStart(SessionSeq(5), 1, 1)) + await pushEvent(mock, { type: 'user/message', seq: SessionSeq(6), time: 7, surfaceOp: 'append', data: steerMessage }) + flush() + expect(pending()).toEqual(projectionFirst ? ['opening'] : ['opening', 'steering']) + expect(normalRetired).not.toHaveBeenCalled() + if (!projectionFirst) expect(steerRetired).not.toHaveBeenCalled() + await pushEvent(mock, { type: 'user/message', seq: SessionSeq(7), time: 8, surfaceOp: 'append', data: normalMessage }) + flush() + if (!projectionFirst) { + expect(pending()).toEqual(['opening', 'steering']) + session.projections.apply('inbox', accepted, SessionSeq(1)) + flush() + expect(normalRetired).not.toHaveBeenCalled() + session.projections.apply('inbox', claimed, SessionSeq(4)) + await Promise.resolve() + flush() + } + expect(pending()).toEqual([]) + expect(normalRetired).toHaveBeenCalledExactlyOnceWith({ reason: 'observed', attachments: [fileRef('opening-file')] }) + expect(steerRetired).toHaveBeenCalledExactlyOnceWith({ + reason: 'observed', attachments: [imageRef('steer-image'), fileRef('steer-file')], + }) + await pushEvent(mock, ev.stepEnd(SessionSeq(8), 1, 1)) + await pushEvent(mock, ev.turnEnd(SessionSeq(9), 1)) + await pushEvent(mock, ev.turnStart(SessionSeq(10), 2)) + await pushEvent(mock, { type: 'agent/inbox/spliced', seq: SessionSeq(11), time: 12, + data: { target: 'next-turn', start: 0, removedCount: 1, inserted: [] } }) + await pushEvent(mock, ev.stepStart(SessionSeq(12), 2, 2)) + await pushEvent(mock, { type: 'user/message', seq: SessionSeq(13), time: 14, surfaceOp: 'append', data: queuedMessage }) + session.projections.apply('inbox', { 'next-turn': [], 'next-step': [] }, SessionSeq(11)) + flush() + expect(queueRetired).toHaveBeenCalledTimes(1) + expect(pending()).toEqual([]) + }) + } + it('keeps a running steer echo through accepted and claimed Inbox states until transcript admission', async ({ mock, start }) => { const session = await sessionBench(mock, start, SID) await session.open() @@ -226,44 +369,46 @@ describe('observed retirement', () => { expect(onRetire).toHaveBeenCalledExactlyOnceWith({ reason: 'observed', attachments: refs }) }) - it('retires canceled or unadmitted steering without removing still-pending input', async ({ mock, start }) => { - const session = await sessionBench(mock, start, SID) - await session.open() - session.handleRunning(true) - const canceled = vi.fn() - const rejected = vi.fn() - const first = session.beginSubmission({ mode: 'steer', text: 'cancel', attachments: [], onRetire: canceled }) - const second = session.beginSubmission({ mode: 'steer', text: 'reject', attachments: [], onRetire: rejected }) - const unclaimed = session.beginSubmission({ mode: 'steer', text: 'later', attachments: [] }) - const foreign = createUserMessage({ source: { kind: 'user' }, content: [] }) - await pushEvent(mock, { - type: 'agent/inbox/spliced', seq: SessionSeq(0), time: 1, - data: { target: 'next-step', start: 0, inserted: [queuedItem(first.requestId), queuedItem(second.requestId)] }, + for (const target of ['next-step', 'next-turn'] as const) { + it(`retires canceled or unadmitted ${target} input without removing still-pending input`, async ({ mock, start }) => { + const session = await sessionBench(mock, start, SID) + await session.open() + session.handleRunning(target === 'next-step') + const canceled = vi.fn() + const rejected = vi.fn() + const first = session.beginSubmission({ mode: 'steer', text: 'cancel', attachments: [], onRetire: canceled }) + const second = session.beginSubmission({ mode: 'steer', text: 'reject', attachments: [], onRetire: rejected }) + const unclaimed = session.beginSubmission({ mode: 'steer', text: 'later', attachments: [] }) + const foreign = createUserMessage({ source: { kind: 'user' }, content: [] }) + await pushEvent(mock, { + type: 'agent/inbox/spliced', seq: SessionSeq(0), time: 1, + data: { target, start: 0, inserted: [queuedItem(first.requestId), queuedItem(second.requestId)] }, + }) + await pushEvent(mock, { + type: 'agent/inbox/spliced', seq: SessionSeq(1), time: 2, + data: { target, start: 0, inserted: [foreign] }, + }) + await pushEvent(mock, { + type: 'agent/inbox/spliced', seq: SessionSeq(2), time: 3, + data: { target, start: 1, removedCount: 1, inserted: [], outcome: 'canceled' }, + }) + expect(canceled).toHaveBeenCalledExactlyOnceWith({ reason: 'failed' }) + expect(rejected).not.toHaveBeenCalled() + await pushEvent(mock, { + type: 'agent/inbox/spliced', seq: SessionSeq(3), time: 4, + data: { target, start: 0, removedCount: 2, inserted: [] }, + }) + await pushEvent(mock, { + type: 'agent/inbox/spliced', seq: SessionSeq(4), time: 5, + data: { target, start: 0, inserted: [queuedItem(unclaimed.requestId)] }, + }) + await pushEvent(mock, { + type: 'turn/end', seq: SessionSeq(5), time: 6, data: { turn: 1, reason: { kind: 'blocked' } }, + }) + expect(rejected).toHaveBeenCalledExactlyOnceWith({ reason: 'failed' }) + expect(session.getSnapshot().pendingSubmissions.map(echo => echo.requestId)).toEqual([unclaimed.requestId]) }) - await pushEvent(mock, { - type: 'agent/inbox/spliced', seq: SessionSeq(1), time: 2, - data: { target: 'next-step', start: 0, inserted: [foreign] }, - }) - await pushEvent(mock, { - type: 'agent/inbox/spliced', seq: SessionSeq(2), time: 3, - data: { target: 'next-step', start: 1, removedCount: 1, inserted: [], outcome: 'canceled' }, - }) - expect(canceled).toHaveBeenCalledExactlyOnceWith({ reason: 'failed' }) - expect(rejected).not.toHaveBeenCalled() - await pushEvent(mock, { - type: 'agent/inbox/spliced', seq: SessionSeq(3), time: 4, - data: { target: 'next-step', start: 0, removedCount: 2, inserted: [] }, - }) - await pushEvent(mock, { - type: 'agent/inbox/spliced', seq: SessionSeq(4), time: 5, - data: { target: 'next-step', start: 0, inserted: [queuedItem(unclaimed.requestId)] }, - }) - await pushEvent(mock, { - type: 'turn/end', seq: SessionSeq(5), time: 6, data: { turn: 1, reason: { kind: 'blocked' } }, - }) - expect(rejected).toHaveBeenCalledExactlyOnceWith({ reason: 'failed' }) - expect(session.getSnapshot().pendingSubmissions.map(echo => echo.requestId)).toEqual([unclaimed.requestId]) - }) + } it('a queue occurrence carrying the rpcId retires the echo (running-turn submissions)', async ({ mock, start }) => { const session = await sessionBench(mock, start, SID) @@ -316,21 +461,28 @@ describe('observed retirement', () => { expect(session.getSnapshot().pendingSubmissions).toEqual([]) }) - for (const pending of [false, true]) { - it(`retires confirmed steering on resync with the Host input ${pending ? 'still pending' : 'outside the history window'}`, async ({ mock, start }) => { + for (const [target, pending] of [ + ['next-step', false], ['next-step', true], ['next-turn', false], ['next-turn', true], + ] as const) { + it(`retires confirmed ${target} input on resync with the Host input ${pending ? 'still pending' : 'outside the history window'}`, async ({ mock, start }) => { const session = await sessionBench(mock, start, SID) await session.open() - session.handleRunning(true) + session.handleRunning(target === 'next-step') const onRetire = vi.fn() const handle = session.beginSubmission({ mode: 'steer', text: 'accepted', attachments: [], onRetire }) const refs = [imageRef('steer-image'), fileRef('steer-file')] const message = queuedItem(handle.requestId, refs) - await pushEvent(mock, { - type: 'agent/inbox/spliced', seq: SessionSeq(0), time: 1, - data: { target: 'next-step', start: 0, inserted: [message] }, - }) + if (target === 'next-turn') { + session.projections.apply('inbox', { 'next-turn': [message], 'next-step': [] }, SessionSeq(0)) + await Promise.resolve() + } else { + await pushEvent(mock, { + type: 'agent/inbox/spliced', seq: SessionSeq(0), time: 1, + data: { target, start: 0, inserted: [message] }, + }) + } const unconfirmed = session.beginSubmission({ mode: 'steer', text: 'not accepted yet', attachments: [] }) - const inbox = { 'next-turn': [], 'next-step': pending ? [message] : [] } + const inbox = { 'next-turn': [], 'next-step': [], [target]: pending ? [message] : [] } const replacement = { ...historyValue([ev.turnEnd(SessionSeq(10), 2)], true), projections: { asOfSeq: 10, values: { inbox } }, @@ -430,6 +582,28 @@ describe('observed retirement', () => { }) describe('disposal', () => { + it('settles an admitted transcript as observed when disposed before the Inbox watermark arrives', async ({ mock, start }) => { + const session = await sessionBench(mock, start, SID) + await session.open() + const onRetire = vi.fn() + const handle = session.beginSubmission({ mode: 'queue', text: 'admitted', attachments: [], onRetire }) + const refs = [imageRef('held-image')] + await pushEvent(mock, { + type: 'agent/inbox/spliced', seq: SessionSeq(0), time: 1, + data: { target: 'next-turn', start: 0, inserted: [queuedItem(handle.requestId, refs)] }, + }) + await pushEvent(mock, { + type: 'agent/inbox/spliced', seq: SessionSeq(1), time: 2, + data: { target: 'next-turn', start: 0, removedCount: 1, inserted: [] }, + }) + await pushEvent(mock, promptEvent(SessionSeq(2), handle.requestId, refs)) + expect(onRetire).not.toHaveBeenCalled() + await session.dispose() + await settleFrames() + expect(session.getSnapshot().pendingSubmissions).toEqual([]) + expect(onRetire).toHaveBeenCalledExactlyOnceWith({ reason: 'observed', attachments: refs }) + }) + it('retires unsettled echoes as failed and preserves an already-observed settlement', async ({ mock, start }) => { const session = await sessionBench(mock, start, SID) await session.open() diff --git a/packages/api/session-controller/tests/session.client.spec.ts b/packages/api/session-controller/tests/session.client.spec.ts index 0c93b71af4..ccba6d3a6a 100644 --- a/packages/api/session-controller/tests/session.client.spec.ts +++ b/packages/api/session-controller/tests/session.client.spec.ts @@ -13,7 +13,7 @@ import { RemoteStreamCarrierError } from '@deepseek-ai/dsh-api-gateway/client' import { RemoteError, type RemoteResult } from '@deepseek-ai/dsh-typert-protocol' import { ok, type RemoteMock } from '@deepseek-ai/dsh-remote-mock' import { createClientTest, type TestClient, webApp } from '@deepseek-ai/dsh-client-test-runtime/src/assembly/index.ts' -import { JUMP_PAGE_MESSAGES, Session } from '../src/client/sessions/session.ts' +import { Session } from '../src/client/sessions/session.ts' import { SessionEventStream } from '../src/client/transport.ts' import type { SessionFollowRequest, SessionPage, SessionPageRequest } from '../src/types.ts' import { entries, ev, historyValue, plainTurn } from './event-script.client.ts' @@ -191,9 +191,9 @@ describe('paging', () => { await session.open() await session.loadOlder() const snapshot = session.getSnapshot() - expect(mock.log.requests(FOLLOW)).toHaveLength(1) - expect(mock.log.requests(PAGE)).toMatchObject([ - { address: ADDRESS, throughSeq: 11, beforeSeq: 6 }, + expect(mock.log.requests(FOLLOW)).toMatchObject([{ maxMessages: 500, turnWindow: { minMessages: 50, minTurns: 2 } }]) + expect(mock.log.requests(PAGE)).toEqual([ + { address: ADDRESS, throughSeq: 11, beforeSeq: 6, maxMessages: 500, turnWindow: { minMessages: 50, minTurns: 2 } }, ]) expect(snapshot.hasMore).toBe(false) expect(eventSeqs(session)).toEqual([...older, ...newer].map(event => event.seq)) @@ -254,8 +254,8 @@ describe('paging', () => { expect(snapshot.loadingOlder).toBe(false) expect(eventSeqs(session)).toEqual([...oldest, ...middle, ...newest].map(event => event.seq)) expect(mock.log.requests(PAGE)).toMatchObject([ - { beforeSeq: 12, maxMessages: JUMP_PAGE_MESSAGES }, - { beforeSeq: 6, maxMessages: JUMP_PAGE_MESSAGES }, + { beforeSeq: 12, maxMessages: 500, turnWindow: { minMessages: 200, minTurns: 2 } }, + { beforeSeq: 6, maxMessages: 500, turnWindow: { minMessages: 200, minTurns: 2 } }, ]) }) @@ -484,7 +484,7 @@ describe('prompt and cancel errors', () => { expect(steered).toEqual({ ok: true, value: { accepted: true } }) expect(cancelled).toEqual({ ok: true, value: { accepted: true } }) expect(mock.log.requests(FOLLOW)).toEqual([ - { address: { kind: 'subagent', ...CHILD }, assistantStream: true, maxMessages: 50 }, + { address: { kind: 'subagent', ...CHILD }, assistantStream: true, maxMessages: 500, turnWindow: { minMessages: 50, minTurns: 2 } }, ]) expect(mock.log.requests(PAGE)).toEqual([]) // The prompt mode crosses the wire as the request's delivery. @@ -580,7 +580,7 @@ describe('prompt and cancel errors', () => { expect(mock.log.requests('subagents/prompt')).toMatchObject([CHILD]) expect(mock.log.calls('subagents/interruptByParent').map(call => call.args)).toEqual([[SID, PARENT, 'continuable']]) expect(mock.log.requests(FOLLOW)).toEqual([ - { address: { kind: 'subagent', ...CHILD, mode: 'one-shot' }, assistantStream: true, maxMessages: 50 }, + { address: { kind: 'subagent', ...CHILD, mode: 'one-shot' }, assistantStream: true, maxMessages: 500, turnWindow: { minMessages: 50, minTurns: 2 } }, ]) expect(mock.log.requests(PAGE)).toEqual([]) expect(mock.log.requests('session/cancel')).toEqual([]) diff --git a/packages/api/session-controller/tests/transport.client.spec.ts b/packages/api/session-controller/tests/transport.client.spec.ts index b0efb1b9bb..87d8fd7479 100644 --- a/packages/api/session-controller/tests/transport.client.spec.ts +++ b/packages/api/session-controller/tests/transport.client.spec.ts @@ -542,15 +542,15 @@ describe('Session Client stream adapters', () => { failed: vi.fn(), }) - await stream.open({ maxMessages: 50 }) + await stream.open({ maxMessages: 500, turnWindow: { minMessages: 50, minTurns: 2 } }) await vi.waitFor(() => { expect(changes).toHaveLength(2) }) - await stream.prepend({ beforeSeq: 2, maxMessages: 50 }) + await stream.prepend({ beforeSeq: 2, maxMessages: 500, turnWindow: { minMessages: 50, minTurns: 2 } }) expect(remote.followRequests).toEqual([{ - address: ADDRESS, assistantStream: true, maxMessages: 50, + address: ADDRESS, assistantStream: true, maxMessages: 500, turnWindow: { minMessages: 50, minTurns: 2 }, }]) expect(remote.pageRequests).toEqual([ - { address: ADDRESS, throughSeq: 4, beforeSeq: 2, maxMessages: 50 }, + { address: ADDRESS, throughSeq: 4, beforeSeq: 2, maxMessages: 500, turnWindow: { minMessages: 50, minTurns: 2 } }, ]) expect(changes).toMatchObject([ { type: 'replace', entries: [entry(2), entry(3)], hasMore: true }, @@ -581,12 +581,12 @@ describe('Session Client stream adapters', () => { failed: vi.fn(), }) - await stream.open({ maxMessages: 50 }) + await stream.open({ maxMessages: 500, turnWindow: { minMessages: 50, minTurns: 2 } }) await vi.waitFor(() => { expect(remote.followRequests).toHaveLength(2) }) expect(remote.followRequests).toEqual([ - { address: ADDRESS, assistantStream: true, maxMessages: 50 }, - { address: ADDRESS, assistantStream: true, maxMessages: 50 }, + { address: ADDRESS, assistantStream: true, maxMessages: 500, turnWindow: { minMessages: 50, minTurns: 2 } }, + { address: ADDRESS, assistantStream: true, maxMessages: 500, turnWindow: { minMessages: 50, minTurns: 2 } }, ]) expect(remote.pageRequests).toEqual([]) expect(changes.map(change => change.type)).toEqual(['replace', 'append', 'replace']) @@ -623,7 +623,7 @@ describe('Session Client stream adapters', () => { await stream.dispose() }) - it.each([{}, { maxMessages: 50 }])('repairs a live gap preserving message limit %j', async (request) => { + it.each([{}, { maxMessages: 50 }, { maxMessages: 500, turnWindow: { minMessages: 50, minTurns: 2 } }])('repairs a live gap preserving history limits %j', async (request) => { const remote = new ScriptedSessionRemote( [{ frames: [snapshot(0, [entry(0)]), entry(2)], hold: true }], [{ ok: true, value: page([entry(0), entry(1), entry(2)]) }], diff --git a/packages/api/session-controller/tests/transport.host.spec.ts b/packages/api/session-controller/tests/transport.host.spec.ts index 341aaf4e86..890000a0ac 100644 --- a/packages/api/session-controller/tests/transport.host.spec.ts +++ b/packages/api/session-controller/tests/transport.host.spec.ts @@ -517,6 +517,12 @@ describe('SessionHistoryController', () => { { address, throughSeq: -1, beforeSeq: 1.5 }, { address, throughSeq: -1, maxMessages: 0 }, { address, throughSeq: -1, maxMessages: 1.5 }, + { address, throughSeq: -1, turnWindow: { minMessages: 0, minTurns: 2 } }, + { address, throughSeq: -1, turnWindow: { minMessages: 1.5, minTurns: 2 } }, + { address, throughSeq: -1, turnWindow: { minMessages: 51, minTurns: 2 } }, + { address, throughSeq: -1, maxMessages: 20, turnWindow: { minMessages: 21, minTurns: 2 } }, + { address, throughSeq: -1, turnWindow: { minMessages: 50, minTurns: 0 } }, + { address, throughSeq: -1, turnWindow: { minMessages: 50, minTurns: 1.5 } }, ]) { await expect(transport.page(request, signal())).rejects.toMatchObject({ code: 'gateway/bad-request' }) } @@ -537,6 +543,16 @@ describe('SessionHistoryController', () => { const iterator = transport.follow({ address, maxMessages }, signal())[Symbol.asyncIterator]() await expect(iterator.next()).rejects.toMatchObject({ code: 'gateway/bad-request' }) } + for (const turnWindow of [ + { minMessages: 0, minTurns: 2 }, + { minMessages: 1.5, minTurns: 2 }, + { minMessages: 51, minTurns: 2 }, + { minMessages: 50, minTurns: 0 }, + { minMessages: 50, minTurns: 1.5 }, + ]) { + const iterator = transport.follow({ address, turnWindow }, signal())[Symbol.asyncIterator]() + await expect(iterator.next()).rejects.toMatchObject({ code: 'gateway/bad-request' }) + } }) it('reports missing ordinary and subagent sources without fabricating inspection failures', async () => { diff --git a/packages/api/settings-controller/package.json b/packages/api/settings-controller/package.json index e15ce96424..e228f2cbeb 100644 --- a/packages/api/settings-controller/package.json +++ b/packages/api/settings-controller/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-api-settings-controller", "description": "Remote owner for the configuration surfaces over the settings-domain seams", - "version": "0.1.7-alpha.1", + "version": "0.1.7-alpha.2", "publishConfig": { "access": "public" }, @@ -44,25 +44,25 @@ ], "license": "MIT", "dependencies": { - "@deepseek-ai/schemastery": "workspace:^", + "@deepseek-ai/schemastery": "workspace:~", "zod": "^4.4.3" }, "peerDependencies": { - "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-agent-preset-registry": "workspace:^", - "@deepseek-ai/dsh-credentials": "workspace:^", - "@deepseek-ai/dsh-native-command": "workspace:^", - "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/dsh-settings": "workspace:^", - "@deepseek-ai/dsh-typert-protocol": "workspace:^" + "@deepseek-ai/cordis": "workspace:~", + "@deepseek-ai/dsh-agent-preset-registry": "workspace:*", + "@deepseek-ai/dsh-credentials": "workspace:*", + "@deepseek-ai/dsh-native-command": "workspace:*", + "@deepseek-ai/dsh-session": "workspace:*", + "@deepseek-ai/dsh-settings": "workspace:*", + "@deepseek-ai/dsh-typert-protocol": "workspace:*" }, "devDependencies": { - "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-agent-preset-registry": "workspace:^", - "@deepseek-ai/dsh-credentials": "workspace:^", - "@deepseek-ai/dsh-native-command": "workspace:^", - "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/dsh-settings": "workspace:^", - "@deepseek-ai/dsh-typert-protocol": "workspace:^" + "@deepseek-ai/cordis": "workspace:~", + "@deepseek-ai/dsh-agent-preset-registry": "workspace:*", + "@deepseek-ai/dsh-credentials": "workspace:*", + "@deepseek-ai/dsh-native-command": "workspace:*", + "@deepseek-ai/dsh-session": "workspace:*", + "@deepseek-ai/dsh-settings": "workspace:*", + "@deepseek-ai/dsh-typert-protocol": "workspace:*" } } diff --git a/packages/api/terminal-controller/package.json b/packages/api/terminal-controller/package.json index 279c79b96c..c26390e3d6 100644 --- a/packages/api/terminal-controller/package.json +++ b/packages/api/terminal-controller/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-api-terminal-controller", "description": "Session-owned interactive terminals with shell discovery, screen recovery and typed Remote control", - "version": "0.1.7-alpha.1", + "version": "0.1.7-alpha.2", "publishConfig": { "access": "public" }, @@ -54,33 +54,33 @@ }, "license": "MIT", "dependencies": { - "@deepseek-ai/dsh-deque": "workspace:^", - "@deepseek-ai/dsh-lazy-require": "workspace:^", - "@deepseek-ai/dsh-typert-protocol": "workspace:^", - "@deepseek-ai/schemastery": "workspace:^", + "@deepseek-ai/dsh-deque": "workspace:*", + "@deepseek-ai/dsh-lazy-require": "workspace:*", + "@deepseek-ai/dsh-typert-protocol": "workspace:*", + "@deepseek-ai/schemastery": "workspace:~", "@xterm/headless": "^6.0.0", "@xterm/addon-serialize": "^0.14.0", "zod": "^4.4.3" }, "peerDependencies": { - "@deepseek-ai/dsh-subprocess": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/dsh-subprocess": "workspace:*", + "@deepseek-ai/cordis": "workspace:~" }, "devDependencies": { - "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-agent": "workspace:^", - "@deepseek-ai/dsh-api-gateway": "workspace:^", - "@deepseek-ai/dsh-brand": "workspace:^", - "@deepseek-ai/dsh-client-store": "workspace:^", - "@deepseek-ai/dsh-fs": "workspace:^", - "@deepseek-ai/dsh-remote-mock": "workspace:^", - "@deepseek-ai/dsh-sandbox": "workspace:^", - "@deepseek-ai/dsh-sandbox-policy": "workspace:^", - "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/dsh-session-projection": "workspace:^", - "@deepseek-ai/dsh-subprocess": "workspace:^", - "@deepseek-ai/dsh-subprocess-local": "workspace:^", - "@deepseek-ai/dsh-util-crypto": "workspace:^" + "@deepseek-ai/cordis": "workspace:~", + "@deepseek-ai/dsh-agent": "workspace:*", + "@deepseek-ai/dsh-api-gateway": "workspace:*", + "@deepseek-ai/dsh-brand": "workspace:*", + "@deepseek-ai/dsh-client-store": "workspace:*", + "@deepseek-ai/dsh-fs": "workspace:*", + "@deepseek-ai/dsh-remote-mock": "workspace:*", + "@deepseek-ai/dsh-sandbox": "workspace:*", + "@deepseek-ai/dsh-sandbox-policy": "workspace:*", + "@deepseek-ai/dsh-session": "workspace:*", + "@deepseek-ai/dsh-session-projection": "workspace:*", + "@deepseek-ai/dsh-subprocess": "workspace:*", + "@deepseek-ai/dsh-subprocess-local": "workspace:*", + "@deepseek-ai/dsh-util-crypto": "workspace:*" }, "files": [ "lib/index.js", diff --git a/packages/api/workspace-controller/package.json b/packages/api/workspace-controller/package.json index cccde98f40..dfa24fae41 100644 --- a/packages/api/workspace-controller/package.json +++ b/packages/api/workspace-controller/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-api-workspace-controller", "description": "Workspace Remote commands and reconnect-safe state transport", - "version": "0.1.7-alpha.1", + "version": "0.1.7-alpha.2", "publishConfig": { "access": "public" }, @@ -65,32 +65,32 @@ ], "license": "MIT", "dependencies": { - "@deepseek-ai/dsh-deque": "workspace:^", - "@deepseek-ai/dsh-native-command": "workspace:^", - "@deepseek-ai/schemastery": "workspace:^", + "@deepseek-ai/dsh-deque": "workspace:*", + "@deepseek-ai/dsh-native-command": "workspace:*", + "@deepseek-ai/schemastery": "workspace:~", "zod": "^4.4.3" }, "peerDependencies": { - "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-api-gateway": "workspace:^", - "@deepseek-ai/dsh-client-connection": "workspace:^", - "@deepseek-ai/dsh-host-directory-picker": "workspace:^", - "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/dsh-storage-domain": "workspace:^", - "@deepseek-ai/dsh-typert-protocol": "workspace:^", - "@deepseek-ai/dsh-workspace": "workspace:^" + "@deepseek-ai/cordis": "workspace:~", + "@deepseek-ai/dsh-api-gateway": "workspace:*", + "@deepseek-ai/dsh-client-connection": "workspace:*", + "@deepseek-ai/dsh-host-directory-picker": "workspace:*", + "@deepseek-ai/dsh-session": "workspace:*", + "@deepseek-ai/dsh-storage-domain": "workspace:*", + "@deepseek-ai/dsh-typert-protocol": "workspace:*", + "@deepseek-ai/dsh-workspace": "workspace:*" }, "devDependencies": { - "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-api-gateway": "workspace:^", - "@deepseek-ai/dsh-client-connection": "workspace:^", - "@deepseek-ai/dsh-client-store": "workspace:^", - "@deepseek-ai/dsh-client-test-runtime": "workspace:^", - "@deepseek-ai/dsh-host-directory-picker": "workspace:^", - "@deepseek-ai/dsh-remote-mock": "workspace:^", - "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/dsh-storage-domain": "workspace:^", - "@deepseek-ai/dsh-typert-protocol": "workspace:^", - "@deepseek-ai/dsh-workspace": "workspace:^" + "@deepseek-ai/cordis": "workspace:~", + "@deepseek-ai/dsh-api-gateway": "workspace:*", + "@deepseek-ai/dsh-client-connection": "workspace:*", + "@deepseek-ai/dsh-client-store": "workspace:*", + "@deepseek-ai/dsh-client-test-runtime": "workspace:*", + "@deepseek-ai/dsh-host-directory-picker": "workspace:*", + "@deepseek-ai/dsh-remote-mock": "workspace:*", + "@deepseek-ai/dsh-session": "workspace:*", + "@deepseek-ai/dsh-storage-domain": "workspace:*", + "@deepseek-ai/dsh-typert-protocol": "workspace:*", + "@deepseek-ai/dsh-workspace": "workspace:*" } } diff --git a/packages/api/workspace-files/package.json b/packages/api/workspace-files/package.json index b7554a8224..a5e73318e6 100644 --- a/packages/api/workspace-files/package.json +++ b/packages/api/workspace-files/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-api-workspace-files", "description": "Workspace file service and Client resource provider: bounded reads, directory listing, and live metadata over the workspaceFiles Remote namespace", - "version": "0.1.7-alpha.1", + "version": "0.1.7-alpha.2", "publishConfig": { "access": "public" }, @@ -52,26 +52,26 @@ }, "license": "MIT", "dependencies": { - "@deepseek-ai/dsh-deque": "workspace:^", - "@deepseek-ai/dsh-typert-protocol": "workspace:^", - "@deepseek-ai/schemastery": "workspace:^", + "@deepseek-ai/dsh-deque": "workspace:*", + "@deepseek-ai/dsh-typert-protocol": "workspace:*", + "@deepseek-ai/schemastery": "workspace:~", "zod": "^4.4.3" }, "peerDependencies": { - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:~" }, "devDependencies": { - "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-api-gateway": "workspace:^", - "@deepseek-ai/dsh-client-connection": "workspace:^", - "@deepseek-ai/dsh-client-resources": "workspace:^", - "@deepseek-ai/dsh-client-ui-slots": "workspace:^", - "@deepseek-ai/dsh-fs": "workspace:^", - "@deepseek-ai/dsh-remote-mock": "workspace:^", - "@deepseek-ai/dsh-sandbox-policy": "workspace:^", - "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/dsh-session-persistence": "workspace:^", - "@deepseek-ai/dsh-util-workspace-path": "workspace:^" + "@deepseek-ai/cordis": "workspace:~", + "@deepseek-ai/dsh-api-gateway": "workspace:*", + "@deepseek-ai/dsh-client-connection": "workspace:*", + "@deepseek-ai/dsh-client-resources": "workspace:*", + "@deepseek-ai/dsh-client-ui-slots": "workspace:*", + "@deepseek-ai/dsh-fs": "workspace:*", + "@deepseek-ai/dsh-remote-mock": "workspace:*", + "@deepseek-ai/dsh-sandbox-policy": "workspace:*", + "@deepseek-ai/dsh-session": "workspace:*", + "@deepseek-ai/dsh-session-persistence": "workspace:*", + "@deepseek-ai/dsh-util-workspace-path": "workspace:*" }, "files": [ "lib/index.js", diff --git a/packages/attachment/attachment-local/package.json b/packages/attachment/attachment-local/package.json index 005cd77c4c..aa3af60e31 100644 --- a/packages/attachment/attachment-local/package.json +++ b/packages/attachment/attachment-local/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-attachment-local", "description": "Private content-addressed DSH_HOME attachment storage", - "version": "0.1.7-alpha.1", + "version": "0.1.7-alpha.2", "publishConfig": { "access": "public" }, @@ -21,18 +21,18 @@ "files": ["lib/index.js", "lib/types/**/*.d.ts"], "license": "MIT", "peerDependencies": { - "@deepseek-ai/dsh-attachment": "workspace:^", - "@deepseek-ai/dsh-home-paths": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/dsh-attachment": "workspace:*", + "@deepseek-ai/dsh-home-paths": "workspace:*", + "@deepseek-ai/cordis": "workspace:~" }, "dependencies": { - "@deepseek-ai/dsh-lazy-require": "workspace:^", - "@deepseek-ai/schemastery": "workspace:^", + "@deepseek-ai/dsh-lazy-require": "workspace:*", + "@deepseek-ai/schemastery": "workspace:~", "sharp": "^0.35.3" }, "devDependencies": { - "@deepseek-ai/dsh-attachment": "workspace:^", - "@deepseek-ai/dsh-home-paths": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/dsh-attachment": "workspace:*", + "@deepseek-ai/dsh-home-paths": "workspace:*", + "@deepseek-ai/cordis": "workspace:~" } } diff --git a/packages/attachment/attachment/package.json b/packages/attachment/attachment/package.json index 9c69bed474..c377716886 100644 --- a/packages/attachment/attachment/package.json +++ b/packages/attachment/attachment/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-attachment", "description": "Durable immutable attachment storage seam for the DeepSeek Harness", - "version": "0.1.7-alpha.1", + "version": "0.1.7-alpha.2", "publishConfig": { "access": "public" }, @@ -22,11 +22,11 @@ "files": ["lib/index.js", "lib/types/**/*.js", "lib/types/**/*.d.ts"], "license": "MIT", "peerDependencies": { - "@deepseek-ai/dsh-brand": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/dsh-brand": "workspace:*", + "@deepseek-ai/cordis": "workspace:~" }, "devDependencies": { - "@deepseek-ai/dsh-brand": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/dsh-brand": "workspace:*", + "@deepseek-ai/cordis": "workspace:~" } } diff --git a/packages/boot/app-boot/package.json b/packages/boot/app-boot/package.json index bf09307b3d..051c79d0bd 100644 --- a/packages/boot/app-boot/package.json +++ b/packages/boot/app-boot/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-app-boot", "description": "Shared boot glue for the app bins: .env loading, fail-loud Loader guards, snapshot-aware config resolution, and the Loader boot sequence", - "version": "0.1.7-alpha.1", + "version": "0.1.7-alpha.2", "publishConfig": { "access": "public" }, @@ -32,7 +32,7 @@ ], "license": "MIT", "dependencies": { - "@deepseek-ai/dsh-package-manifest": "workspace:^", + "@deepseek-ai/dsh-package-manifest": "workspace:*", "@eslint-community/regexpp": "^4.12.2", "ajv": "^8.20.0", "js-yaml": "^4.2.0", @@ -40,24 +40,24 @@ "resolve.exports": "^2.0.3" }, "peerDependencies": { - "@deepseek-ai/cordis-plugin-group": "workspace:^", - "@deepseek-ai/cordis-plugin-include": "workspace:^", - "@deepseek-ai/cordis-plugin-loader": "workspace:^", - "@deepseek-ai/dsh-launch-environment": "workspace:^", - "@deepseek-ai/dsh-home-paths": "workspace:^", - "@deepseek-ai/dsh-system-prompt": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis-plugin-group": "workspace:~", + "@deepseek-ai/cordis-plugin-include": "workspace:~", + "@deepseek-ai/cordis-plugin-loader": "workspace:~", + "@deepseek-ai/dsh-launch-environment": "workspace:*", + "@deepseek-ai/dsh-home-paths": "workspace:*", + "@deepseek-ai/dsh-system-prompt": "workspace:*", + "@deepseek-ai/cordis": "workspace:~" }, "devDependencies": { - "@deepseek-ai/schemastery": "workspace:^", - "@deepseek-ai/cordis-plugin-group": "workspace:^", - "@deepseek-ai/cordis-plugin-include": "workspace:^", - "@deepseek-ai/cordis-plugin-loader": "workspace:^", - "@deepseek-ai/cordis-plugin-timer": "workspace:^", - "@deepseek-ai/dsh-launch-environment": "workspace:^", - "@deepseek-ai/dsh-home-paths": "workspace:^", - "@deepseek-ai/dsh-system-prompt": "workspace:^", + "@deepseek-ai/schemastery": "workspace:~", + "@deepseek-ai/cordis-plugin-group": "workspace:~", + "@deepseek-ai/cordis-plugin-include": "workspace:~", + "@deepseek-ai/cordis-plugin-loader": "workspace:~", + "@deepseek-ai/cordis-plugin-timer": "workspace:~", + "@deepseek-ai/dsh-launch-environment": "workspace:*", + "@deepseek-ai/dsh-home-paths": "workspace:*", + "@deepseek-ai/dsh-system-prompt": "workspace:*", "@types/js-yaml": "^4.0.9", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:~" } } diff --git a/packages/boot/cmdline/package.json b/packages/boot/cmdline/package.json index 88c76e667b..2a74de5479 100644 --- a/packages/boot/cmdline/package.json +++ b/packages/boot/cmdline/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-cmdline", "description": "Immutable command-line handoff from a dsh launcher to any app plugin that injects cmdlineArgs", - "version": "0.1.7-alpha.1", + "version": "0.1.7-alpha.2", "publishConfig": { "access": "public" }, @@ -27,13 +27,13 @@ ], "license": "MIT", "peerDependencies": { - "@deepseek-ai/cordis-plugin-loader": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis-plugin-loader": "workspace:~", + "@deepseek-ai/cordis": "workspace:~" }, "devDependencies": { - "@deepseek-ai/cordis-plugin-include": "workspace:^", - "@deepseek-ai/cordis-plugin-loader": "workspace:^", + "@deepseek-ai/cordis-plugin-include": "workspace:~", + "@deepseek-ai/cordis-plugin-loader": "workspace:~", "commander": "^15.0.0", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:~" } } diff --git a/packages/boot/config-editor/package.json b/packages/boot/config-editor/package.json index aba05d7311..1a69c93b6d 100644 --- a/packages/boot/config-editor/package.json +++ b/packages/boot/config-editor/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-config-editor", "description": "Persist plugin configuration through profile patches and Loader reconciliation", - "version": "0.1.7-alpha.1", + "version": "0.1.7-alpha.2", "publishConfig": { "access": "public" }, @@ -27,24 +27,24 @@ ], "license": "MIT", "peerDependencies": { - "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/cordis-plugin-loader": "workspace:^", - "@deepseek-ai/dsh-hmr": "workspace:^", - "@deepseek-ai/cordis-plugin-include": "workspace:^", - "@deepseek-ai/dsh-app-boot": "workspace:^" + "@deepseek-ai/cordis": "workspace:~", + "@deepseek-ai/cordis-plugin-loader": "workspace:~", + "@deepseek-ai/dsh-hmr": "workspace:*", + "@deepseek-ai/cordis-plugin-include": "workspace:~", + "@deepseek-ai/dsh-app-boot": "workspace:*" }, "dependencies": { - "@deepseek-ai/dsh-atomic-write": "workspace:^", + "@deepseek-ai/dsh-atomic-write": "workspace:*", "yaml": "^2.8.1", "js-yaml": "^4.1.0" }, "devDependencies": { - "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/cordis-plugin-loader": "workspace:^", - "@deepseek-ai/dsh-hmr": "workspace:^", - "@deepseek-ai/cordis-plugin-include": "workspace:^", + "@deepseek-ai/cordis": "workspace:~", + "@deepseek-ai/cordis-plugin-loader": "workspace:~", + "@deepseek-ai/dsh-hmr": "workspace:*", + "@deepseek-ai/cordis-plugin-include": "workspace:~", "@types/js-yaml": "^4.0.9", - "@deepseek-ai/dsh-app-boot": "workspace:^" + "@deepseek-ai/dsh-app-boot": "workspace:*" }, "peerDependenciesMeta": { "@deepseek-ai/dsh-hmr": { diff --git a/packages/boot/hmr/package.json b/packages/boot/hmr/package.json index 14757f45c7..d57ac71584 100644 --- a/packages/boot/hmr/package.json +++ b/packages/boot/hmr/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-hmr", "description": "Coordinated module and profile configuration hot reload", - "version": "0.1.7-alpha.1", + "version": "0.1.7-alpha.2", "publishConfig": { "access": "public" }, @@ -28,29 +28,29 @@ "author": "Shigma ", "license": "MIT", "peerDependencies": { - "@deepseek-ai/cordis-plugin-timer": "workspace:^", - "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/cordis-plugin-loader": "workspace:^", - "@deepseek-ai/cordis-plugin-include": "workspace:^", - "@deepseek-ai/dsh-app-boot": "workspace:^", - "@deepseek-ai/dsh-cmdline": "workspace:^" + "@deepseek-ai/cordis-plugin-timer": "workspace:~", + "@deepseek-ai/cordis": "workspace:~", + "@deepseek-ai/cordis-plugin-loader": "workspace:~", + "@deepseek-ai/cordis-plugin-include": "workspace:~", + "@deepseek-ai/dsh-app-boot": "workspace:*", + "@deepseek-ai/dsh-cmdline": "workspace:*" }, "dependencies": { "@babel/code-frame": "^7.29.0", "chokidar": "^4.0.3", "picomatch": "^4.0.3", - "@deepseek-ai/schemastery": "workspace:^" + "@deepseek-ai/schemastery": "workspace:~" }, "devDependencies": { "@types/babel__code-frame": "^7.27.0", "@types/picomatch": "^3.0.2", "esbuild": "^0.28.1", - "@deepseek-ai/cordis-plugin-timer": "workspace:^", - "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/cordis-plugin-loader": "workspace:^", - "@deepseek-ai/cordis-plugin-include": "workspace:^", - "@deepseek-ai/dsh-app-boot": "workspace:^", - "@deepseek-ai/dsh-cmdline": "workspace:^", - "@deepseek-ai/dsh-atomic-write": "workspace:^" + "@deepseek-ai/cordis-plugin-timer": "workspace:~", + "@deepseek-ai/cordis": "workspace:~", + "@deepseek-ai/cordis-plugin-loader": "workspace:~", + "@deepseek-ai/cordis-plugin-include": "workspace:~", + "@deepseek-ai/dsh-app-boot": "workspace:*", + "@deepseek-ai/dsh-cmdline": "workspace:*", + "@deepseek-ai/dsh-atomic-write": "workspace:*" } } diff --git a/packages/boot/plugin-manager/README.i18n.yaml b/packages/boot/plugin-manager/README.i18n.yaml index 8dbbf1d7b8..c612967bc1 100644 --- a/packages/boot/plugin-manager/README.i18n.yaml +++ b/packages/boot/plugin-manager/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/plugin-manager/README.md -README.md: 1de9c9d78e421d44eb006f1ab7fd82656f006a2a -README.zh.md: f7502aab64738ad1feccf58b84ff945213df2eff +README.md: 24936df8b1be4e2997c2382e0d84fddd2dfc5345 +README.zh.md: 122ccc9a2796caf891b19a38c3c90e9d32a22fdc diff --git a/packages/boot/plugin-manager/README.md b/packages/boot/plugin-manager/README.md index 1de9c9d78e..24936df8b1 100644 --- a/packages/boot/plugin-manager/README.md +++ b/packages/boot/plugin-manager/README.md @@ -45,11 +45,13 @@ A selected bundle that cannot load remains in `listBundles` with an `error`; `en `inspect(spec, options)` reads what a spec names before anything installs: a registry name is asked of the registry through `pnpm view`, run in the profile directory so the same proxy and authentication settings apply as to the install; an absolute path has its `package.json` read; a git address or tarball answers only its form and the `host` it is fetched from. The answer carries the name, version, description, whether the package declares a bundle, and the `registry` that answered, or a `problem`: `invalid-spec`, `already-installed`, `not-found`, `not-a-package`, `not-a-bundle`, `network`, or `unknown`, with the `registries` asked. A caller's `signal` or `inspectTimeoutMs` ends the lookup. +`installBundle` checks GitHub repositories with `git ls-remote` before starting pnpm, using the profile directory and the installer's Git and proxy configuration. `githubConnectionTimeoutMs` defaults to 5000 ms and limits only this check, not package download or builds. The check disables credential helpers and prompts; only network failures and timeouts stop installation, reporting `failedAt: 'spec-host'` with the existing failure kind and diagnostic log. Authentication, repository lookup and other failures are left to pnpm, including its HTTPS-to-SSH fallback. Cancellation and manager disposal stop the check and its descendants. Registry packages, paths, tarballs and other Git hosts skip this check. A reachable repository can still fail during download or bundle validation. + Registries are asked in turn. The plan starts at `options.registry`, else the configured `registry` (`null` is the one pnpm's own configuration names), and continues through `fallbackRegistries` while a registry is unreachable, times out, or answers that it has no such package or version, which a mirror not yet synced does. A registry outside the configured set is asked alone, so a private registry never falls through to a public one; pnpm's own registry counts as part of the set only while what it names, read through `pnpm config get registry` before each plan, is npm's own registry or one of the fallbacks, and is otherwise asked alone as a private one. A registry pnpm's own configuration already names is asked once. The lookup runs `pnpm view` with `--registry` and without pnpm's own retries, so a dead registry is reported within `inspectTimeoutMs` and the next one is asked; pnpm prints its refusal as JSON on stdout, which is read like stderr. The install keeps pnpm's retry settings. `registries()` answers the configured set and what pnpm names, for a picker. The [registry Agent Note](../../../.agents/notes/implemented/architecture/2026-09-18-plugin-install-registries.md) owns the rationale. The browser-safe `@deepseek-ai/dsh-plugin-manager/registry` entry exports `OFFICIAL_NPM_REGISTRY` and `NPMMIRROR_REGISTRY` for consumers that identify those public registries. -`installBundle` accepts a caller-generated `requestId`, under which `plugin-manager/install-log` streams each pnpm run's output and `plugin-manager/install-state` announces `installing`, `cancelling`, and `applying`; `installing` is announced once per registry asked, with the `attempt`'s registry, position, and the plan's length. The install asks the same plan as the lookup, from `options.registry`, running `pnpm add` with `--registry` and restoring the profile files between attempts; it moves on for the failures another registry can change and stops at any other, and at a failure whose error line names the host a git or tarball spec is fetched from, which no registry stands in for; `failedAt` says which of the two the last failed run could not reach. `cancelInstall(requestId)` stops the run and answers `cancelled` only after pnpm exited and the files are back, `too-late` once the bundle is being applied, and `not-running` for any other id; the install call then reports `application: 'cancelled'`. A run that fails, is cancelled, or adds a package without a bundle patch restores `package.json` and `pnpm-lock.yaml` as they were; `packageResult.kind` classifies the last run from its exit and output, `registries` names every registry asked, and `bundle` names the package a finished run added. `listBundles` carries each bundle's one-liner (the package `description`), the rows its patch declares with their live entries, and the built-in rows it overrides; it lists the profile's own bundles, the bundles the installation supplies, and a selected name without a bundle patch as a `not-bundle` problem, while an unselected plain dependency is left out. A bundle the launcher's `OPTIONAL_BUNDLES` names is `optional`: shipped switched off for the person to turn on, never removable, and selected by no shipped template ([rationale](../../../.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.md)). Every completed operation emits `plugin-manager/changed`; a patch generation applied outside the manager, by HMR's watcher after a CLI or hand edit, announces nothing, so the page learns of it on its next read. +`installBundle` accepts a caller-generated `requestId`, under which `plugin-manager/install-log` streams each pnpm run's output and `plugin-manager/install-state` announces `installing`, `cancelling`, and `applying`; `installing` is announced once per registry asked, with the `attempt`'s registry, position, and the plan's length. The install asks the same plan as the lookup, from `options.registry`, running `pnpm add` with `--registry` and restoring the profile files between attempts; it moves on for the failures another registry can change and stops at any other, and at a failure whose error line names the host a git or tarball spec is fetched from, which no registry stands in for; `failedAt` says which of the two the last failed run could not reach. `cancelInstall(requestId)` stops the run and answers `cancelled` only after the Git check or pnpm exited and the files are back, `too-late` once the bundle is being applied, and `not-running` for any other id; the install call then reports `application: 'cancelled'`. A run that fails, is cancelled, or adds a package without a bundle patch restores `package.json` and `pnpm-lock.yaml` as they were; `packageResult.kind` classifies the last run from its exit and output, `registries` names every registry asked, and `bundle` names the package a finished run added. `listBundles` carries each bundle's one-liner (the package `description`), the rows its patch declares with their live entries, and the built-in rows it overrides; it lists the profile's own bundles, the bundles the installation supplies, and a selected name without a bundle patch as a `not-bundle` problem, while an unselected plain dependency is left out. A bundle the launcher's `OPTIONAL_BUNDLES` names is `optional`: shipped switched off for the person to turn on, never removable, and selected by no shipped template ([rationale](../../../.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.md)). Every completed operation emits `plugin-manager/changed`; a patch generation applied outside the manager, by HMR's watcher after a CLI or hand edit, announces nothing, so the page learns of it on its next read. `waitForInstall(requestId)` lets a client recover a lost response by waiting for the active installation, including its non-cancellable application phase. It returns the same outcome as the original call, or `null` if the request is not active. Completed results are not retained; `null` establishes neither success nor cancellation. @@ -61,10 +63,12 @@ When pnpm 11 blocks dependency scripts, the failed installation reports every pe |---|---|---| | `pnpmCommand` | `pnpm` | The pnpm executable name or path, resolved through `PATH` like the `dsh plugin` command. | | `inspectTimeoutMs` | `20000` | Bound on one registry lookup an inspection runs, in milliseconds. | +| `githubConnectionTimeoutMs` | `5000` | Deadline for the GitHub repository check before installation, in milliseconds. | | `registry` | pnpm's own | The registry lookups and installations ask first, as an http(s) URL; absent, the one pnpm's own configuration names. | | `fallbackRegistries` | `['https://registry.npmmirror.com/']` | Registries asked in turn, as http(s) URLs, while the one before is unreachable or holds no copy of the package; pnpm's own registry joins the order only while it names npm's own registry or one of these. | | `outputBytes` | `16384` | Maximum pnpm diagnostic bytes returned per operation; the full output remains in the returned log path. | | `lockWaitMs` | `120000` | Maximum time in milliseconds to acquire the profile write lock. | +| `idleTimeoutMs` | `600000` | Maximum time in milliseconds a service package run may capture no output before the manager terminates it; a run with inherited descriptors (`dsh plugin`) is never bound. | ----- @@ -74,7 +78,7 @@ When pnpm 11 blocks dependency scripts, the failed installation reports every pe

Implementation internals — click to expand -The service and `dsh plugin` share the package operations in [operations.ts](src/operations.ts). The launcher supplies the current profile; [DSH HMR](../hmr/README.md) serializes module reloads, file watching and management writes. Each refresh re-reads bundle selection and patch layers, updates the original root Include, and awaits removed plugin resources as well as the remaining Loader tree. CLI and service operations share the profile manifest writer lock to prevent concurrent package and manifest writes. HMR does not acquire that lock. Pnpm runs outside the HMR queue; installation selects the bundle after pnpm succeeds, while removal deselects and unloads the bundle before pnpm runs. Dependency-only changes do not trigger configuration reloads. +The service and `dsh plugin` share the package operations in [operations.ts](src/operations.ts). The launcher supplies the current profile; [DSH HMR](../hmr/README.md) serializes module reloads, file watching and management writes. Each refresh re-reads bundle selection and patch layers, updates the original root Include, and awaits removed plugin resources as well as the remaining Loader tree. CLI and service operations share the profile manifest writer lock to prevent concurrent package and manifest writes. HMR does not acquire that lock. Pnpm runs outside the HMR queue; installation selects the bundle after pnpm succeeds, while removal deselects and unloads the bundle before pnpm runs. A service run whose captured output stays silent for `idleTimeoutMs` is terminated, reports `timedOut` alongside its exit status, is classified `timeout` whatever status the signal left behind, and is not retried on the next registry, which bounds how long one operation can hold the profile lock; the CLI inherits the terminal, captures no output, and stays unbounded for its operator to interrupt. A run completes when its process exits, and the pipes then drain under a bounded grace period, so a descendant that inherited them cannot hold the operation open. A terminated run stops its whole process tree and waits for it, because a lifecycle script outlives the pnpm process that started it (issue #4981). Dependency-only changes do not trigger configuration reloads. Results contain the last attempted stage, target, saved-state change, application status and error codes. Web dictionaries render management text; pnpm and Loader diagnostics remain unmodified. Unrelated pre-existing inactive entries return warnings; new or changed failures and inactive explicit enablement targets fail the operation. A failed or cancelled installation restores the manifest and lockfile it snapshotted before pnpm ran ([rationale](../../../.agents/notes/implemented/architecture/2026-09-15-guided-plugin-installation.md)); a failed removal retains its partial changes and diagnostics. Installations are tracked by request id until their call settles, so a cancellation names one run and joins its settlement without taking the profile lock. The CLI inherits authentication variables and terminal descriptors; service operations use a scrubbed environment and captured output. No invariant companion is published because the manager reads files and Loader state directly and owns no independent state projection. diff --git a/packages/boot/plugin-manager/README.zh.md b/packages/boot/plugin-manager/README.zh.md index f7502aab64..122ccc9a27 100644 --- a/packages/boot/plugin-manager/README.zh.md +++ b/packages/boot/plugin-manager/README.zh.md @@ -45,11 +45,13 @@ kind: "package-reference" `inspect(spec, options)` 在任何东西安装之前读出 spec 指向什么:注册表包名通过 `pnpm view` 询问注册表,在 profile 目录中运行,因而与安装使用同样的代理与认证设置;绝对路径读取其 `package.json`;git 地址或 tarball 只答复自己的形式和它被拉取的 `host`。答复携带名称、版本、描述、该包是否声明组合包,以及作答的 `registry`,否则给出 `problem`:`invalid-spec`、`already-installed`、`not-found`、`not-a-package`、`not-a-bundle`、`network` 或 `unknown`,并附上问过的 `registries`。调用方的 `signal` 或 `inspectTimeoutMs` 会结束查询。 +`installBundle` 在启动 pnpm 前通过 `git ls-remote` 检查 GitHub 仓库,使用 profile 目录及安装器的 Git 与代理配置。`githubConnectionTimeoutMs` 默认为 5000 毫秒,只限制这次检查,不限制包下载或构建。检查禁用凭据助手和认证提示;只有网络失败与超时会停止安装,通过现有失败类型与诊断日志返回,并标记 `failedAt: 'spec-host'`。认证、仓库查找及其他失败继续交给 pnpm,包括其 HTTPS 到 SSH 的回退。取消安装或销毁管理器会停止检查及其子进程。注册表包、本地路径、压缩包和其他 Git 主机跳过此检查。仓库可达后,下载或组合包验证仍可能失败。 + 注册表按顺序询问。计划从 `options.registry` 开始,否则从配置的 `registry`(`null` 即 pnpm 自身配置指定的那个)开始,并在一个注册表不可达、超时或答复没有这个包或版本(尚未同步的镜像会如此)时继续问 `fallbackRegistries`。配置集合之外的注册表只问它自己,因而私有源永远不会落到公共源;pnpm 自身的注册表只在它指向的地址(每次做计划前经 `pnpm config get registry` 读出)是 npm 官方源或某个备选源时才算集合成员,否则视为私有源只问它自己。pnpm 自身配置已经指向的注册表只问一次。查询以 `--registry` 运行 `pnpm view` 且不带 pnpm 自身的重试,所以死掉的注册表会在 `inspectTimeoutMs` 内报告并转问下一个;pnpm 把拒绝以 JSON 打印在 stdout,读法与 stderr 相同。安装保留 pnpm 的重试设置。`registries()` 把配置集合和 pnpm 指向的地址答复给选择器。[注册表 Agent Note](../../../.agents/notes/implemented/architecture/2026-09-18-plugin-install-registries.zh.md) 拥有理由。 可在浏览器使用的 `@deepseek-ai/dsh-plugin-manager/registry` 入口导出 `OFFICIAL_NPM_REGISTRY` 和 `NPMMIRROR_REGISTRY`,供消费方识别这两个公共注册表。 -`installBundle` 接受调用方生成的 `requestId`,`plugin-manager/install-log` 在其下流式转发每次 pnpm 运行的输出,`plugin-manager/install-state` 通告 `installing`、`cancelling` 与 `applying`;每问一个注册表通告一次 `installing`,`attempt` 带上这次的注册表、序号和计划长度。安装沿用查询的计划,从 `options.registry` 开始,以 `--registry` 运行 `pnpm add`,两次尝试之间恢复 profile 文件;换一个注册表能改变的失败才转问下一个,其他失败即停,错误行点名 git 或 tarball spec 自身拉取主机的失败也停,因为没有注册表能替代那台主机;`failedAt` 说明最后一次失败的运行连不上的是二者中的哪一个。`cancelInstall(requestId)` 停止运行,只在 pnpm 退出且文件恢复后答复 `cancelled`,组合包已在应用时答复 `too-late`,其他 id 答复 `not-running`;安装调用随后报告 `application: 'cancelled'`。失败、被取消或装入了没有组合包 patch 的包的运行,会把 `package.json` 与 `pnpm-lock.yaml` 恢复原样;`packageResult.kind` 按退出方式与输出对最后一次运行分类,`registries` 列出问过的每个注册表,`bundle` 给出完成的运行新增的包。`listBundles` 携带每个组合包的一句话简介(包的 `description`)、其 patch 声明的行及其存活条目,以及它覆盖的内置行;它列出 profile 自己的组合包、安装提供的组合包,以及被选中却没有组合包 patch 的名字(作为 `not-bundle` 问题),未选中的普通依赖不列出。启动器的 `OPTIONAL_BUNDLES` 点名的组合包是 `optional`:随安装提供、默认关闭、由用户开启,永不可卸载,也不被任何随附模板选中([理由](../../../.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.zh.md))。每个完成的操作都会发出 `plugin-manager/changed`;在管理器之外应用的一代 patch(HMR 监视到 CLI 或手工编辑后)不发通知,页面要到下一次读取才知道。 +`installBundle` 接受调用方生成的 `requestId`,`plugin-manager/install-log` 在其下流式转发每次 pnpm 运行的输出,`plugin-manager/install-state` 通告 `installing`、`cancelling` 与 `applying`;每问一个注册表通告一次 `installing`,`attempt` 带上这次的注册表、序号和计划长度。安装沿用查询的计划,从 `options.registry` 开始,以 `--registry` 运行 `pnpm add`,两次尝试之间恢复 profile 文件;换一个注册表能改变的失败才转问下一个,其他失败即停,错误行点名 git 或 tarball spec 自身拉取主机的失败也停,因为没有注册表能替代那台主机;`failedAt` 说明最后一次失败的运行连不上的是二者中的哪一个。`cancelInstall(requestId)` 停止运行,只在 Git 检查或 pnpm 退出且文件恢复后答复 `cancelled`,组合包已在应用时答复 `too-late`,其他 id 答复 `not-running`;安装调用随后报告 `application: 'cancelled'`。失败、被取消或装入了没有组合包 patch 的包的运行,会把 `package.json` 与 `pnpm-lock.yaml` 恢复原样;`packageResult.kind` 按退出方式与输出对最后一次运行分类,`registries` 列出问过的每个注册表,`bundle` 给出完成的运行新增的包。`listBundles` 携带每个组合包的一句话简介(包的 `description`)、其 patch 声明的行及其存活条目,以及它覆盖的内置行;它列出 profile 自己的组合包、安装提供的组合包,以及被选中却没有组合包 patch 的名字(作为 `not-bundle` 问题),未选中的普通依赖不列出。启动器的 `OPTIONAL_BUNDLES` 点名的组合包是 `optional`:随安装提供、默认关闭、由用户开启,永不可卸载,也不被任何随附模板选中([理由](../../../.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.zh.md))。每个完成的操作都会发出 `plugin-manager/changed`;在管理器之外应用的一代 patch(HMR 监视到 CLI 或手工编辑后)不发通知,页面要到下一次读取才知道。 `waitForInstall(requestId)` 让客户端在响应丢失后等待活动安装的结果,包括不可取消的应用阶段。它返回与原调用相同的结果,请求不在活动中时返回 `null`。已完成的结果不保留;`null` 不表示成功或已取消。 @@ -61,10 +63,12 @@ pnpm 11 拦下依赖脚本时,失败的安装在 `pendingBuilds` 里报告 pro |---|---|---| | `pnpmCommand` | `pnpm` | pnpm 可执行文件名或路径,与 `dsh plugin` 命令一样通过 `PATH` 解析。 | | `inspectTimeoutMs` | `20000` | 单次检查所做注册表查询的上限,单位毫秒。 | +| `githubConnectionTimeoutMs` | `5000` | 安装前 GitHub 仓库连接检查的时限,单位毫秒。 | | `registry` | pnpm 自身配置 | 查询与安装首先询问的注册表,http(s) URL;缺省为 pnpm 自身配置指定的那个。 | | `fallbackRegistries` | `['https://registry.npmmirror.com/']` | 前一个注册表不可达或没有该包副本时依次询问的注册表,http(s) URL;pnpm 自身的注册表只在它指向 npm 官方源或这里的某一个时才进入顺序。 | | `outputBytes` | `16384` | 每次操作返回的 pnpm 诊断字节上限;完整输出保留在返回的日志路径中。 | | `lockWaitMs` | `120000` | 获取 profile 写锁的最长等待毫秒数。 | +| `idleTimeoutMs` | `600000` | service 包操作允许持续无捕获输出的最长毫秒数,达到即被管理器终止;继承描述符运行的 `dsh plugin` 不受此上界约束。 | ----- @@ -74,7 +78,7 @@ pnpm 11 拦下依赖脚本时,失败的安装在 `pendingBuilds` 里报告 pro
实现细节——点击展开 -服务与 `dsh plugin` 共用 [operations.ts](src/operations.ts) 中的包管理操作。启动器提供当前 profile;[DSH HMR](../hmr/README.zh.md) 串行执行模块重载、文件监听和管理写入。每次刷新重新读取组合包选择与 patch 层,更新原有根 Include,并等待已移除插件释放资源及剩余 Loader 树稳定。CLI 与 service 操作共用 profile manifest 写锁,防止并发包操作和 manifest 写入。HMR 不获取该锁。pnpm 在 HMR 队列之外执行;安装在 pnpm 成功后选入组合包,删除则在执行 pnpm 前取消选入并完成卸载。仅依赖字段变化不会触发配置重载。 +服务与 `dsh plugin` 共用 [operations.ts](src/operations.ts) 中的包管理操作。启动器提供当前 profile;[DSH HMR](../hmr/README.zh.md) 串行执行模块重载、文件监听和管理写入。每次刷新重新读取组合包选择与 patch 层,更新原有根 Include,并等待已移除插件释放资源及剩余 Loader 树稳定。CLI 与 service 操作共用 profile manifest 写锁,防止并发包操作和 manifest 写入。HMR 不获取该锁。pnpm 在 HMR 队列之外执行;安装在 pnpm 成功后选入组合包,删除则在执行 pnpm 前取消选入并完成卸载。service 运行若在 `idleTimeoutMs` 内没有任何捕获输出即被终止,与退出状态一并报告 `timedOut`,不论信号留下什么退出状态都归类为 `timeout`,且不再转问下一个注册表,因此单次操作占用 profile 锁的时长有上界;CLI 继承终端、不捕获输出,因此不受此上界约束,由操作者中断。运行以进程退出为完成点,随后只在一个有界的宽限窗口内排空管道,因此继承管道的孙进程无法让操作挂起。被终止的运行会停止整棵进程树并等待其消失,因为生命周期脚本的存活时间超过启动它的 pnpm 进程(issue #4981)。仅依赖字段变化不会触发配置重载。 结果包含最后尝试的阶段、目标、磁盘变化、应用状态和错误码。Web 词典呈现管理文案;pnpm 与 Loader 的诊断保持原样。无关的已有故障作为警告返回;新出现、配置变化后的故障,以及显式启用目标未激活,都会使操作失败。失败或被取消的安装会恢复 pnpm 运行前快照的 manifest 与 lockfile([理由](../../../.agents/notes/implemented/architecture/2026-09-15-guided-plugin-installation.zh.md));失败的删除保留部分改动和诊断。安装按 request id 跟踪到调用结束,因此取消只针对一次运行,并且不取 profile 锁就能等待它结束。CLI 继承认证环境和终端描述符;service 使用清理后的环境并捕获输出。管理器直接读取文件和 Loader 状态,不维护第二份目标状态注册表,因此不发布单独的运行时不变式伴生入口。 diff --git a/packages/boot/plugin-manager/package.json b/packages/boot/plugin-manager/package.json index 292b74a61f..c96579daf0 100644 --- a/packages/boot/plugin-manager/package.json +++ b/packages/boot/plugin-manager/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-plugin-manager", "description": "Current-profile plugin and bundle management shared by dsh CLI, Web and agent tools", - "version": "0.1.7-alpha.1", + "version": "0.1.7-alpha.2", "publishConfig": { "access": "public" }, @@ -56,26 +56,26 @@ ], "license": "MIT", "dependencies": { - "@deepseek-ai/dsh-app-boot": "workspace:^", - "@deepseek-ai/dsh-atomic-write": "workspace:^", - "@deepseek-ai/schemastery": "workspace:^", + "@deepseek-ai/dsh-app-boot": "workspace:*", + "@deepseek-ai/dsh-atomic-write": "workspace:*", + "@deepseek-ai/schemastery": "workspace:~", "execa": "^10.0.0", "yaml": "^2.9.0", "zod": "^4.4.3", - "@deepseek-ai/dsh-util-values": "workspace:^" + "@deepseek-ai/dsh-util-values": "workspace:*" }, "peerDependencies": { - "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/cordis-plugin-loader": "workspace:^", - "@deepseek-ai/dsh-host-plugin-inventory": "workspace:^", - "@deepseek-ai/dsh-subprocess": "workspace:^", - "@deepseek-ai/dsh-typert-protocol": "workspace:^", - "@deepseek-ai/dsh-tools": "workspace:^", - "@deepseek-ai/dsh-hmr": "workspace:^", - "@deepseek-ai/dsh-brand": "workspace:^", - "@deepseek-ai/dsh-sandbox-policy": "workspace:^", - "@deepseek-ai/dsh-sandbox": "workspace:^", - "@deepseek-ai/dsh-user-approval": "workspace:^" + "@deepseek-ai/cordis": "workspace:~", + "@deepseek-ai/cordis-plugin-loader": "workspace:~", + "@deepseek-ai/dsh-host-plugin-inventory": "workspace:*", + "@deepseek-ai/dsh-subprocess": "workspace:*", + "@deepseek-ai/dsh-typert-protocol": "workspace:*", + "@deepseek-ai/dsh-tools": "workspace:*", + "@deepseek-ai/dsh-hmr": "workspace:*", + "@deepseek-ai/dsh-brand": "workspace:*", + "@deepseek-ai/dsh-sandbox-policy": "workspace:*", + "@deepseek-ai/dsh-sandbox": "workspace:*", + "@deepseek-ai/dsh-user-approval": "workspace:*" }, "peerDependenciesMeta": { "@deepseek-ai/dsh-hmr": { @@ -86,22 +86,22 @@ } }, "devDependencies": { - "@deepseek-ai/dsh-package-manifest": "workspace:^", - "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/cordis-plugin-loader": "workspace:^", - "@deepseek-ai/dsh-host-plugin-inventory": "workspace:^", - "@deepseek-ai/dsh-subprocess": "workspace:^", - "@deepseek-ai/dsh-typert-protocol": "workspace:^", - "@deepseek-ai/dsh-tools": "workspace:^", - "@deepseek-ai/dsh-system-prompt": "workspace:^", - "@deepseek-ai/dsh-hmr": "workspace:^", - "@deepseek-ai/cordis-plugin-timer": "workspace:^", - "@deepseek-ai/dsh-brand": "workspace:^", - "@deepseek-ai/dsh-sandbox-policy": "workspace:^", - "@deepseek-ai/dsh-agent": "workspace:^", - "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/dsh-session-projection": "workspace:^", - "@deepseek-ai/dsh-sandbox": "workspace:^", - "@deepseek-ai/dsh-user-approval": "workspace:^" + "@deepseek-ai/dsh-package-manifest": "workspace:*", + "@deepseek-ai/cordis": "workspace:~", + "@deepseek-ai/cordis-plugin-loader": "workspace:~", + "@deepseek-ai/dsh-host-plugin-inventory": "workspace:*", + "@deepseek-ai/dsh-subprocess": "workspace:*", + "@deepseek-ai/dsh-typert-protocol": "workspace:*", + "@deepseek-ai/dsh-tools": "workspace:*", + "@deepseek-ai/dsh-system-prompt": "workspace:*", + "@deepseek-ai/dsh-hmr": "workspace:*", + "@deepseek-ai/cordis-plugin-timer": "workspace:~", + "@deepseek-ai/dsh-brand": "workspace:*", + "@deepseek-ai/dsh-sandbox-policy": "workspace:*", + "@deepseek-ai/dsh-agent": "workspace:*", + "@deepseek-ai/dsh-session": "workspace:*", + "@deepseek-ai/dsh-session-projection": "workspace:*", + "@deepseek-ai/dsh-sandbox": "workspace:*", + "@deepseek-ai/dsh-user-approval": "workspace:*" } } diff --git a/packages/boot/plugin-manager/src/github-connection.ts b/packages/boot/plugin-manager/src/github-connection.ts new file mode 100644 index 0000000000..99b64fd73c --- /dev/null +++ b/packages/boot/plugin-manager/src/github-connection.ts @@ -0,0 +1,68 @@ +/** Bounded GitHub repository checks using the installer's Git configuration and environment. */ +import { mkdir, mkdtemp, open } from 'node:fs/promises' +import { join } from 'node:path' +import { execa } from 'execa' +import { scrubbedParentEnv } from '@deepseek-ai/dsh-subprocess' +import { classifyInstallFailure } from './install-failure.ts' +import type { ParsedInstallSpec } from './install-spec.ts' +import type { PackageResult } from './types.ts' + +/** Deadline, diagnostics and cancellation for one read-only GitHub check. */ +export interface GithubConnectionOptions { + timeoutMs: number + outputBytes: number + signal: AbortSignal + /** The application-owned package manager's environment, also used by its Git children. */ + env?: Readonly> +} + +/** + * Check a GitHub repository before pnpm starts, without downloading or building its package. + * Git reads the profile's Git and proxy configuration without invoking credential helpers or prompting. + * Timeout and cancellation terminate its descendants too; pnpm owns authentication and transport fallback. + * @param spec The parsed installation address; npm packages, local paths and other hosts are not checked. + * @param dir The profile directory where installation runs. + * @param options The connection deadline, output bound and operation cancellation. + * @returns A failed check with bounded output and a complete log at logPath, or undefined for a reachable repository or an unhandled spec. + */ +export async function checkGithubConnection( + spec: ParsedInstallSpec, dir: string, options: GithubConnectionOptions, +): Promise { + if (spec.kind !== 'git') return undefined + const host = spec.host.toLowerCase().replace(/:.*$/, '') + if (host !== 'github.com' && !host.endsWith('.github.com')) return undefined + const repository = spec.spec.replace(/#.*$/s, '') + .replace(/^github:/i, 'https://github.com/') + .replace(/^gist:/i, 'https://gist.github.com/') + .replace(/^git\+/i, '') + const logRoot = join(dir, '.plugin-manager', 'logs') + await mkdir(logRoot, { recursive: true, mode: 0o700 }) + const logDir = await mkdtemp(join(logRoot, 'github-connection-')) + const logPath = join(logDir, 'git.log') + const log = await open(logPath, 'ax+', 0o600) + try { + const result = await execa('git', ['-c', 'credential.helper=', 'ls-remote', '--', repository, 'HEAD'], { + cwd: dir, + env: { + ...scrubbedParentEnv(), ...options.env, LC_ALL: 'C', GIT_TERMINAL_PROMPT: '0', + GIT_ASKPASS: '', SSH_ASKPASS: '', SSH_ASKPASS_REQUIRE: 'never', + }, + extendEnv: false, + stdin: 'ignore', stdout: 'ignore', stderr: { file: logPath, append: true }, buffer: false, reject: false, + timeout: options.timeoutMs, cancelSignal: options.signal, killDescendants: true, killSignal: 'SIGKILL', + }) + if (!result.failed) return undefined + if (result.timedOut) await log.write(`dsh: connection to ${spec.host} timed out after ${String(options.timeoutMs)}ms\n`) + if ((await log.stat()).size === 0) await log.write(result.shortMessage ?? 'GitHub connection check failed') + const { size } = await log.stat() + const bytes = Buffer.alloc(Math.min(size, options.outputBytes)) + await log.read(bytes, 0, bytes.length, size - bytes.length) + const output = bytes.toString('utf8') + return { + exitCode: result.exitCode ?? (result.code === 'ENOENT' ? 127 : 1), output, truncated: size > options.outputBytes, logPath, + kind: classifyInstallFailure({ log: output, timedOut: result.timedOut }), + } + } finally { + await log.close() + } +} diff --git a/packages/boot/plugin-manager/src/index.ts b/packages/boot/plugin-manager/src/index.ts index 876adf9750..818b68ac16 100644 --- a/packages/boot/plugin-manager/src/index.ts +++ b/packages/boot/plugin-manager/src/index.ts @@ -24,6 +24,7 @@ import { attributeFailure, normalizeRegistry, NPMMIRROR_REGISTRY, registryPlan } import { writePluginEnabled } from './patch.ts' import { ManagementFailure } from './failure.ts' import { approveBuilds, readPendingBuilds } from './build-approval.ts' +import { checkGithubConnection } from './github-connection.ts' import type { BundleInfo, BundleRowInfo, ChangeResult, InspectOptions, InstallBundleOptions, ManagementError, PackageResult, PluginChange, PluginEntryId, PluginInfo, PluginInspectProblem, PluginInstallCancellation, PluginInstallProgress, PluginInstallRequestId, @@ -33,16 +34,20 @@ export type * from './types.ts' export { classifyInstallFailure, type InstallFailureFacts } from './install-failure.ts' export { InvalidInstallSpecError, parseInstallSpec, type ParsedInstallSpec } from './install-spec.ts' -/** The pnpm executable, the registries asked, and the limits for package diagnostics and registry lookups. */ +/** The pnpm executable, registries, and limits for diagnostics, lookups and connection checks. */ export interface Config { /** The pnpm executable name or path; resolved through `PATH` like the `dsh plugin` command. */ pnpmCommand?: string - /** Maximum retained pnpm diagnostic bytes per operation. */ + /** Maximum retained package-operation diagnostic bytes. */ outputBytes?: number /** Maximum time to wait for another process's profile package operation. */ lockWaitMs?: number /** Bound on one registry lookup an inspection runs, in milliseconds. */ 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 /** @@ -172,6 +177,8 @@ export class PluginManager extends TypertRemoteService { outputBytes: z.number().step(1).min(1).default(16384), lockWaitMs: z.number().step(1).min(0).default(120000), inspectTimeoutMs: z.number().step(1).min(1000).default(20000), + githubConnectionTimeoutMs: z.number().step(1).min(1000).default(5000), + idleTimeoutMs: z.number().step(1).min(1000).default(600000), registry: z.string().pattern(REGISTRY_URL), fallbackRegistries: z.array(z.string().pattern(REGISTRY_URL)).default([NPMMIRROR_REGISTRY]), }) @@ -183,6 +190,8 @@ export class PluginManager extends TypertRemoteService { private readonly outputBytes: number private readonly lockWaitMs: number private readonly inspectTimeoutMs: number + private readonly githubConnectionTimeoutMs: number + private readonly idleTimeoutMs: number private readonly pnpmCommand: string private readonly configuredRegistries: Omit private readonly ownerContext: Context @@ -199,6 +208,8 @@ export class PluginManager extends TypertRemoteService { this.outputBytes = (config as Required).outputBytes this.lockWaitMs = (config as Required).lockWaitMs this.inspectTimeoutMs = (config as Required).inspectTimeoutMs + this.githubConnectionTimeoutMs = (config as Required).githubConnectionTimeoutMs + this.idleTimeoutMs = (config as Required).idleTimeoutMs this.pnpmCommand = (config as Required).pnpmCommand this.configuredRegistries = { registry: config.registry === undefined ? null : normalizeRegistry(config.registry), @@ -406,7 +417,9 @@ export class PluginManager extends TypertRemoteService { } /** - * Install a package using the same pnpm implementation as dsh plugin. A run + * Install a package using the same pnpm implementation as dsh plugin. GitHub + * repositories get a connection check bounded by githubConnectionTimeoutMs before pnpm starts; + * only network failures or timeouts stop installation, while pnpm owns authentication and transport fallback. A run * that fails, is cancelled, or adds a package without a bundle patch restores * `package.json` and `pnpm-lock.yaml` as they were; downloaded files can stay. * @param spec One package spec, including local paths relative to the invocation directory. @@ -418,7 +431,7 @@ export class PluginManager extends TypertRemoteService { installBundle(spec: string, options?: InstallBundleOptions): Promise { const requestId = options?.requestId const control: InstallControl = { abort: new AbortController(), phase: 'installing', result: Promise.resolve(null) } - const stopped = (): boolean => control.abort.signal.aborted + const stopped = (): boolean => control.abort.signal.aborted || this.abort.signal.aborted if (requestId !== undefined) this.installs.set(requestId, control) const announce = (phase: PluginInstallProgress['phase'], attempt?: PluginInstallProgress['attempt']): void => { if (requestId !== undefined) this.ownerContext.emit('plugin-manager/install-state', { requestId, phase, ...attempt === undefined ? {} : { attempt } }) @@ -432,12 +445,27 @@ export class PluginManager extends TypertRemoteService { } const files = await this.readRestoredFiles() const before = readProfileManifest('dsh', this.profile.dir).dependencies ?? {} - const plan = registryPlan(options?.registry, await this.registries()) let name: string try { - // The last run is the result's; the registries asked stay listed whatever the outcome. - let run: PackageResult | undefined result.registries = [] + const connection = checkGithubConnection(parsedForRegistry(spec), this.profile.dir, { + timeoutMs: this.githubConnectionTimeoutMs, outputBytes: this.outputBytes, + signal: AbortSignal.any([this.abort.signal, control.abort.signal]), + ...this.profile.packageManager?.env === undefined ? {} : { env: this.profile.packageManager.env }, + }) + this.packageOperations.add(connection) + let connectionFailure: PackageResult | undefined + try { connectionFailure = await connection } + finally { this.packageOperations.delete(connection) } + if (stopped()) throw new InstallCancelledError() + if (connectionFailure?.kind === 'network' || connectionFailure?.kind === 'timeout') { + result.packageResult = connectionFailure + result.failedAt = 'spec-host' + throw new Error(connectionFailure.output) + } + // The last run is the result's; the registries asked stay listed whatever the outcome. + const plan = registryPlan(options?.registry, await this.registries()) + let run: PackageResult | undefined for (const [index, registry] of plan.entries()) { if (index > 0) await this.restoreFiles(files) // A stop that landed while the files went back, or before the first run, starts no run with a dead signal. @@ -447,18 +475,24 @@ export class PluginManager extends TypertRemoteService { run = await this.runPnpm(['add', spec, ...registryArguments(registry)], control.abort.signal, requestId) result.packageResult = run if (stopped()) throw new InstallCancelledError() - /* v8 ignore next 2 -- runPnpm classifies every failed run, so kind is never absent here */ - if (run.exitCode === 0 || run.kind === undefined) break - const failedAt = attributeFailure(run.kind, run.output, parsedForRegistry(spec)) + // A run this manager terminated is not a success, even when pnpm trapped the signal and exited 0. + if (run.exitCode === 0 && run.timedOut !== true) break + /* v8 ignore next 2 -- runPnpm classifies every run it does not report as succeeded */ + if (run.kind === undefined) break // What the last failed run could not reach; a later run that succeeds leaves nothing to say. delete result.failedAt + // A run this manager terminated got no answer from the registry at all, so no registry explains it. + if (run.timedOut === true) break + const failedAt = attributeFailure(run.kind, run.output, parsedForRegistry(spec)) if (failedAt !== 'other') result.failedAt = failedAt if (failedAt !== 'registry' || index === plan.length - 1) break } /* v8 ignore next -- the plan is never empty, so a run always settled */ if (run === undefined) throw new Error('no registry was asked') - if (run.exitCode === 0) delete result.failedAt - if (run.exitCode !== 0) { + // A terminated run reports no usable exit status, so neither its files nor its bundle are trusted. + const succeeded = run.exitCode === 0 && run.timedOut !== true + if (succeeded) delete result.failedAt + if (!succeeded) { // pnpm-workspace.yaml is not restored, so the names pnpm left undecided there can be offered for approval. try { result.pendingBuilds = await readPendingBuilds(this.profile.dir) } catch (error) { @@ -509,7 +543,7 @@ export class PluginManager extends TypertRemoteService { /** Stop an installation this manager owns and wait until its files are back. * @param requestId The id the installation was started with. - * @returns `cancelled` once pnpm exited and the files are restored, `too-late` once the bundle is being + * @returns `cancelled` once the Git check or pnpm exited and the files are restored, `too-late` once the bundle is being * applied, `not-running` for any other id. */ @Remote @@ -550,7 +584,9 @@ export class PluginManager extends TypertRemoteService { } }) result.packageResult = await this.runPnpm(['remove', name]) - if (result.packageResult.exitCode !== 0) throw new Error(result.packageResult.output) + if (result.packageResult.exitCode !== 0 || result.packageResult.timedOut === true) { + throw new Error(result.packageResult.output) + } }, { stage: 'remove', target: name }, 'remove') } @@ -597,7 +633,7 @@ export class PluginManager extends TypertRemoteService { const task = runProfilePnpm({ ...this.profile, profile: this.profile.name }, args, { execution: 'service', ...this.profile.packageManager ?? { command: this.pnpmCommand }, signal: signal === undefined ? this.abort.signal : AbortSignal.any([this.abort.signal, signal]), - outputBytes: this.outputBytes, activateNewBundles: false, + outputBytes: this.outputBytes, activateNewBundles: false, idleTimeoutMs: this.idleTimeoutMs, onOutput: (text, stream) => { this.ownerContext.emit('plugin-manager/install-log', { ...identity, jobId, argv, cwd, stream, text }) }, @@ -608,7 +644,12 @@ export class PluginManager extends TypertRemoteService { this.ownerContext.emit('plugin-manager/install-log', { ...identity, jobId, argv, cwd, stream: 'stdout', text: '', exitCode: signal?.aborted === true ? null : result.exitCode, }) - return result.exitCode === 0 ? result : { ...result, kind: classifyInstallFailure({ log: result.output }) } + // A terminated run keeps its own kind even when pnpm trapped the signal and exited 0. + if (result.exitCode === 0 && result.timedOut !== true) return result + return { + ...result, + kind: classifyInstallFailure({ log: result.output, ...result.timedOut === true ? { timedOut: true } : {} }), + } } catch (error) { this.ownerContext.emit('plugin-manager/install-log', { ...identity, jobId, argv, cwd, stream: 'stderr', text: messageOf(error), exitCode: null }) throw error diff --git a/packages/boot/plugin-manager/src/install-failure.ts b/packages/boot/plugin-manager/src/install-failure.ts index 47a68c920a..009eeb1ad9 100644 --- a/packages/boot/plugin-manager/src/install-failure.ts +++ b/packages/boot/plugin-manager/src/install-failure.ts @@ -25,6 +25,7 @@ const LOG_KINDS: readonly [PluginInstallFailureKind, RegExp][] = [ ['disk-full', /\bENOSPC\b|no space left on device/i], ['permission', /\bEACCES\b|\bEPERM\b|permission denied/i], ['integrity', /ERR_PNPM_TARBALL_INTEGRITY|ERR_PNPM_BAD_TARBALL_SIZE|\bEINTEGRITY\b/], + ['unknown', /The requested URL returned error: 40[134]\b/], ['network', /\bENOTFOUND\b|\bECONNRESET\b|\bETIMEDOUT\b|\bECONNREFUSED\b|\bEAI_AGAIN\b|ERR_PNPM_META_FETCH_FAIL|ERR_PNPM_FETCH_5\d\d|ERR_PNPM_FETCH_TIMEOUT|\bFETCH_ERROR\b|socket hang up|Could not resolve host|unable to access/], ] diff --git a/packages/boot/plugin-manager/src/operations.ts b/packages/boot/plugin-manager/src/operations.ts index 883e79713e..817a4e2219 100644 --- a/packages/boot/plugin-manager/src/operations.ts +++ b/packages/boot/plugin-manager/src/operations.ts @@ -1,4 +1,5 @@ /** Shared profile package operations used by dsh plugin and the running manager. */ +import { once } from 'node:events' import { existsSync } from 'node:fs' import { mkdir, mkdtemp, open } from 'node:fs/promises' import { join, resolve } from 'node:path' @@ -9,6 +10,7 @@ import { resolveBundleDir, resolveProfileDir, loadOverlayPatches, type ProfileManifest, } from '@deepseek-ai/dsh-app-boot' import { scrubbedParentEnv } from '@deepseek-ai/dsh-subprocess' +import { awaitTreeGone, leadsOwnGroup } from './run-tree.ts' import type { PackageResult, Registry } from './types.ts' /** Profile and invocation locations supplied by the launcher. */ @@ -36,6 +38,13 @@ export interface PackageOperationOptions { onOutput?: (text: string, stream: 'stdout' | 'stderr') => void activateNewBundles?: boolean lockWaitMs?: number + /** + * Terminate the run once its captured output has been silent for this long, in + * milliseconds. A run stopped this way reports `timedOut`; without a bound, a + * child that stops progressing without exiting holds its caller forever. A run + * with inherited descriptors captures nothing and is never bound. + */ + idleTimeoutMs?: number } /** Resolve relative package specs against the caller's directory. @@ -96,11 +105,39 @@ async function reconcile(before: ProfileManifest, dir: string, anchor: string, o await saveManifest(dir, after) } +/** + * How long the pipes keep draining after their process exited, as a fixed part of + * finishing a run rather than a deployment knob: a descendant that inherited them + * holds them open, and the tail a failure classification reads is written by then. + */ +const DRAIN_AFTER_EXIT_MS = 2_000 + +/** Whether every collector finished within `ms`. + * @param collectors The pipe readers racing the bound. + * @param ms The longest wait, in milliseconds. + * @returns True when all collectors settled in time. + */ +async function drainWithin(collectors: readonly Promise[], ms: number): Promise { + if (collectors.length === 0) return true + let timer: NodeJS.Timeout | undefined + try { + return await Promise.race([ + Promise.allSettled(collectors).then(() => true), + new Promise((resolve) => { + timer = setTimeout(() => { resolve(false) }, ms) + }), + ]) + } finally { + clearTimeout(timer) + } +} + /** Execute pnpm inside a profile whose caller already holds the profile write lock. * @param context Launcher-owned profile and resolution locations. * @param args Pnpm arguments, before relative path anchoring. * @param options Output, activation and cancellation policy. - * @returns Exit status and diagnostic path; service output is bounded, CLI output uses inherited descriptors. + * @returns Exit status, whether the silence bound stopped the run, and the diagnostic path. + * Service output is bounded; CLI output uses inherited descriptors. */ export async function runProfilePnpm( context: PackageOperationContext, args: readonly string[], options: PackageOperationOptions, @@ -115,53 +152,131 @@ export async function runProfilePnpm( let output = Buffer.alloc(0) let truncated = false const cancellation = new AbortController() + // A service run captures output, so execa terminates the tree it leads when the + // run is killed: a lifecycle script outlives the pnpm process that started it. + // The CLI keeps the caller's process group, so an interrupt still reaches it. + const grouped = leadsOwnGroup(options.execution) const child = execa(options.command ?? 'pnpm', [...options.args ?? [], ...args.map(arg => anchorPathSpec(arg, context.cwd))], { cwd: dir, env: { ...(options.execution === 'cli' ? process.env : scrubbedParentEnv()), ...options.env }, extendEnv: false, reject: false, stdout: options.execution === 'cli' ? 'inherit' : 'pipe', stderr: options.execution === 'cli' ? 'inherit' : 'pipe', + killDescendants: options.execution === 'service', buffer: false, stdin: options.execution === 'cli' ? 'inherit' : 'ignore', cancelSignal: options.signal === undefined ? cancellation.signal : AbortSignal.any([cancellation.signal, options.signal]), }) + const append = (bytes: Buffer): void => { + output = Buffer.concat([output, bytes]) + if (output.length > options.outputBytes) { + truncated = true + output = output.subarray(output.length - options.outputBytes) + } + } let writes = Promise.resolve() + /** `settled` records that the process outcome is known; `stalled` that the silence bound stopped the run. */ + const control = { settled: false, stalled: false } + /** Set once this call cuts the reading short itself, so the close it causes is not read as a run failure. */ + let cut = false + /** The first failure a reading hit before that cut, which the run still reports. */ + let failure: Error | undefined + let idleTimer: NodeJS.Timeout | undefined + /** The silence bound: a captured run that stops printing without exiting is terminated, never awaited. */ + const armIdle = (): void => { + if (control.settled || options.idleTimeoutMs === undefined) return + clearTimeout(idleTimer) + idleTimer = setTimeout(() => { + control.stalled = true + // execa's kill reaches the whole tree of a service run, and escalates on its own. + child.kill() + }, options.idleTimeoutMs) + } const collect = async (stream: AsyncIterable, kind: 'stdout' | 'stderr') => { try { for await (const chunk of stream) { + armIdle() const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk) writes = writes.then(async () => { await log.write(bytes) }) await writes options.onOutput?.(bytes.toString('utf8'), kind) - output = Buffer.concat([output, bytes]) - if (output.length > options.outputBytes) { - truncated = true - output = output.subarray(output.length - options.outputBytes) - } + append(bytes) } } catch (error) { - cancellation.abort() + // A reading this call cut short is not a failure the run hit, and the run has + // already exited, so there is nothing left for the cancellation to stop. + if (!cut) { + failure ??= error instanceof Error ? error : new Error(String(error)) + cancellation.abort() + } throw error } } + const collectors = [ + ...child.stdout === null ? [] : [collect(child.stdout, 'stdout')], + ...child.stderr === null ? [] : [collect(child.stderr, 'stderr')], + ] + // The drain decides whether a failure surfaces, so each reading is claimed now: + // an unclaimed rejection would be reported as unhandled while the pipes drain. + for (const collector of collectors) void collector.catch(() => { /* reported through `failure` and the settled readings */ }) + // Inherited descriptors hand pnpm the terminal, so there is no captured output + // for a silence bound to observe: it would fire on a healthy run. + if (collectors.length > 0) armIdle() let exitCode: number try { - const [completion, ...streams] = await Promise.allSettled([child, - ...child.stdout === null ? [] : [collect(child.stdout, 'stdout')], - ...child.stderr === null ? [] : [collect(child.stderr, 'stderr')], - ]) - for (const stream of streams) if (stream.status === 'rejected') throw stream.reason + // execa resolves its promise only once the piped stdio has ended, so the + // process's own exit — the run's completion — is read from the raw child. A + // spawn failure settles without one. + const settled = Promise.allSettled([child]) + await Promise.race([once(child.nodeChildProcess, 'exit').catch(() => undefined), settled]) + control.settled = true + clearTimeout(idleTimer) + // A stalled run stops its whole tree first, so the caller's rollback and lock + // release happen only after the scripts it started stopped writing. + if (control.stalled) await awaitTreeGone({ pid: child.pid, grouped }) + // A descendant that inherited the pipes can hold them open past the process; + // the tail drains under a bound instead of being awaited forever. + const drained = await drainWithin(collectors, DRAIN_AFTER_EXIT_MS) + if (drained) { + for (const stream of await Promise.allSettled(collectors)) if (stream.status === 'rejected') throw stream.reason + } else { + // Cutting the tail short is this call's own end, not a failure the run hit; + // a failure from before the cut still surfaces, and the cut leaves a notice + // in the log because a classification may read an incomplete tail. + cut = true + child.stdout?.destroy() + child.stderr?.destroy() + const notice = 'dsh: pnpm output was cut short after its process exited\n' + await log.write(notice) + // A failure from before the cut is the run's own and replaces the notice a + // caller would otherwise read; a rejection the cut itself causes is its end. + if (failure !== undefined) throw failure + options.onOutput?.(notice, 'stderr') + append(Buffer.from(notice)) + } + const [completion] = await settled if (completion.status === 'rejected') throw completion.reason const result = completion.value exitCode = result.exitCode ?? (result.code === 'ENOENT' ? 127 : 1) + if (control.stalled) { + const notice = `dsh: pnpm printed nothing for ${String(options.idleTimeoutMs)}ms and was terminated\n` + await log.write(notice) + options.onOutput?.(notice, 'stderr') + append(Buffer.from(notice)) + } if (result.failed && output.length === 0) { const diagnostic = result.shortMessage ?? 'pnpm failed' await log.write(diagnostic) truncated = Buffer.byteLength(diagnostic) > options.outputBytes output = Buffer.from(diagnostic).subarray(0, options.outputBytes) } - if (exitCode === 0 && options.activateNewBundles !== false) await reconcile(before, dir, context.installAnchor, options) + // A terminated run's exit status says nothing about what it wrote, so it never reconciles the selection. + if (exitCode === 0 && !control.stalled && options.activateNewBundles !== false) { + await reconcile(before, dir, context.installAnchor, options) + } } finally { + control.settled = true + clearTimeout(idleTimer) await log.close() } - return { exitCode, output: output.toString('utf8'), truncated, logPath } + return { exitCode, output: output.toString('utf8'), truncated, logPath, ...control.stalled ? { timedOut: true } : {} } } /** Initialize and run the dsh plugin command with the same write lock as the service. diff --git a/packages/boot/plugin-manager/src/run-tree.ts b/packages/boot/plugin-manager/src/run-tree.ts new file mode 100644 index 0000000000..bce2f4d872 --- /dev/null +++ b/packages/boot/plugin-manager/src/run-tree.ts @@ -0,0 +1,70 @@ +/** Waiting for one package run's process tree to disappear before its caller touches the profile. */ + +/** How long a terminated tree may take to disappear before the caller stops waiting. */ +const TREE_WAIT_MS = 5_000 + +/** Poll cadence while waiting for a terminated tree to disappear. */ +const TREE_POLL_MS = 15 + +/** The process (or process group) one run occupies. */ +export interface RunTree { + /** The run's process id, when its spawn published one. */ + pid: number | undefined + /** Whether the run leads its own process group, which is what a POSIX group probe addresses. */ + grouped: boolean +} + +/** Injectable process operations, so every platform's probing is testable on any host. */ +export interface RunTreeInternals { + /** Host platform override for target decisions. */ + platform?: NodeJS.Platform + /** Liveness probe override, defaulting to `process.kill(target, 0)`. */ + alive?: (target: number) => boolean + /** Wait bound override, defaulting to the tree wait constant. */ + waitMs?: number +} + +/** + * Whether one run leads its own process group, which is the target a POSIX + * liveness probe addresses for the whole tree. A run that captures output is + * spawned as its own group leader, because its tree is terminated as a unit; a + * run that inherits the caller's descriptors keeps the caller's group, so an + * interrupt still reaches it. + * @param execution Whether the run captures output or inherits the caller's descriptors. + * @param platform Host platform deciding how a tree is addressed. + * @returns True when the run leads its own process group. + */ +export function leadsOwnGroup(execution: 'cli' | 'service', platform: NodeJS.Platform = process.platform): boolean { + return execution === 'service' && platform !== 'win32' +} + +/** The target a tree probe addresses: a POSIX group when the run leads one, else the process itself. */ +function targetOf(pid: number, grouped: boolean, platform: NodeJS.Platform): number { + return platform !== 'win32' && grouped ? -pid : pid +} + +/** + * Wait until a terminated run's tree is gone, so the caller restores and + * unlocks the profile only after the scripts it started stopped writing. + * @param tree The run's process id and whether it leads its own group. + * @param internals Injectable process operations. + * @returns Fulfillment once no member remains, or the wait bound elapsed. + */ +export async function awaitTreeGone(tree: RunTree, internals: RunTreeInternals = {}): Promise { + const pid = tree.pid + if (pid === undefined) return + const platform = internals.platform ?? process.platform + const alive = internals.alive ?? ((target: number) => { process.kill(target, 0); return true }) + const remaining = (): boolean => { + try { + return alive(targetOf(pid, tree.grouped, platform)) + } catch { + return false + } + } + const deadline = Date.now() + (internals.waitMs ?? TREE_WAIT_MS) + while (remaining()) { + if (Date.now() >= deadline) return + await new Promise(resolve => setTimeout(resolve, TREE_POLL_MS)) + } +} diff --git a/packages/boot/plugin-manager/src/types.ts b/packages/boot/plugin-manager/src/types.ts index 7743b4f35f..e544d53d7d 100644 --- a/packages/boot/plugin-manager/src/types.ts +++ b/packages/boot/plugin-manager/src/types.ts @@ -69,7 +69,7 @@ export interface PluginRegistries { readonly resolved: string | null } -/** How a pnpm run failed, read off how it ended and what it printed. */ +/** How a package operation failed, read off how it ended and what it printed. */ export type PluginInstallFailureKind = | 'pnpm-missing' | 'timeout' @@ -82,7 +82,7 @@ export type PluginInstallFailureKind = | 'integrity' | 'unknown' -/** Pnpm completion, including a retrieval path for unabridged diagnostics. */ +/** Package operation completion, including Git checks and a retrieval path for unabridged diagnostics. */ export interface PackageResult { exitCode: number output: string @@ -90,6 +90,8 @@ export interface PackageResult { logPath: string /** Present when the run failed: what kind of failure its exit and output describe. */ kind?: PluginInstallFailureKind + /** The manager terminated the run after it printed nothing for its silence bound; `exitCode` still reports how it ended. */ + timedOut?: boolean } /** Persisted change and independently observed application outcome. */ diff --git a/packages/boot/plugin-manager/tests/github-connection.spec.ts b/packages/boot/plugin-manager/tests/github-connection.spec.ts new file mode 100644 index 0000000000..673fb7c733 --- /dev/null +++ b/packages/boot/plugin-manager/tests/github-connection.spec.ts @@ -0,0 +1,183 @@ +/** Real Git transport deadlines, process cleanup, and bounded connection diagnostics. */ +import { once } from 'node:events' +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { appendFileSync } from 'node:fs' +import { createServer, type Socket } from 'node:net' +import { createServer as createHttpServer } from 'node:http' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import * as subprocess from 'execa' +import { expect, it, onTestFinished, vi } from 'vitest' +import { checkGithubConnection } from '../src/github-connection.ts' +import { parseInstallSpec } from '../src/install-spec.ts' + +vi.mock('execa', async (original) => { + const module = await original() + return { ...module, execa: vi.fn(module.execa) } +}) + +async function fixture() { + const dir = await mkdtemp(join(tmpdir(), 'dsh-github-check-')) + onTestFinished(() => rm(dir, { recursive: true, force: true })) + const controller = new AbortController() + const tasks: Promise[] = [] + onTestFinished(async () => { controller.abort(); await Promise.allSettled(tasks) }) + const config = join(dir, 'git.config') + await writeFile(config, '') + const env = { GIT_CONFIG_GLOBAL: config, GIT_CONFIG_NOSYSTEM: '1' } + const check = (spec: string, timeoutMs = 3000, outputBytes = 4096) => { + const task = checkGithubConnection(parseInstallSpec(spec), dir, { + timeoutMs, outputBytes, signal: controller.signal, + env, + }) + tasks.push(task) + return task + } + return { dir, config, controller, check, env } +} + +async function proxy(config: string, fail: boolean) { + const sockets = new Set() + const connected = Promise.withResolvers() + const disconnected = Promise.withResolvers() + const server = createServer((socket) => { + sockets.add(socket) + socket.on('error', () => {}) + socket.once('close', () => { sockets.delete(socket); disconnected.resolve(undefined) }) + socket.once('data', () => { connected.resolve(undefined); if (fail) socket.destroy() }) + }) + onTestFinished(async () => { + for (const socket of sockets) socket.destroy() + await new Promise((resolve, reject) => { server.close((error) => { if (error) reject(error); else resolve() }) }) + }) + server.listen(0, '127.0.0.1') + await once(server, 'listening') + const address = server.address() + if (address === null || typeof address === 'string') throw new Error('proxy did not bind a TCP port') + await writeFile(config, `[http "https://github.com/"]\n proxy = http://127.0.0.1:${String(address.port)}\n`) + return { connected: connected.promise, disconnected: disconnected.promise, sockets } +} + +it('leaves registry packages and other Git hosts to the installer', async () => { + const { check, dir } = await fixture() + expect(await check('@acme/plugin')).toBeUndefined() + expect(await check('https://gitlab.com/acme/plugin.git')).toBeUndefined() + await expect(readFile(join(dir, '.plugin-manager', 'logs'))).rejects.toMatchObject({ code: 'ENOENT' }) +}) + +it.each([ + ['https://github.com/acme/plugin.git#main', 'https://github.com/'], + ['github:acme/plugin#main', 'https://github.com/'], + ['git+https://github.com/acme/plugin.git#main', 'https://github.com/'], + ['git+ssh://git@github.com:22/acme/plugin.git#main', 'ssh://git@github.com:22/'], + ['git@github.com:acme/plugin.git#main', 'git@github.com:'], + ['gist:acme/plugin#main', 'https://gist.github.com/'], +])('checks %s through the profile Git configuration without downloading a package', async (spec, prefix) => { + const { check, dir, config } = await fixture() + const repository = join(dir, 'empty.git') + await subprocess.execa('git', ['init', '--bare', repository]) + // Match both forms exactly; a fragment reaching Git would fail to resolve this repository. + await writeFile(config, `[url "${pathToFileURL(repository).href}"]\n insteadOf = ${prefix}acme/plugin.git\n insteadOf = ${prefix}acme/plugin\n`) + expect(await check(spec)).toBeUndefined() +}) + +it('reports an immediate transport failure without waiting for the deadline', async () => { + const { check, config } = await fixture() + const transport = await proxy(config, true) + const result = await check('https://github.com/acme/plugin.git') + await transport.connected + expect(result).toMatchObject({ exitCode: 128, kind: 'network', truncated: false }) + expect(await readFile(result!.logPath, 'utf8')).toBe(result!.output) + await transport.disconnected + expect(transport.sockets.size).toBe(0) +}) + +it('ends a silent proxy connection and its Git child at the connection deadline', async () => { + const { check, config } = await fixture() + const transport = await proxy(config, false) + const running = check('https://github.com/acme/plugin.git') + await transport.connected + const result = await running + expect(result?.kind).toBe('timeout') + expect(result!.output).toContain('connection to github.com timed out after 3000ms') + await transport.disconnected + expect(transport.sockets.size).toBe(0) +}) + +it('cancels a connected Git probe and closes its transport before the deadline', async () => { + const { check, config, controller } = await fixture() + const transport = await proxy(config, false) + const running = check('https://github.com/acme/plugin.git', 60000) + await transport.connected + controller.abort() + const result = await running + expect(result?.kind).toBe('unknown') + await transport.disconnected + expect(transport.sockets.size).toBe(0) +}) + +it.each([undefined, 'Git could not start'])('retains a diagnostic when Git writes no stderr (%s)', async (shortMessage) => { + const { check } = await fixture() + const run = vi.spyOn(subprocess, 'execa').mockResolvedValue({ failed: true, shortMessage } as Awaited>) + onTestFinished(() => { run.mockRestore() }) + const result = await check('github:acme/plugin') + expect(result).toMatchObject({ exitCode: 1, kind: 'unknown', output: shortMessage ?? 'GitHub connection check failed' }) + expect(await readFile(result!.logPath, 'utf8')).toBe(result!.output) +}) + +it('bounds returned diagnostics while retaining Git stderr in the operation log', async () => { + const { check } = await fixture() + const run = vi.spyOn(subprocess, 'execa').mockImplementation((...args: unknown[]) => { + const options = args[2] as { stderr: { file: string } } + appendFileSync(options.stderr.file, '0123456789') + return Promise.resolve({ failed: true, exitCode: 128 }) as ReturnType + }) + onTestFinished(() => { run.mockRestore() }) + const result = await check('github:acme/plugin', 3000, 4) + expect(result).toMatchObject({ output: '6789', truncated: true }) + expect(await readFile(result!.logPath, 'utf8')).toBe('0123456789') +}) + +it('reports a missing Git executable without classifying it as a network failure', async () => { + const { check } = await fixture() + const run = vi.spyOn(subprocess, 'execa').mockResolvedValue({ failed: true, code: 'ENOENT', shortMessage: 'spawn git ENOENT' } as Awaited>) + onTestFinished(() => { run.mockRestore() }) + expect(await check('github:acme/plugin')).toMatchObject({ exitCode: 127, kind: 'unknown', output: 'spawn git ENOENT' }) +}) + +it('does not invoke configured credential helpers or askpass for an authentication challenge', async () => { + const { check, dir, config, env } = await fixture() + const server = createHttpServer((_request, response) => { + response.writeHead(401, { 'WWW-Authenticate': 'Basic realm="fixture"' }) + response.end() + }) + onTestFinished(async () => { + server.closeAllConnections() + await new Promise((resolve, reject) => { server.close((error) => { if (error) reject(error); else resolve() }) }) + }) + server.listen(0, '127.0.0.1') + await once(server, 'listening') + const address = server.address() + if (address === null || typeof address === 'string') throw new Error('server did not bind a TCP port') + const marker = join(dir, 'prompted') + const helper = join(dir, 'credential-helper.cjs') + await writeFile(helper, `require('node:fs').appendFileSync(${JSON.stringify(marker)}, process.argv.slice(2).join(' ') + '\\n')`) + const helperCommand = `"${process.execPath.replaceAll('\\', '/')}" "${helper.replaceAll('\\', '/')}"` + const askpass = join(dir, 'askpass.sh') + await writeFile(askpass, `#!/bin/sh\nexec ${helperCommand} "$@"\n`, { mode: 0o700 }) + await writeFile(config, `[url "http://127.0.0.1:${String(address.port)}/"]\n insteadOf = https://github.com/\n[http]\n proxy =\n`) + await subprocess.execa('git', ['config', '--file', config, 'credential.helper', `!${helperCommand}`]) + await subprocess.execa('git', ['config', '--file', config, 'core.askPass', askpass]) + Object.assign(env, { GIT_ASKPASS: askpass, SSH_ASKPASS: askpass }) + // The same Git configuration really prompts without the probe's noninteractive overrides. + await subprocess.execa('git', ['ls-remote', '--', 'https://github.com/acme/private.git', 'HEAD'], { + cwd: dir, env: { ...env, GIT_TERMINAL_PROMPT: '0' }, reject: false, + }) + const prompted = await readFile(marker, 'utf8') + expect(prompted).toContain('get') + expect(prompted).toContain('Username') + await rm(marker) + expect(await check('github:acme/private.git')).toMatchObject({ exitCode: 128, kind: 'unknown' }) + await expect(readFile(marker)).rejects.toMatchObject({ code: 'ENOENT' }) +}) diff --git a/packages/boot/plugin-manager/tests/install-spec.spec.ts b/packages/boot/plugin-manager/tests/install-spec.spec.ts index c45d461e67..10a4a76398 100644 --- a/packages/boot/plugin-manager/tests/install-spec.spec.ts +++ b/packages/boot/plugin-manager/tests/install-spec.spec.ts @@ -56,6 +56,10 @@ describe('parseInstallSpec', () => { }) describe('classifyInstallFailure', () => { + it.each([401, 403, 404])('leaves Git HTTP %s responses out of connection recovery', (status) => { + expect(classifyInstallFailure({ log: `fatal: unable to access 'https://github.com/acme/private.git/': The requested URL returned error: ${String(status)}` })).toBe('unknown') + }) + it('names the run\'s end before reading its output, then the most specific code in the output', () => { expect(classifyInstallFailure({ log: 'ENOSPC', timedOut: true })).toBe('timeout') expect(classifyInstallFailure({ log: '', cause: Object.assign(new Error('spawn pnpm ENOENT'), { code: 'ENOENT' }) })).toBe('pnpm-missing') diff --git a/packages/boot/plugin-manager/tests/manager.spec.ts b/packages/boot/plugin-manager/tests/manager.spec.ts index 16fbed9650..d80755bba2 100644 --- a/packages/boot/plugin-manager/tests/manager.spec.ts +++ b/packages/boot/plugin-manager/tests/manager.spec.ts @@ -1,9 +1,13 @@ /** Persistent manager behavior through a real profile Include and Loader. */ import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { realpath } from 'node:fs/promises' +import { once } from 'node:events' +import { createServer } from 'node:http' +import type { Socket } from 'node:net' import { join } from 'node:path' import { tmpdir } from 'node:os' import { fileURLToPath, pathToFileURL } from 'node:url' +import { execa } from 'execa' import type { Context } from '@deepseek-ai/cordis' import { expect, it, onTestFinished, vi } from 'vitest' import { @@ -17,6 +21,7 @@ import Timer from '@deepseek-ai/cordis-plugin-timer' import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include' import { Group } from '@deepseek-ai/cordis-plugin-loader' import * as operations from '../src/operations.ts' +import * as githubConnection from '../src/github-connection.ts' import { parse, parseDocument } from 'yaml' async function fixture(reload: 'live' | 'startup' = 'live', overlay = false, prepare?: (ctx: Context) => void, config: Config = {}, packageManager?: ProfileContext['packageManager'], prepareFiles?: (dir: string) => void) { @@ -60,7 +65,8 @@ async function fixture(reload: 'live' | 'startup' = 'live', overlay = false, pre }) // What pnpm's own configuration names is read from pnpm before every registry plan; the fixture answers npm's own registry. const registry = vi.spyOn(operations, 'readProfileRegistry').mockResolvedValue('https://registry.npmjs.org/') - onTestFinished(() => { registry.mockRestore() }) + const connection = vi.spyOn(githubConnection, 'checkGithubConnection').mockResolvedValue(undefined) + onTestFinished(() => { registry.mockRestore(); connection.mockRestore() }) let stopHmr = async () => {} if (reload === 'live') { await ctx.plugin(Timer) @@ -68,9 +74,155 @@ async function fixture(reload: 'live' | 'startup' = 'live', overlay = false, pre stopHmr = () => owner.dispose() await ctx.hmr.runExclusive(async () => {}) } - return { ctx, dir, manager: ctx.pluginManager, bundle, profile, stopHmr, overlays } + return { ctx, dir, manager: ctx.pluginManager, bundle, profile, stopHmr, overlays, connection } } +it.each(['network', 'timeout'] as const)('stops a GitHub %s before pnpm and attributes it to the repository', async (kind) => { + const { manager, dir, connection } = await fixture() + const before = readFileSync(join(dir, 'package.json'), 'utf8') + const failure = { exitCode: 1, output: 'GitHub check failed', truncated: false, logPath: join(dir, 'git.log'), kind } + connection.mockResolvedValue(failure) + const pnpm = vi.spyOn(operations, 'runProfilePnpm') + onTestFinished(() => { pnpm.mockRestore() }) + expect(await manager.installBundle('https://github.com/acme/dsh-plugin.git')).toMatchObject({ + application: 'failed', changed: false, stage: 'install', failedAt: 'spec-host', packageResult: failure, + }) + expect(pnpm).not.toHaveBeenCalled() + expect(connection).toHaveBeenCalledWith(expect.objectContaining({ host: 'github.com' }), dir, expect.objectContaining({ timeoutMs: 5000 })) + expect(readFileSync(join(dir, 'package.json'), 'utf8')).toBe(before) +}) + +it('leaves non-network GitHub errors to pnpm and forwards profile Git environment and deadline', async () => { + const env = { GIT_CONFIG_GLOBAL: '/application/git.config' } + const { manager, connection } = await fixture('live', false, undefined, { githubConnectionTimeoutMs: 8000 }, { command: 'pnpm', args: [], env }) + connection.mockResolvedValue({ exitCode: 128, output: 'fatal: Authentication failed', truncated: false, logPath: 'git.log', kind: 'unknown' }) + const failure = { exitCode: 1, output: 'pnpm owns authentication', truncated: false, logPath: 'pnpm.log', kind: 'unknown' as const } + const pnpm = vi.spyOn(operations, 'runProfilePnpm').mockResolvedValue(failure) + onTestFinished(() => { pnpm.mockRestore() }) + const result = await manager.installBundle('github:acme/private-plugin') + expect(result.application).toBe('failed') + expect(result.packageResult).toEqual(failure) + expect(pnpm).toHaveBeenCalledOnce() + expect(result.failedAt).toBeUndefined() + expect(connection).toHaveBeenCalledWith(expect.anything(), expect.anything(), expect.objectContaining({ timeoutMs: 8000, env })) +}) + +it('cancels an active GitHub check before starting pnpm', async () => { + const { manager, connection } = await fixture() + const entered = Promise.withResolvers() + connection.mockImplementation(async (_spec, _dir, options) => { + entered.resolve(undefined) + await new Promise((resolve) => { options.signal.addEventListener('abort', () => { resolve() }, { once: true }) }) + return { exitCode: 1, output: 'cancelled', truncated: false, logPath: 'git.log', kind: 'unknown' } + }) + const pnpm = vi.spyOn(operations, 'runProfilePnpm') + onTestFinished(() => { pnpm.mockRestore() }) + const requestId = '824103ec-bc45-489d-bb85-5b4fe0aefc78' as PluginInstallRequestId + const installing = manager.installBundle('github:acme/dsh-plugin', { requestId }) + await entered.promise + expect(await manager.cancelInstall(requestId)).toEqual({ status: 'cancelled' }) + expect(await installing).toMatchObject({ application: 'cancelled', changed: false }) + expect(pnpm).not.toHaveBeenCalled() +}) + +it('waits for the GitHub check before installing and activating the bundle', async () => { + const { manager, dir, bundle, connection } = await fixture() + const entered = Promise.withResolvers() + const release = Promise.withResolvers() + connection.mockImplementation(async () => { entered.resolve(undefined); await release.promise; return undefined }) + const pnpm = vi.spyOn(operations, 'runProfilePnpm').mockImplementation(async () => { + bundle('addon', []) + const manifest = readProfileManifest('test', dir) + manifest.dependencies = { ...manifest.dependencies, addon: '1.0.0' } + writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest)) + return { exitCode: 0, output: 'installed', truncated: false, logPath: join(dir, 'pnpm.log') } + }) + const installing = manager.installBundle('github:acme/addon') + onTestFinished(async () => { release.resolve(undefined); await installing; pnpm.mockRestore() }) + await entered.promise + expect(pnpm).not.toHaveBeenCalled() + release.resolve(undefined) + expect(await installing).toMatchObject({ application: 'applied', changed: true, bundle: 'addon' }) + expect(readProfileManifest('test', dir).dsh?.profile?.bundles).toContain('addon') +}) + +it('disposal waits for an active GitHub check to stop', async () => { + const { ctx, manager, connection } = await fixture('startup') + const entered = Promise.withResolvers() + const released = Promise.withResolvers() + connection.mockImplementation(async (_spec, _dir, options) => { + entered.resolve(undefined) + await new Promise((resolve) => { options.signal.addEventListener('abort', () => { resolve() }, { once: true }) }) + released.resolve(undefined) + return { exitCode: 1, output: 'cancelled', truncated: false, logPath: 'git.log', kind: 'unknown' } + }) + const pnpm = vi.spyOn(operations, 'runProfilePnpm') + onTestFinished(() => { pnpm.mockRestore() }) + const installing = manager.installBundle('github:acme/addon') + await entered.promise + await ctx.fiber.dispose() + await released.promise + expect(await installing).toMatchObject({ application: 'cancelled', changed: false }) + expect(pnpm).not.toHaveBeenCalled() +}) + +it.each(['github:acme/connected', 'https://github.com/acme/connected.git', 'git+ssh://git@github.com/acme/connected.git'])( + 'installs %s through real Git and pnpm with private repository SSH fallback', async (spec) => { + const pnpm = fileURLToPath(new URL('../../../../apps/desktop/node_modules/pnpm/bin/pnpm.mjs', import.meta.url)) + const env = { GIT_CONFIG_GLOBAL: '', GIT_CONFIG_NOSYSTEM: '1' } + const { manager, dir, connection } = await fixture('startup', false, undefined, {}, { + command: process.execPath, args: ['--expose-internals', pnpm], env, + }) + connection.mockRestore() + const repository = join(dir, 'repository') + mkdirSync(repository) + env.GIT_CONFIG_GLOBAL = join(dir, 'git.config') + writeFileSync(env.GIT_CONFIG_GLOBAL, '') + const git = (args: string[]) => execa('git', args, { cwd: repository, env }) + await git(['init', '--initial-branch=main']) + const name = '@test/github-connected' + writeFileSync(join(repository, 'package.json'), JSON.stringify({ name, version: '1.0.0', dsh: { bundle: { patch: './cordis.patch.yml' } } })) + writeFileSync(join(repository, 'cordis.patch.yml'), '[]\n') + await git(['add', '.']) + await git(['-c', 'user.name=Fixture', '-c', 'user.email=fixture@example.invalid', '-c', 'commit.gpgsign=false', 'commit', '-m', 'fixture bundle']) + const requests: string[] = [] + const sockets = new Set() + const proxy = createServer((_request, response) => { + response.writeHead(401, { 'WWW-Authenticate': 'Basic realm="fixture"' }) + response.end() + }) + proxy.on('connection', (socket) => { + sockets.add(socket) + socket.once('close', () => { sockets.delete(socket) }) + }) + proxy.on('connect', (request, socket) => { + requests.push(request.url ?? '') + socket.end('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n') + }) + onTestFinished(async () => { + for (const socket of sockets) socket.destroy() + await new Promise((resolve, reject) => { proxy.close((error) => { if (error) reject(error); else resolve() }) }) + }) + proxy.listen(0, '127.0.0.1') + await once(proxy, 'listening') + const address = proxy.address() + if (address === null || typeof address === 'string') throw new Error('proxy did not bind a TCP port') + const proxyUrl = `http://127.0.0.1:${String(address.port)}` + writeFileSync(env.GIT_CONFIG_GLOBAL, `[url "${pathToFileURL(repository).href}"]\n insteadOf = ssh://git@github.com/acme/connected.git\n insteadOf = git@github.com:acme/connected.git\n[url "${proxyUrl}/"]\n insteadOf = https://github.com/\n[http]\n proxy =\n`) + // pnpm probes HTTPS with Node before falling back to SSH, even for an SSH spec. + // Route that HTTP request to our proxy too; Git's URL rewrite alone cannot isolate it. + const manifest = readProfileManifest('test', dir) + delete manifest.dependencies + writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest)) + writeFileSync(join(dir, '.npmrc'), `store-dir=${join(dir, 'store').replaceAll('\\', '/')}\nhttps-proxy=${proxyUrl}\nproxy=${proxyUrl}\nnoproxy=\n`) + const result = await manager.installBundle(spec, { enabled: false }) + expect(result.error).toBeUndefined() + expect(result).toMatchObject({ changed: true, bundle: name, packageResult: { exitCode: 0 } }) + expect(readProfileManifest('test', dir).dependencies).toHaveProperty(name) + expect(requests).toContain('github.com:443') + }, +) + it.each(['missing package', 'invalid manifest', 'not a bundle', 'missing patch', 'invalid patch'])( 'boots with %s, lists its error, and permits deselection without enabling the broken bundle', async (failure) => { const { manager, dir, ctx } = await fixture('live', false, undefined, {}, undefined, (dir) => { @@ -430,6 +582,38 @@ it('unloads before removing packages and retries inactive dependencies whose fil expect((await manager.listBundles()).some(row => row.name === 'extra')).toBe(false) }) +it('treats a terminated run that trapped the signal and exited zero as a failure', async () => { + const { manager, dir } = await fixture() + const install = vi.spyOn(operations, 'runProfilePnpm').mockImplementation(async () => { + // pnpm rewrote the manifest before it trapped the manager's signal. + const manifest = readProfileManifest('test', dir) + manifest.dependencies = { ...manifest.dependencies, half: '1.0.0' } + writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest)) + return { exitCode: 0, output: 'installing\n', truncated: false, logPath: join(dir, 'pnpm.log'), timedOut: true } + }) + onTestFinished(() => { install.mockRestore() }) + const result = await manager.installBundle('half', { enabled: false }) + expect(result).toMatchObject({ application: 'failed', packageResult: { exitCode: 0, timedOut: true, kind: 'timeout' } }) + // The half-written dependency goes back, and nothing is activated. + expect(readProfileManifest('test', dir).dependencies).toEqual({ extra: '1.0.0' }) + expect((await manager.listBundles()).some(row => row.name === 'half')).toBe(false) +}) + +it('reports a terminated removal as failed even when pnpm exited zero', async () => { + const { manager, dir } = await fixture() + const remove = vi.spyOn(operations, 'runProfilePnpm').mockImplementation(async () => { + // The unload already happened, so the removal is reported through its own run. + const manifest = readProfileManifest('test', dir) + delete manifest.dependencies?.extra + writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest)) + return { exitCode: 0, output: 'removing\n', truncated: false, logPath: join(dir, 'pnpm.log'), timedOut: true } + }) + onTestFinished(() => { remove.mockRestore() }) + expect(await manager.removeBundle('extra')).toMatchObject({ + application: 'failed', packageResult: { exitCode: 0, timedOut: true, kind: 'timeout' }, + }) +}) + it('restores the manifest and lockfile after a failed package run, classifying the failure', async () => { const { manager, dir } = await fixture() const lockPath = join(dir, 'pnpm-lock.yaml') @@ -1050,6 +1234,46 @@ it('installs from the next registry after one is unreachable, restoring the file expect(chunks.at(-1)).toMatchObject({ requestId, argv: ['pnpm', 'add', 'fallen', `--registry=${MIRROR}`], exitCode: 0 }) }) +it('does not ask the next registry after the manager terminated a silent run', async () => { + const { manager } = await fixture() + const install = vi.spyOn(operations, 'runProfilePnpm').mockResolvedValue({ + exitCode: 1, output: 'installing\ndsh: pnpm printed nothing for 600000ms and was terminated\n', + truncated: false, logPath: '/dev/null', timedOut: true, + }) + onTestFinished(() => { install.mockRestore() }) + const stalled = await manager.installBundle('silent') + expect(stalled).toMatchObject({ application: 'failed', registries: [null], packageResult: { kind: 'timeout', timedOut: true } }) + expect(stalled.failedAt).toBeUndefined() + expect(install).toHaveBeenCalledTimes(1) +}) + +it('drops the earlier registry failure once a later attempt is terminated for silence', async () => { + const { manager } = await fixture() + const install = vi.spyOn(operations, 'runProfilePnpm') + .mockResolvedValueOnce({ + exitCode: 1, output: 'ERR_PNPM_META_FETCH_FAIL GET https://registry.npmjs.org/flaky: ETIMEDOUT', + truncated: false, logPath: '/dev/null', + }) + .mockResolvedValueOnce({ + exitCode: 1, output: 'installing\ndsh: pnpm printed nothing for 600000ms and was terminated\n', + truncated: false, logPath: '/dev/null', timedOut: true, + }) + onTestFinished(() => { install.mockRestore() }) + const stalled = await manager.installBundle('flaky') + expect(stalled).toMatchObject({ application: 'failed', registries: [null, MIRROR], packageResult: { kind: 'timeout', timedOut: true } }) + expect(stalled.failedAt).toBeUndefined() + expect(install).toHaveBeenCalledTimes(2) +}) + +it('bounds each package run with the configured silence timeout', async () => { + const { manager } = await fixture('live', false, undefined, { idleTimeoutMs: 1234 }) + const install = vi.spyOn(operations, 'runProfilePnpm') + .mockResolvedValue({ exitCode: 1, output: 'plain failure', truncated: false, logPath: '/dev/null' }) + onTestFinished(() => { install.mockRestore() }) + await manager.installBundle('quiet') + expect(install.mock.calls[0]?.[2]).toMatchObject({ idleTimeoutMs: 1234 }) +}) + it('stops at a failure no registry changes, at a host the spec itself is fetched from, and after a registry outside the configured set', async () => { const { manager } = await fixture() const failing = (output: string) => ({ exitCode: 1, output, truncated: false, logPath: '/dev/null' }) diff --git a/packages/boot/plugin-manager/tests/operations-process.spec.ts b/packages/boot/plugin-manager/tests/operations-process.spec.ts new file mode 100644 index 0000000000..d4ad5f28ce --- /dev/null +++ b/packages/boot/plugin-manager/tests/operations-process.spec.ts @@ -0,0 +1,141 @@ +/** The bounded drain and the tree stop are measured against real child processes. */ +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { setTimeout as sleep } from 'node:timers/promises' +import { describe, expect, it, onTestFinished } from 'vitest' +import { initProfile } from '@deepseek-ai/dsh-app-boot' +import { runProfilePnpm, type PackageOperationOptions } from '../src/operations.ts' + +/** Holds the inherited pipes until its stop file appears, and ends on its own after a minute. */ +const HOLDER_SCRIPT = ` +const { existsSync } = require('node:fs') +setInterval(() => { if (existsSync(process.env.DSH_DESCENDANT_STOP_FILE)) process.exit(0) }, 50) +setTimeout(() => process.exit(0), 60000) +` + +/** Writes a marker after the run is over, which only a stopped tree can prevent. */ +const LATE_SCRIPT = ` +setTimeout(() => { require('node:fs').writeFileSync(process.env.DSH_LATE_FILE, 'late') }, 1500) +setTimeout(() => process.exit(0), 60000) +` + +/** + * A child that exits immediately while its descendant inherits both pipes and + * outlives it, so execa's promise cannot settle before the bounded drain. It + * runs from a file because Windows cannot carry a nested `-e` argument. + */ +const DRAIN_CHILD = ` +const { spawn } = require('node:child_process') +const { writeFileSync } = require('node:fs') +const descendant = spawn(process.execPath, [process.env.DSH_DESCENDANT_SCRIPT], { + stdio: ['ignore', 'inherit', 'inherit'], +}) +writeFileSync(process.env.DSH_DESCENDANT_PID_FILE, String(descendant.pid)) +console.log('installed') +setTimeout(() => process.exit(0), 50) +` + +/** A child that starts a descendant and then never exits, so only the silence bound ends the run. */ +const STALLED_CHILD = ` +const { spawn } = require('node:child_process') +const { writeFileSync } = require('node:fs') +const descendant = spawn(process.execPath, [process.env.DSH_DESCENDANT_SCRIPT], { + stdio: ['ignore', 'inherit', 'inherit'], +}) +writeFileSync(process.env.DSH_DESCENDANT_PID_FILE, String(descendant.pid)) +console.log('installing') +setTimeout(() => process.exit(0), 60000) +` + +/** Read the descendant's published pid, waiting for the write a loaded runner can delay. */ +async function readPid(pidFile: string): Promise { + for (let attempt = 0; attempt < 200; attempt += 1) { + if (existsSync(pidFile)) { + const pid = Number(readFileSync(pidFile, 'utf8')) + if (pid > 0) return pid + } + await sleep(25) + } + throw new Error('the descendant published no process id') +} + +/** Wait until a descendant this spec killed is really gone, so its directory can be removed. */ +async function awaitGone(pid: number): Promise { + for (let attempt = 0; attempt < 200; attempt += 1) { + try { process.kill(pid, 0) } catch { return /* the descendant is gone */ } + await sleep(25) + } +} + +/** A profile whose package run is `child`, with its descendant stopped during teardown. */ +function fixture(child: string, descendant: string) { + const home = mkdtempSync(join(tmpdir(), 'manager-run-')) + const dir = join(home, 'profiles', 'test') + const installAnchor = join(home, 'package.json') + writeFileSync(installAnchor, '{}\n') + initProfile(dir, []) + const pidFile = join(home, 'descendant.pid') + const stopFile = join(home, 'descendant.stop') + const scriptFile = join(home, 'descendant.cjs') + const lateFile = join(home, 'late.marker') + writeFileSync(scriptFile, descendant) + onTestFinished(async () => { + // The stop file ends a surviving descendant on its own; the kill only shortens the wait. + writeFileSync(stopFile, '') + if (existsSync(pidFile)) { + const pid = Number(readFileSync(pidFile, 'utf8')) + try { process.kill(pid, 'SIGKILL') } catch { /* the descendant exited with its own timer */ } + await awaitGone(pid) + } + rmSync(home, { recursive: true, force: true }) + }) + const options: PackageOperationOptions = { + command: process.execPath, args: ['-e', child], execution: 'service', outputBytes: 1000, + activateNewBundles: false, + env: { + DSH_DESCENDANT_PID_FILE: pidFile, DSH_DESCENDANT_STOP_FILE: stopFile, + DSH_DESCENDANT_SCRIPT: scriptFile, DSH_LATE_FILE: lateFile, + }, + } + return { context: { home, profile: 'test', installAnchor, cwd: home }, pidFile, lateFile, options } +} + +/** + * Windows cannot stage these fixtures: there a descendant neither keeps the + * inherited pipes open past the direct child's exit nor leads a group the run + * can stop, so the drain's premise holds on POSIX alone. The drain itself is + * platform-independent, and the mocked-child cases in operations.spec.ts cover + * it everywhere. + */ +describe.skipIf(process.platform === 'win32')('a run with descendants', () => { + it('settles a run whose pipes a descendant keeps open', async () => { + const { context, pidFile, options } = fixture(DRAIN_CHILD, HOLDER_SCRIPT) + const outcome = await runProfilePnpm(context, ['add', 'held'], options) + const descendant = await readPid(pidFile) + expect(outcome).toMatchObject({ exitCode: 0 }) + expect(outcome.output).toContain('dsh: pnpm output was cut short after its process exited') + // The descendant still holds the pipes, so the run settled under its drain bound instead of waiting for their end. + expect(() => process.kill(descendant, 0)).not.toThrow() + }) + + it('surfaces an output consumer failure that the bounded drain cuts short', async () => { + const { context, options } = fixture(DRAIN_CHILD, HOLDER_SCRIPT) + const run = runProfilePnpm(context, ['add', 'held'], { + ...options, + onOutput() { throw new Error('output destination closed') }, + }) + await expect(run).rejects.toThrow('output destination closed') + }) + + it('stops the whole tree of a stalled run before returning', async () => { + const { context, pidFile, lateFile, options } = fixture(STALLED_CHILD, LATE_SCRIPT) + const outcome = await runProfilePnpm(context, ['add', 'stalled'], { ...options, idleTimeoutMs: 300 }) + const descendant = await readPid(pidFile) + expect(outcome).toMatchObject({ timedOut: true }) + // The descendant is gone, so the marker it would have written 1.5s in never appears. + expect(() => process.kill(descendant, 0)).toThrow() + await sleep(2_000) + expect(existsSync(lateFile)).toBe(false) + }) +}) diff --git a/packages/boot/plugin-manager/tests/operations.spec.ts b/packages/boot/plugin-manager/tests/operations.spec.ts index d63f3374ab..190f567cf9 100644 --- a/packages/boot/plugin-manager/tests/operations.spec.ts +++ b/packages/boot/plugin-manager/tests/operations.spec.ts @@ -1,4 +1,5 @@ /** The CLI and manager share package reconciliation, path anchoring and diagnostics. */ +import { EventEmitter } from 'node:events' import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { join, resolve } from 'node:path' import { tmpdir } from 'node:os' @@ -7,7 +8,23 @@ import { expect, it, onTestFinished, vi } from 'vitest' import { initProfile, readProfileManifest } from '@deepseek-ai/dsh-app-boot' import { anchorPathSpec, readProfileRegistry, runPluginCommand, runProfilePnpm, viewProfilePackage } from '../src/operations.ts' -const command = vi.hoisted(() => ({ run: vi.fn<(...args: unknown[]) => ReturnType>() })) +/** What execa resolves for a run that settled. */ +interface FakeOutcome { + exitCode: number | undefined + failed: boolean + code?: string + shortMessage?: string +} + +/** The child execa hands back, including the descriptors an inherited CLI run reports as null. */ +interface FakeChild { + stdout: PassThrough | null + stderr: PassThrough | null + nodeChildProcess: EventEmitter + kill?: () => boolean +} + +const command = vi.hoisted(() => ({ run: vi.fn<(...args: unknown[]) => Promise & FakeChild>() })) vi.mock('execa', () => ({ execa: (...args: unknown[]) => command.run(...args) })) function fixture() { @@ -26,13 +43,68 @@ function result( ) { const stdout = new PassThrough() const stderr = new PassThrough() + const raw = new EventEmitter() const done = Promise.resolve().then(() => { mutate() stdout.end(output) stderr.end() return { exitCode, failed: exitCode !== 0, ...details } }) - return Object.assign(done, { stdout, stderr }) + // A launch failure reports on the raw child, which never emits an exit; anything else exits once execa's promise settles. + // Both arrive on a macrotask, so a fixture built before the run still reaches the listener the run attaches. + if (details.code === undefined) { + void done.then(() => { setTimeout(() => { raw.emit('exit', exitCode, null) }, 0) }, () => {}) + } else { + setTimeout(() => { raw.emit('error', Object.assign(new Error('pnpm failed to launch'), { code: details.code })) }, 0) + } + return Object.assign(done, { stdout, stderr, nodeChildProcess: raw }) +} + +/** A child that printed once and never exits on its own: only the silence bound can end it. */ +function silentChild(output: string, exitCode?: number) { + const stdout = new PassThrough() + const stderr = new PassThrough() + const raw = new EventEmitter() + const exit = Promise.withResolvers<{ exitCode: number | undefined; failed: boolean }>() + const kill = () => { + stdout.end() + stderr.end() + raw.emit('exit', exitCode ?? null, 'SIGTERM') + exit.resolve({ exitCode, failed: exitCode !== 0 }) + return true + } + stdout.write(output) + return Object.assign(exit.promise, { stdout, stderr, kill, nodeChildProcess: raw }) +} + +/** A child that exited while its pipes stay open, as a descendant that inherited them leaves them. */ +function heldPipeChild(output: string) { + const stdout = new PassThrough() + const stderr = new PassThrough() + const raw = new EventEmitter() + const exit = Promise.withResolvers<{ exitCode: number; failed: boolean }>() + let ended = 0 + // execa settles only once both pipes end, which the bounded drain forces by destroying them. + for (const stream of [stdout, stderr]) { + stream.on('close', () => { + ended += 1 + if (ended === 2) exit.resolve({ exitCode: 0, failed: false }) + }) + } + // The process exits as soon as the run watches the raw child, so a fixture the + // test built before the run cannot emit past the listener the run attaches. + let watched = false + const watch = (): void => { + if (watched) return + watched = true + setTimeout(() => { raw.emit('exit', 0, null) }, 0) + } + const on = raw.on.bind(raw) + const once = raw.once.bind(raw) + raw.on = (event, listener) => { const holder = on(event, listener); if (event === 'exit') watch(); return holder } + raw.once = (event, listener) => { const holder = once(event, listener); if (event === 'exit') watch(); return holder } + stdout.write(output) + return Object.assign(exit.promise, { stdout, stderr, nodeChildProcess: raw }) } function install(dir: string, name: string) { @@ -107,6 +179,72 @@ it('initializes missing profiles under the same lock and reports initialization' expect(messages.filter(text => text.includes('initialized profile'))).toHaveLength(2) }) +it('terminates a run that stopped printing and reports the silence bound', async () => { + const { context } = fixture() + command.run.mockImplementationOnce(() => silentChild('installing\n')) + const outcome = await runProfilePnpm(context, ['add', 'silent'], { execution: 'service', outputBytes: 100, idleTimeoutMs: 20 }) + expect(outcome).toMatchObject({ exitCode: 1, timedOut: true }) + expect(outcome.output).toBe('installing\ndsh: pnpm printed nothing for 20ms and was terminated\n') + expect(readFileSync(outcome.logPath, 'utf8')).toBe(outcome.output) +}) + +it('settles a run whose pipes stay open past the process exit', async () => { + const { context } = fixture() + const child = heldPipeChild('installed\n') + command.run.mockImplementationOnce(() => child) + const outcome = await runProfilePnpm(context, ['add', 'held'], { + execution: 'service', outputBytes: 100, activateNewBundles: false, + }) + expect(outcome).toMatchObject({ exitCode: 0 }) + expect(outcome.output).toBe('installed\ndsh: pnpm output was cut short after its process exited\n') + // The pipes never ended on their own, so the run settled by cutting them here. + expect(child.stdout.destroyed).toBe(true) + expect(child.stderr.destroyed).toBe(true) + expect(outcome.timedOut).toBeUndefined() +}) + +it('reports a terminated run that trapped the signal and exited zero', async () => { + const { context } = fixture() + command.run.mockImplementationOnce(() => silentChild('installing\n', 0)) + const outcome = await runProfilePnpm(context, ['add', 'trapped'], { execution: 'service', outputBytes: 100, idleTimeoutMs: 20 }) + // The independent facts stay separate: the signal was trapped, so the exit status is still zero. + expect(outcome).toMatchObject({ exitCode: 0, timedOut: true }) + expect(outcome.output).toContain('printed nothing for 20ms and was terminated') +}) + +it('surfaces an output consumer failure the bounded drain cuts short', async () => { + const { context } = fixture() + command.run.mockImplementationOnce(() => heldPipeChild('installed\n')) + await expect(runProfilePnpm(context, ['add', 'held'], { + execution: 'service', outputBytes: 100, activateNewBundles: false, + onOutput() { throw new Error('output destination closed') }, + })).rejects.toThrow('output destination closed') +}) + +it('reports a reading that fails with something other than an Error by its text', async () => { + const { context } = fixture() + const child = heldPipeChild('installed\n') + command.run.mockImplementationOnce(() => child) + await expect(runProfilePnpm(context, ['add', 'held'], { + execution: 'service', outputBytes: 100, activateNewBundles: false, + // A reading can reject with any value; its text is what the failure reports. + onOutput() { throw 'pipe broke' }, + })).rejects.toThrow('pipe broke') +}) + +it('terminates a service run as a tree and leaves the CLI in the caller group', async () => { + const { context } = fixture() + command.run.mockImplementation(() => result(0, '')) + await runProfilePnpm(context, ['add', 'tree'], { execution: 'service', outputBytes: 100, activateNewBundles: false }) + await runProfilePnpm(context, ['add', 'tree'], { execution: 'cli', outputBytes: 100, activateNewBundles: false }) + expect(command.run).toHaveBeenCalledWith( + expect.anything(), expect.anything(), expect.objectContaining({ killDescendants: true }), + ) + expect(command.run).toHaveBeenLastCalledWith( + expect.anything(), expect.anything(), expect.objectContaining({ killDescendants: false }), + ) +}) + it('retains built-in layers, removes deleted dependencies and warns about plain packages', async () => { const { context, dir } = fixture() install(dir, 'removed') @@ -172,7 +310,9 @@ it('preserves an unexpected subprocess rejection after both streams settle', asy const stderr = new PassThrough() stdout.end() stderr.end() - command.run.mockImplementationOnce(() => Object.assign(Promise.reject(new Error('subprocess failed')), { stdout, stderr })) + command.run.mockImplementationOnce(() => Object.assign(Promise.reject(new Error('subprocess failed')), { + stdout, stderr, nodeChildProcess: new EventEmitter(), + })) await expect(runProfilePnpm(context, ['root'], { execution: 'service', outputBytes: 100 })).rejects.toThrow('subprocess failed') }) @@ -207,8 +347,8 @@ it.each(['cli', 'service'] as const)('uses the %s environment and interaction po it('settles inherited CLI descriptors without requiring captured streams', async () => { const { context } = fixture() command.run.mockImplementationOnce(() => Object.assign( - Promise.resolve({ exitCode: 0, failed: false }), { stdout: null, stderr: null }, - ) as unknown as ReturnType) + Promise.resolve({ exitCode: 0, failed: false }), { stdout: null, stderr: null, nodeChildProcess: new EventEmitter() }, + )) expect(await runPluginCommand(context, ['approve-builds'], { execution: 'cli', outputBytes: 100 })).toMatchObject({ exitCode: 0, output: '' }) }) diff --git a/packages/boot/plugin-manager/tests/run-tree.spec.ts b/packages/boot/plugin-manager/tests/run-tree.spec.ts new file mode 100644 index 0000000000..0d72b672b2 --- /dev/null +++ b/packages/boot/plugin-manager/tests/run-tree.spec.ts @@ -0,0 +1,47 @@ +/** Waiting for one run's tree is decided by platform and target, without starting a real process. */ +import { expect, it } from 'vitest' +import { awaitTreeGone, leadsOwnGroup, type RunTree } from '../src/run-tree.ts' + +/** One run's process or process group. */ +function tree(pid: number | undefined, grouped = true): RunTree { + return { pid, grouped } +} + +it.each([ + ['service', 'linux', true], + ['service', 'darwin', true], + ['service', 'win32', false], + ['cli', 'linux', false], + ['cli', 'win32', false], +] as const)('decides group leadership for %s on %s', (execution, platform, expected) => { + expect(leadsOwnGroup(execution, platform)).toBe(expected) +}) + +it('waits for a tree that disappears on its own', async () => { + let alive = true + await expect(awaitTreeGone(tree(42), { + platform: 'linux', + alive: () => { const first = alive; alive = false; return first }, + })).resolves.toBeUndefined() +}) + +it('probes the group a run leads and the process it does not', async () => { + const probes: number[] = [] + const gone = { alive: (target: number) => { probes.push(target); return false } } + await expect(awaitTreeGone(tree(42), { ...gone, platform: 'linux' })).resolves.toBeUndefined() + await expect(awaitTreeGone(tree(42, false), { ...gone, platform: 'linux' })).resolves.toBeUndefined() + await expect(awaitTreeGone(tree(42), { ...gone, platform: 'win32' })).resolves.toBeUndefined() + expect(probes).toEqual([-42, 42, 42]) +}) + +it('returns at once for a tree with no process id or one already gone', async () => { + await expect(awaitTreeGone(tree(undefined), { alive: () => { throw new Error('never probed') } })).resolves.toBeUndefined() + // The host platform answers for a run that names none, and the default probe reports a vanished process. + await expect(awaitTreeGone(tree(99_999_999, false))).resolves.toBeUndefined() + await expect(awaitTreeGone(tree(99_999_999, false), { platform: 'linux' })).resolves.toBeUndefined() +}) + +it('gives up on a tree that never disappears', async () => { + // The wait bound is an input, so this needs no global clock: fake timers leak into the suites sharing the worker. + await expect(awaitTreeGone(tree(42), { platform: 'linux', alive: () => true, waitMs: 0 })).resolves.toBeUndefined() +}) diff --git a/packages/browser-use/browser-use/package.json b/packages/browser-use/browser-use/package.json index 6ce71c831b..02974247ef 100644 --- a/packages/browser-use/browser-use/package.json +++ b/packages/browser-use/browser-use/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-browser-use", "description": "Exclusive named browser-use provider registration", - "version": "0.1.7-alpha.1", + "version": "0.1.7-alpha.2", "publishConfig": { "access": "public" }, @@ -32,11 +32,11 @@ ], "license": "MIT", "peerDependencies": { - "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-brand": "workspace:^" + "@deepseek-ai/cordis": "workspace:~", + "@deepseek-ai/dsh-brand": "workspace:*" }, "devDependencies": { - "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-brand": "workspace:^" + "@deepseek-ai/cordis": "workspace:~", + "@deepseek-ai/dsh-brand": "workspace:*" } } diff --git a/packages/bundle/acp-app/package.json b/packages/bundle/acp-app/package.json index 238e70fb36..fd79e0747f 100644 --- a/packages/bundle/acp-app/package.json +++ b/packages/bundle/acp-app/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-acp-app", "description": "The dsh ACP profile bundle: automation-only JSON-RPC stdio and process lifecycle over dsh-base", - "version": "0.1.7-alpha.1", + "version": "0.1.7-alpha.2", "publishConfig": { "access": "public" }, @@ -34,15 +34,15 @@ } }, "dependencies": { - "@deepseek-ai/dsh-acp": "workspace:^", - "@deepseek-ai/dsh-cmdline": "workspace:^", + "@deepseek-ai/dsh-acp": "workspace:*", + "@deepseek-ai/dsh-cmdline": "workspace:*", "commander": "^15.0.0" }, "peerDependencies": { - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:~" }, "devDependencies": { - "@deepseek-ai/cordis-plugin-include": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis-plugin-include": "workspace:~", + "@deepseek-ai/cordis": "workspace:~" } } diff --git a/packages/bundle/base/package.json b/packages/bundle/base/package.json index 7364ea5268..9bac1d952d 100644 --- a/packages/bundle/base/package.json +++ b/packages/bundle/base/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-base", "description": "The shared dsh core as a profile bundle: the first patch layer of base-backed profiles, inserting core rows over the empty profile root", - "version": "0.1.7-alpha.1", + "version": "0.1.7-alpha.2", "publishConfig": { "access": "public" }, @@ -34,102 +34,102 @@ } }, "dependencies": { - "@deepseek-ai/dsh-hmr": "workspace:^", - "@deepseek-ai/cordis-plugin-timer": "workspace:^", - "@deepseek-ai/dsh-agent": "workspace:^", - "@deepseek-ai/dsh-agent-default-model": "workspace:^", - "@deepseek-ai/dsh-agent-loop": "workspace:^", - "@deepseek-ai/dsh-attachment-local": "workspace:^", - "@deepseek-ai/dsh-shell-env": "workspace:^", - "@deepseek-ai/dsh-bash-sandbox": "workspace:^", - "@deepseek-ai/dsh-command-compact": "workspace:^", - "@deepseek-ai/dsh-command-feedback": "workspace:^", - "@deepseek-ai/dsh-command-goal": "workspace:^", - "@deepseek-ai/dsh-commands": "workspace:^", - "@deepseek-ai/dsh-compaction-basic": "workspace:^", - "@deepseek-ai/dsh-compaction-image-offload": "workspace:^", - "@deepseek-ai/dsh-compaction-tool-result-pruner": "workspace:^", - "@deepseek-ai/dsh-credentials-local": "workspace:^", - "@deepseek-ai/dsh-deepseek-llm-api-extensions": "workspace:^", - "@deepseek-ai/dsh-plugin-package-inventory-deepseek": "workspace:^", - "@deepseek-ai/dsh-fs-local": "workspace:^", - "@deepseek-ai/dsh-fs-observation-policy": "workspace:^", - "@deepseek-ai/dsh-fs-sandbox": "workspace:^", - "@deepseek-ai/dsh-goal": "workspace:^", - "@deepseek-ai/dsh-goal-round-driver": "workspace:^", - "@deepseek-ai/dsh-api-gateway": "workspace:^", - "@deepseek-ai/dsh-llm": "workspace:^", - "@deepseek-ai/dsh-llm-deepseek": "workspace:^", - "@deepseek-ai/dsh-llm-pi-ai": "workspace:^", - "@deepseek-ai/dsh-llm-retry": "workspace:^", - "@deepseek-ai/dsh-mcp-resources": "workspace:^", - "@deepseek-ai/dsh-permission-presets": "workspace:^", - "@deepseek-ai/dsh-plan-mode": "workspace:^", - "@deepseek-ai/dsh-pwsh-sandbox": "workspace:^", - "@deepseek-ai/dsh-repeat-tool-reminder": "workspace:^", - "@deepseek-ai/dsh-sandbox-local": "workspace:^", - "@deepseek-ai/dsh-sandbox-policy": "workspace:^", - "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/dsh-session-checkpoint-policy": "workspace:^", - "@deepseek-ai/dsh-session-log-deepseek": "workspace:^", - "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", - "@deepseek-ai/dsh-session-projection": "workspace:^", - "@deepseek-ai/dsh-session-projection-cache": "workspace:^", - "@deepseek-ai/dsh-session-query-sqlite": "workspace:^", - "@deepseek-ai/dsh-session-telemetry-otel": "workspace:^", - "@deepseek-ai/dsh-session-title": "workspace:^", - "@deepseek-ai/dsh-session-title-first-prompt-llm": "workspace:^", - "@deepseek-ai/dsh-skill": "workspace:^", - "@deepseek-ai/dsh-skill-badge": "workspace:^", - "@deepseek-ai/dsh-skill-filesystem": "workspace:^", - "@deepseek-ai/dsh-spill-local": "workspace:^", - "@deepseek-ai/dsh-spill-policy": "workspace:^", - "@deepseek-ai/dsh-storage": "workspace:^", - "@deepseek-ai/dsh-storage-domain": "workspace:^", - "@deepseek-ai/dsh-storage-json": "workspace:^", - "@deepseek-ai/dsh-subagent": "workspace:^", - "@deepseek-ai/dsh-subagent-fork-in-process": "workspace:^", - "@deepseek-ai/dsh-subagent-spawn-in-process": "workspace:^", - "@deepseek-ai/dsh-subprocess-local": "workspace:^", - "@deepseek-ai/dsh-system-prompt": "workspace:^", - "@deepseek-ai/dsh-jobs-local": "workspace:^", - "@deepseek-ai/dsh-tool-call-timeout-policy": "workspace:^", - "@deepseek-ai/dsh-token-meter": "workspace:^", - "@deepseek-ai/dsh-tool-bash": "workspace:^", - "@deepseek-ai/dsh-tool-present": "workspace:^", - "@deepseek-ai/dsh-tool-fs": "workspace:^", - "@deepseek-ai/dsh-tool-fs-search": "workspace:^", - "@deepseek-ai/dsh-tool-goal": "workspace:^", - "@deepseek-ai/dsh-tool-pwsh": "workspace:^", - "@deepseek-ai/dsh-tool-ralph": "workspace:^", - "@deepseek-ai/dsh-tool-skill": "workspace:^", - "@deepseek-ai/dsh-tool-subagent": "workspace:^", - "@deepseek-ai/dsh-tool-subagent-control": "workspace:^", - "@deepseek-ai/dsh-tool-jobs": "workspace:^", - "@deepseek-ai/dsh-tool-todo": "workspace:^", - "@deepseek-ai/dsh-tool-web": "workspace:^", - "@deepseek-ai/dsh-tool-workflow": "workspace:^", - "@deepseek-ai/dsh-tools": "workspace:^", - "@deepseek-ai/dsh-typert-loader": "workspace:^", - "@deepseek-ai/dsh-typert-registry": "workspace:^", - "@deepseek-ai/dsh-user-approval": "workspace:^", - "@deepseek-ai/dsh-user-questions": "workspace:^", - "@deepseek-ai/dsh-web": "workspace:^", - "@deepseek-ai/dsh-web-fetch-http": "workspace:^", - "@deepseek-ai/dsh-web-search-deepseek": "workspace:^", - "@deepseek-ai/dsh-workflow-ptc": "workspace:^", - "@deepseek-ai/dsh-agent-instructions": "workspace:^", - "@deepseek-ai/dsh-ptc-runtime-node": "workspace:^", - "@deepseek-ai/dsh-plugin-manager": "workspace:^", - "@deepseek-ai/dsh-authorization": "workspace:^", - "@deepseek-ai/dsh-deepseek-account-platform": "workspace:^", - "@deepseek-ai/dsh-config-editor": "workspace:^", - "@deepseek-ai/dsh-settings": "workspace:^" + "@deepseek-ai/dsh-hmr": "workspace:*", + "@deepseek-ai/cordis-plugin-timer": "workspace:~", + "@deepseek-ai/dsh-agent": "workspace:*", + "@deepseek-ai/dsh-agent-default-model": "workspace:*", + "@deepseek-ai/dsh-agent-loop": "workspace:*", + "@deepseek-ai/dsh-attachment-local": "workspace:*", + "@deepseek-ai/dsh-shell-env": "workspace:*", + "@deepseek-ai/dsh-bash-sandbox": "workspace:*", + "@deepseek-ai/dsh-command-compact": "workspace:*", + "@deepseek-ai/dsh-command-feedback": "workspace:*", + "@deepseek-ai/dsh-command-goal": "workspace:*", + "@deepseek-ai/dsh-commands": "workspace:*", + "@deepseek-ai/dsh-compaction-basic": "workspace:*", + "@deepseek-ai/dsh-compaction-image-offload": "workspace:*", + "@deepseek-ai/dsh-compaction-tool-result-pruner": "workspace:*", + "@deepseek-ai/dsh-credentials-local": "workspace:*", + "@deepseek-ai/dsh-deepseek-llm-api-extensions": "workspace:*", + "@deepseek-ai/dsh-plugin-package-inventory-deepseek": "workspace:*", + "@deepseek-ai/dsh-fs-local": "workspace:*", + "@deepseek-ai/dsh-fs-observation-policy": "workspace:*", + "@deepseek-ai/dsh-fs-sandbox": "workspace:*", + "@deepseek-ai/dsh-goal": "workspace:*", + "@deepseek-ai/dsh-goal-round-driver": "workspace:*", + "@deepseek-ai/dsh-api-gateway": "workspace:*", + "@deepseek-ai/dsh-llm": "workspace:*", + "@deepseek-ai/dsh-llm-deepseek": "workspace:*", + "@deepseek-ai/dsh-llm-pi-ai": "workspace:*", + "@deepseek-ai/dsh-llm-retry": "workspace:*", + "@deepseek-ai/dsh-mcp-resources": "workspace:*", + "@deepseek-ai/dsh-permission-presets": "workspace:*", + "@deepseek-ai/dsh-plan-mode": "workspace:*", + "@deepseek-ai/dsh-pwsh-sandbox": "workspace:*", + "@deepseek-ai/dsh-repeat-tool-reminder": "workspace:*", + "@deepseek-ai/dsh-sandbox-local": "workspace:*", + "@deepseek-ai/dsh-sandbox-policy": "workspace:*", + "@deepseek-ai/dsh-session": "workspace:*", + "@deepseek-ai/dsh-session-checkpoint-policy": "workspace:*", + "@deepseek-ai/dsh-session-log-deepseek": "workspace:*", + "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:*", + "@deepseek-ai/dsh-session-projection": "workspace:*", + "@deepseek-ai/dsh-session-projection-cache": "workspace:*", + "@deepseek-ai/dsh-session-query-sqlite": "workspace:*", + "@deepseek-ai/dsh-session-telemetry-otel": "workspace:*", + "@deepseek-ai/dsh-session-title": "workspace:*", + "@deepseek-ai/dsh-session-title-first-prompt-llm": "workspace:*", + "@deepseek-ai/dsh-skill": "workspace:*", + "@deepseek-ai/dsh-skill-badge": "workspace:*", + "@deepseek-ai/dsh-skill-filesystem": "workspace:*", + "@deepseek-ai/dsh-spill-local": "workspace:*", + "@deepseek-ai/dsh-spill-policy": "workspace:*", + "@deepseek-ai/dsh-storage": "workspace:*", + "@deepseek-ai/dsh-storage-domain": "workspace:*", + "@deepseek-ai/dsh-storage-json": "workspace:*", + "@deepseek-ai/dsh-subagent": "workspace:*", + "@deepseek-ai/dsh-subagent-fork-in-process": "workspace:*", + "@deepseek-ai/dsh-subagent-spawn-in-process": "workspace:*", + "@deepseek-ai/dsh-subprocess-local": "workspace:*", + "@deepseek-ai/dsh-system-prompt": "workspace:*", + "@deepseek-ai/dsh-jobs-local": "workspace:*", + "@deepseek-ai/dsh-tool-call-timeout-policy": "workspace:*", + "@deepseek-ai/dsh-token-meter": "workspace:*", + "@deepseek-ai/dsh-tool-bash": "workspace:*", + "@deepseek-ai/dsh-tool-present": "workspace:*", + "@deepseek-ai/dsh-tool-fs": "workspace:*", + "@deepseek-ai/dsh-tool-fs-search": "workspace:*", + "@deepseek-ai/dsh-tool-goal": "workspace:*", + "@deepseek-ai/dsh-tool-pwsh": "workspace:*", + "@deepseek-ai/dsh-tool-ralph": "workspace:*", + "@deepseek-ai/dsh-tool-skill": "workspace:*", + "@deepseek-ai/dsh-tool-subagent": "workspace:*", + "@deepseek-ai/dsh-tool-subagent-control": "workspace:*", + "@deepseek-ai/dsh-tool-jobs": "workspace:*", + "@deepseek-ai/dsh-tool-todo": "workspace:*", + "@deepseek-ai/dsh-tool-web": "workspace:*", + "@deepseek-ai/dsh-tool-workflow": "workspace:*", + "@deepseek-ai/dsh-tools": "workspace:*", + "@deepseek-ai/dsh-typert-loader": "workspace:*", + "@deepseek-ai/dsh-typert-registry": "workspace:*", + "@deepseek-ai/dsh-user-approval": "workspace:*", + "@deepseek-ai/dsh-user-questions": "workspace:*", + "@deepseek-ai/dsh-web": "workspace:*", + "@deepseek-ai/dsh-web-fetch-http": "workspace:*", + "@deepseek-ai/dsh-web-search-deepseek": "workspace:*", + "@deepseek-ai/dsh-workflow-ptc": "workspace:*", + "@deepseek-ai/dsh-agent-instructions": "workspace:*", + "@deepseek-ai/dsh-ptc-runtime-node": "workspace:*", + "@deepseek-ai/dsh-plugin-manager": "workspace:*", + "@deepseek-ai/dsh-authorization": "workspace:*", + "@deepseek-ai/dsh-deepseek-account-platform": "workspace:*", + "@deepseek-ai/dsh-config-editor": "workspace:*", + "@deepseek-ai/dsh-settings": "workspace:*" }, "peerDependencies": { - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:~" }, "devDependencies": { - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:~" } } diff --git a/packages/bundle/headless/package.json b/packages/bundle/headless/package.json index aaa64d7ee9..01c6559068 100644 --- a/packages/bundle/headless/package.json +++ b/packages/bundle/headless/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-headless", "description": "The dsh one-shot bundle: a direct core Agent/Session runner over dsh-base with no Host, HTTP, or browser layer", - "version": "0.1.7-alpha.1", + "version": "0.1.7-alpha.2", "publishConfig": { "access": "public" }, @@ -40,32 +40,32 @@ } }, "dependencies": { - "@deepseek-ai/dsh-brand": "workspace:^", - "@deepseek-ai/dsh-cmdline": "workspace:^", - "@deepseek-ai/dsh-util-values": "workspace:^", - "@deepseek-ai/schemastery": "workspace:^", + "@deepseek-ai/dsh-brand": "workspace:*", + "@deepseek-ai/dsh-cmdline": "workspace:*", + "@deepseek-ai/dsh-util-values": "workspace:*", + "@deepseek-ai/schemastery": "workspace:~", "commander": "^15.0.0" }, "peerDependencies": { - "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/cordis-plugin-loader": "workspace:^", - "@deepseek-ai/dsh-agent": "workspace:^", - "@deepseek-ai/dsh-agent-default-model": "workspace:^", - "@deepseek-ai/dsh-llm": "workspace:^", - "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/dsh-session-query": "workspace:^", - "@deepseek-ai/dsh-fs": "workspace:^" + "@deepseek-ai/cordis": "workspace:~", + "@deepseek-ai/cordis-plugin-loader": "workspace:~", + "@deepseek-ai/dsh-agent": "workspace:*", + "@deepseek-ai/dsh-agent-default-model": "workspace:*", + "@deepseek-ai/dsh-llm": "workspace:*", + "@deepseek-ai/dsh-session": "workspace:*", + "@deepseek-ai/dsh-session-query": "workspace:*", + "@deepseek-ai/dsh-fs": "workspace:*" }, "devDependencies": { - "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/cordis-plugin-loader": "workspace:^", - "@deepseek-ai/dsh-agent": "workspace:^", - "@deepseek-ai/dsh-agent-default-model": "workspace:^", - "@deepseek-ai/dsh-agent-loop": "workspace:^", - "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^", - "@deepseek-ai/dsh-llm": "workspace:^", - "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/dsh-session-query": "workspace:^", - "@deepseek-ai/dsh-fs": "workspace:^" + "@deepseek-ai/cordis": "workspace:~", + "@deepseek-ai/cordis-plugin-loader": "workspace:~", + "@deepseek-ai/dsh-agent": "workspace:*", + "@deepseek-ai/dsh-agent-default-model": "workspace:*", + "@deepseek-ai/dsh-agent-loop": "workspace:*", + "@deepseek-ai/dsh-agent-loop-testkit": "workspace:*", + "@deepseek-ai/dsh-llm": "workspace:*", + "@deepseek-ai/dsh-session": "workspace:*", + "@deepseek-ai/dsh-session-query": "workspace:*", + "@deepseek-ai/dsh-fs": "workspace:*" } } diff --git a/packages/bundle/sdk-app/cordis.patch.yml b/packages/bundle/sdk-app/cordis.patch.yml index 2abfbc952d..85aeaca996 100644 --- a/packages/bundle/sdk-app/cordis.patch.yml +++ b/packages/bundle/sdk-app/cordis.patch.yml @@ -38,3 +38,4 @@ disabled: !!js "!(process.env.DSH_PRIMARY_RUNTIME ?? process.env.DSH_BUNDLED_PRIMARY_RUNTIME)" config: assetRoot: !!js "process.getBuiltinModule('node:path').resolve(process.env.DSH_PRIMARY_RUNTIME ?? process.env.DSH_BUNDLED_PRIMARY_RUNTIME ?? '', '..', 'office-skills')" + node: !!js "process.getBuiltinModule('node:path').resolve(process.env.DSH_PRIMARY_RUNTIME ?? process.env.DSH_BUNDLED_PRIMARY_RUNTIME ?? '', 'dependencies', 'node', 'bin', process.platform === 'win32' ? 'node.exe' : 'node')" diff --git a/packages/bundle/sdk-app/package.json b/packages/bundle/sdk-app/package.json index b22f4a974b..dc76a874fa 100644 --- a/packages/bundle/sdk-app/package.json +++ b/packages/bundle/sdk-app/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-sdk-app", "description": "The dsh SDK profile bundle: stdio JSON-RPC serving and process lifecycle over dsh-base", - "version": "0.1.7-alpha.1", + "version": "0.1.7-alpha.2", "publishConfig": { "access": "public" }, @@ -34,18 +34,18 @@ } }, "dependencies": { - "@deepseek-ai/dsh-cmdline": "workspace:^", - "@deepseek-ai/dsh-sdk-jsonrpc-server": "workspace:^", - "@deepseek-ai/schemastery": "workspace:^", + "@deepseek-ai/dsh-cmdline": "workspace:*", + "@deepseek-ai/dsh-sdk-jsonrpc-server": "workspace:*", + "@deepseek-ai/schemastery": "workspace:~", "commander": "^15.0.0", - "@deepseek-ai/dsh-skill-office": "workspace:^", - "@deepseek-ai/dsh-tool-workspace-dependencies": "workspace:^" + "@deepseek-ai/dsh-skill-office": "workspace:*", + "@deepseek-ai/dsh-tool-workspace-dependencies": "workspace:*" }, "peerDependencies": { - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:~" }, "devDependencies": { - "@deepseek-ai/cordis-plugin-include": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis-plugin-include": "workspace:~", + "@deepseek-ai/cordis": "workspace:~" } } diff --git a/packages/bundle/sdk-minimal/package.json b/packages/bundle/sdk-minimal/package.json index 00443c256c..d62fa60d58 100644 --- a/packages/bundle/sdk-minimal/package.json +++ b/packages/bundle/sdk-minimal/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-sdk-minimal", "description": "The standalone minimal SDK profile bundle: JSON-RPC, one DeepSeek adapter, persistent shell, and JSONL sessions", - "version": "0.1.7-alpha.1", + "version": "0.1.7-alpha.2", "publishConfig": { "access": "public" }, @@ -34,39 +34,39 @@ } }, "dependencies": { - "@deepseek-ai/cordis-plugin-timer": "workspace:^", - "@deepseek-ai/dsh-agent": "workspace:^", - "@deepseek-ai/dsh-agent-loop": "workspace:^", - "@deepseek-ai/dsh-deepseek-llm-api-extensions": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/dsh-jobs-local": "workspace:^", - "@deepseek-ai/dsh-llm": "workspace:^", - "@deepseek-ai/dsh-llm-deepseek": "workspace:^", - "@deepseek-ai/dsh-llm-retry": "workspace:^", - "@deepseek-ai/dsh-mcp-resources": "workspace:^", - "@deepseek-ai/dsh-plugin-package-inventory-deepseek": "workspace:^", - "@deepseek-ai/dsh-sandbox-local": "workspace:^", - "@deepseek-ai/dsh-sandbox-policy": "workspace:^", - "@deepseek-ai/dsh-scope": "workspace:^", - "@deepseek-ai/dsh-sdk-app": "workspace:^", - "@deepseek-ai/dsh-sdk-jsonrpc-server": "workspace:^", - "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/dsh-session-log-deepseek": "workspace:^", - "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", - "@deepseek-ai/dsh-session-projection": "workspace:^", - "@deepseek-ai/dsh-session-title": "workspace:^", - "@deepseek-ai/dsh-subprocess-local": "workspace:^", - "@deepseek-ai/dsh-system-prompt": "workspace:^", - "@deepseek-ai/dsh-terminal": "workspace:^", - "@deepseek-ai/dsh-terminal-bash": "workspace:^", - "@deepseek-ai/dsh-tool-bash-persistent": "workspace:^", - "@deepseek-ai/dsh-tool-pwsh-persistent": "workspace:^", - "@deepseek-ai/dsh-tools": "workspace:^" + "@deepseek-ai/cordis-plugin-timer": "workspace:~", + "@deepseek-ai/dsh-agent": "workspace:*", + "@deepseek-ai/dsh-agent-loop": "workspace:*", + "@deepseek-ai/dsh-deepseek-llm-api-extensions": "workspace:*", + "@deepseek-ai/dsh-invariants": "workspace:*", + "@deepseek-ai/dsh-jobs-local": "workspace:*", + "@deepseek-ai/dsh-llm": "workspace:*", + "@deepseek-ai/dsh-llm-deepseek": "workspace:*", + "@deepseek-ai/dsh-llm-retry": "workspace:*", + "@deepseek-ai/dsh-mcp-resources": "workspace:*", + "@deepseek-ai/dsh-plugin-package-inventory-deepseek": "workspace:*", + "@deepseek-ai/dsh-sandbox-local": "workspace:*", + "@deepseek-ai/dsh-sandbox-policy": "workspace:*", + "@deepseek-ai/dsh-scope": "workspace:*", + "@deepseek-ai/dsh-sdk-app": "workspace:*", + "@deepseek-ai/dsh-sdk-jsonrpc-server": "workspace:*", + "@deepseek-ai/dsh-session": "workspace:*", + "@deepseek-ai/dsh-session-log-deepseek": "workspace:*", + "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:*", + "@deepseek-ai/dsh-session-projection": "workspace:*", + "@deepseek-ai/dsh-session-title": "workspace:*", + "@deepseek-ai/dsh-subprocess-local": "workspace:*", + "@deepseek-ai/dsh-system-prompt": "workspace:*", + "@deepseek-ai/dsh-terminal": "workspace:*", + "@deepseek-ai/dsh-terminal-bash": "workspace:*", + "@deepseek-ai/dsh-tool-bash-persistent": "workspace:*", + "@deepseek-ai/dsh-tool-pwsh-persistent": "workspace:*", + "@deepseek-ai/dsh-tools": "workspace:*" }, "peerDependencies": { - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:~" }, "devDependencies": { - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:~" } } diff --git a/packages/bundle/web-app/package.json b/packages/bundle/web-app/package.json index 6d3db15d18..8ee1332d47 100644 --- a/packages/bundle/web-app/package.json +++ b/packages/bundle/web-app/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-web-app", "description": "The dsh browser-surface bundle: the web patch layer over dsh-base plus the runtime glue plugin (frontend dist serving, web-surface prompt, bash runtime variables, URL line)", - "version": "0.1.7-alpha.1", + "version": "0.1.7-alpha.2", "publishConfig": { "access": "public" }, @@ -50,148 +50,148 @@ } }, "dependencies": { - "@deepseek-ai/dsh-agent-instructions": "workspace:^", - "@deepseek-ai/dsh-agent-preset": "workspace:^", - "@deepseek-ai/dsh-agent-preset-registry": "workspace:^", - "@deepseek-ai/dsh-agent-tool-presentation": "workspace:^", - "@deepseek-ai/dsh-api-job-controller": "workspace:^", - "@deepseek-ai/dsh-api-remotes": "workspace:^", - "@deepseek-ai/dsh-api-session-controller": "workspace:^", - "@deepseek-ai/dsh-api-settings-controller": "workspace:^", - "@deepseek-ai/dsh-api-terminal-controller": "workspace:^", - "@deepseek-ai/dsh-api-workspace-controller": "workspace:^", - "@deepseek-ai/dsh-api-workspace-files": "workspace:^", - "@deepseek-ai/dsh-app-boot": "workspace:^", - "@deepseek-ai/dsh-client-connection": "workspace:^", - "@deepseek-ai/dsh-client-file-upload": "workspace:^", - "@deepseek-ai/dsh-client-hmr": "workspace:^", - "@deepseek-ai/dsh-client-locale": "workspace:^", - "@deepseek-ai/dsh-client-modules": "workspace:^", - "@deepseek-ai/dsh-client-resources": "workspace:^", - "@deepseek-ai/dsh-client-ui-agent-preset": "workspace:^", - "@deepseek-ai/dsh-client-ui-approval": "workspace:^", - "@deepseek-ai/dsh-client-ui-attachment": "workspace:^", - "@deepseek-ai/dsh-client-ui-brand-official": "workspace:^", - "@deepseek-ai/dsh-client-ui-chat": "workspace:^", - "@deepseek-ai/dsh-client-ui-commands": "workspace:^", - "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", - "@deepseek-ai/dsh-client-ui-cordis": "workspace:^", - "@deepseek-ai/dsh-client-ui-deliverables": "workspace:^", - "@deepseek-ai/dsh-client-ui-directory-picker-browse": "workspace:^", - "@deepseek-ai/dsh-client-ui-directory-picker-native": "workspace:^", - "@deepseek-ai/dsh-client-ui-goal": "workspace:^", - "@deepseek-ai/dsh-client-ui-input-trigger": "workspace:^", - "@deepseek-ai/dsh-client-ui-jobs": "workspace:^", - "@deepseek-ai/dsh-client-ui-layout": "workspace:^", - "@deepseek-ai/dsh-client-ui-message-feedback": "workspace:^", - "@deepseek-ai/dsh-client-ui-model-selection": "workspace:^", - "@deepseek-ai/dsh-client-ui-open-in-app": "workspace:^", - "@deepseek-ai/dsh-client-ui-permission-presets": "workspace:^", - "@deepseek-ai/dsh-client-ui-plan": "workspace:^", - "@deepseek-ai/dsh-client-ui-plugin-manager": "workspace:^", - "@deepseek-ai/dsh-client-ui-reference": "workspace:^", - "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", - "@deepseek-ai/dsh-client-ui-schedule": "workspace:^", - "@deepseek-ai/dsh-client-ui-session": "workspace:^", - "@deepseek-ai/dsh-client-ui-settings": "workspace:^", - "@deepseek-ai/dsh-client-ui-settings-general": "workspace:^", - "@deepseek-ai/dsh-client-ui-settings-models": "workspace:^", - "@deepseek-ai/dsh-client-ui-settings-plugin-inventory": "workspace:^", - "@deepseek-ai/dsh-client-ui-settings-plugins": "workspace:^", - "@deepseek-ai/dsh-client-ui-settings-shell": "workspace:^", - "@deepseek-ai/dsh-client-ui-settings-agent-loop": "workspace:^", - "@deepseek-ai/dsh-client-ui-settings-subagent": "workspace:^", - "@deepseek-ai/dsh-client-ui-settings-web-search": "workspace:^", - "@deepseek-ai/dsh-client-ui-sidebar": "workspace:^", - "@deepseek-ai/dsh-client-ui-sidebar-browser": "workspace:^", - "@deepseek-ai/dsh-client-ui-sidebar-documentpreview": "workspace:^", - "@deepseek-ai/dsh-client-ui-sidebar-files": "workspace:^", - "@deepseek-ai/dsh-client-ui-sidebar-right": "workspace:^", - "@deepseek-ai/dsh-client-ui-sidebar-terminal": "workspace:^", - "@deepseek-ai/dsh-client-ui-skill": "workspace:^", - "@deepseek-ai/dsh-client-ui-subagent": "workspace:^", - "@deepseek-ai/dsh-client-ui-theme": "workspace:^", - "@deepseek-ai/dsh-client-ui-tool": "workspace:^", - "@deepseek-ai/dsh-client-ui-trajectory": "workspace:^", - "@deepseek-ai/dsh-client-ui-user-questions": "workspace:^", - "@deepseek-ai/dsh-client-ui-workflow-run": "workspace:^", - "@deepseek-ai/dsh-client-ui-workspace": "workspace:^", - "@deepseek-ai/dsh-cmdline": "workspace:^", - "@deepseek-ai/dsh-command-compact": "workspace:^", - "@deepseek-ai/dsh-command-goal": "workspace:^", - "@deepseek-ai/dsh-compaction-basic": "workspace:^", - "@deepseek-ai/dsh-compaction-tool-result-pruner": "workspace:^", - "@deepseek-ai/dsh-cordis-client-runner": "workspace:^", - "@deepseek-ai/dsh-cordis-host-runner": "workspace:^", - "@deepseek-ai/dsh-file-reference": "workspace:^", - "@deepseek-ai/dsh-file-reference-local": "workspace:^", - "@deepseek-ai/dsh-host-directory-picker-auto": "workspace:^", - "@deepseek-ai/dsh-host-directory-picker-browse": "workspace:^", - "@deepseek-ai/dsh-host-directory-picker-native": "workspace:^", - "@deepseek-ai/dsh-host-frontend-static": "workspace:^", - "@deepseek-ai/dsh-host-open-in-app": "workspace:^", - "@deepseek-ai/dsh-host-plugin-inventory": "workspace:^", - "@deepseek-ai/dsh-host-webserver": "workspace:^", - "@deepseek-ai/dsh-launch-environment": "workspace:^", - "@deepseek-ai/dsh-message-feedback": "workspace:^", - "@deepseek-ai/dsh-office-to-pdf": "workspace:^", - "@deepseek-ai/dsh-persona": "workspace:^", - "@deepseek-ai/dsh-plan-mode": "workspace:^", - "@deepseek-ai/dsh-plugin-manager": "workspace:^", - "@deepseek-ai/dsh-session-log-export": "workspace:^", - "@deepseek-ai/dsh-session-reference": "workspace:^", - "@deepseek-ai/dsh-session-stats": "workspace:^", - "@deepseek-ai/dsh-session-turn-outline": "workspace:^", - "@deepseek-ai/dsh-skill-filesystem": "workspace:^", - "@deepseek-ai/dsh-subprocess": "workspace:^", - "@deepseek-ai/dsh-terminal": "workspace:^", - "@deepseek-ai/dsh-terminal-bash": "workspace:^", - "@deepseek-ai/dsh-tool-ask-user": "workspace:^", - "@deepseek-ai/dsh-tool-bash": "workspace:^", - "@deepseek-ai/dsh-tool-bash-persistent": "workspace:^", - "@deepseek-ai/dsh-tool-cordis": "workspace:^", - "@deepseek-ai/dsh-tool-fs": "workspace:^", - "@deepseek-ai/dsh-tool-fs-search": "workspace:^", - "@deepseek-ai/dsh-tool-goal": "workspace:^", - "@deepseek-ai/dsh-tool-jobs": "workspace:^", - "@deepseek-ai/dsh-tool-present": "workspace:^", - "@deepseek-ai/dsh-tool-pwsh": "workspace:^", - "@deepseek-ai/dsh-tool-pwsh-persistent": "workspace:^", - "@deepseek-ai/dsh-tool-ralph": "workspace:^", - "@deepseek-ai/dsh-tool-skill": "workspace:^", - "@deepseek-ai/dsh-tool-subagent": "workspace:^", - "@deepseek-ai/dsh-tool-subagent-control": "workspace:^", - "@deepseek-ai/dsh-tool-todo": "workspace:^", - "@deepseek-ai/dsh-tool-web": "workspace:^", - "@deepseek-ai/dsh-tool-workflow": "workspace:^", - "@deepseek-ai/dsh-web-frontend": "workspace:^", - "@deepseek-ai/dsh-workflow-ptc": "workspace:^", - "@deepseek-ai/dsh-workspace": "workspace:^", - "@deepseek-ai/dsh-workspace-changes": "workspace:^", - "@deepseek-ai/schemastery": "workspace:^", + "@deepseek-ai/dsh-agent-instructions": "workspace:*", + "@deepseek-ai/dsh-agent-preset": "workspace:*", + "@deepseek-ai/dsh-agent-preset-registry": "workspace:*", + "@deepseek-ai/dsh-agent-tool-presentation": "workspace:*", + "@deepseek-ai/dsh-api-job-controller": "workspace:*", + "@deepseek-ai/dsh-api-remotes": "workspace:*", + "@deepseek-ai/dsh-api-session-controller": "workspace:*", + "@deepseek-ai/dsh-api-settings-controller": "workspace:*", + "@deepseek-ai/dsh-api-terminal-controller": "workspace:*", + "@deepseek-ai/dsh-api-workspace-controller": "workspace:*", + "@deepseek-ai/dsh-api-workspace-files": "workspace:*", + "@deepseek-ai/dsh-app-boot": "workspace:*", + "@deepseek-ai/dsh-client-connection": "workspace:*", + "@deepseek-ai/dsh-client-file-upload": "workspace:*", + "@deepseek-ai/dsh-client-hmr": "workspace:*", + "@deepseek-ai/dsh-client-locale": "workspace:*", + "@deepseek-ai/dsh-client-modules": "workspace:*", + "@deepseek-ai/dsh-client-resources": "workspace:*", + "@deepseek-ai/dsh-client-ui-agent-preset": "workspace:*", + "@deepseek-ai/dsh-client-ui-approval": "workspace:*", + "@deepseek-ai/dsh-client-ui-attachment": "workspace:*", + "@deepseek-ai/dsh-client-ui-brand-official": "workspace:*", + "@deepseek-ai/dsh-client-ui-chat": "workspace:*", + "@deepseek-ai/dsh-client-ui-commands": "workspace:*", + "@deepseek-ai/dsh-client-ui-conversation": "workspace:*", + "@deepseek-ai/dsh-client-ui-cordis": "workspace:*", + "@deepseek-ai/dsh-client-ui-deliverables": "workspace:*", + "@deepseek-ai/dsh-client-ui-directory-picker-browse": "workspace:*", + "@deepseek-ai/dsh-client-ui-directory-picker-native": "workspace:*", + "@deepseek-ai/dsh-client-ui-goal": "workspace:*", + "@deepseek-ai/dsh-client-ui-input-trigger": "workspace:*", + "@deepseek-ai/dsh-client-ui-jobs": "workspace:*", + "@deepseek-ai/dsh-client-ui-layout": "workspace:*", + "@deepseek-ai/dsh-client-ui-message-feedback": "workspace:*", + "@deepseek-ai/dsh-client-ui-model-selection": "workspace:*", + "@deepseek-ai/dsh-client-ui-open-in-app": "workspace:*", + "@deepseek-ai/dsh-client-ui-permission-presets": "workspace:*", + "@deepseek-ai/dsh-client-ui-plan": "workspace:*", + "@deepseek-ai/dsh-client-ui-plugin-manager": "workspace:*", + "@deepseek-ai/dsh-client-ui-reference": "workspace:*", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:*", + "@deepseek-ai/dsh-client-ui-schedule": "workspace:*", + "@deepseek-ai/dsh-client-ui-session": "workspace:*", + "@deepseek-ai/dsh-client-ui-settings": "workspace:*", + "@deepseek-ai/dsh-client-ui-settings-general": "workspace:*", + "@deepseek-ai/dsh-client-ui-settings-models": "workspace:*", + "@deepseek-ai/dsh-client-ui-settings-plugin-inventory": "workspace:*", + "@deepseek-ai/dsh-client-ui-settings-plugins": "workspace:*", + "@deepseek-ai/dsh-client-ui-settings-shell": "workspace:*", + "@deepseek-ai/dsh-client-ui-settings-agent-loop": "workspace:*", + "@deepseek-ai/dsh-client-ui-settings-subagent": "workspace:*", + "@deepseek-ai/dsh-client-ui-settings-web-search": "workspace:*", + "@deepseek-ai/dsh-client-ui-sidebar": "workspace:*", + "@deepseek-ai/dsh-client-ui-sidebar-browser": "workspace:*", + "@deepseek-ai/dsh-client-ui-sidebar-documentpreview": "workspace:*", + "@deepseek-ai/dsh-client-ui-sidebar-files": "workspace:*", + "@deepseek-ai/dsh-client-ui-sidebar-right": "workspace:*", + "@deepseek-ai/dsh-client-ui-sidebar-terminal": "workspace:*", + "@deepseek-ai/dsh-client-ui-skill": "workspace:*", + "@deepseek-ai/dsh-client-ui-subagent": "workspace:*", + "@deepseek-ai/dsh-client-ui-theme": "workspace:*", + "@deepseek-ai/dsh-client-ui-tool": "workspace:*", + "@deepseek-ai/dsh-client-ui-trajectory": "workspace:*", + "@deepseek-ai/dsh-client-ui-user-questions": "workspace:*", + "@deepseek-ai/dsh-client-ui-workflow-run": "workspace:*", + "@deepseek-ai/dsh-client-ui-workspace": "workspace:*", + "@deepseek-ai/dsh-cmdline": "workspace:*", + "@deepseek-ai/dsh-command-compact": "workspace:*", + "@deepseek-ai/dsh-command-goal": "workspace:*", + "@deepseek-ai/dsh-compaction-basic": "workspace:*", + "@deepseek-ai/dsh-compaction-tool-result-pruner": "workspace:*", + "@deepseek-ai/dsh-cordis-client-runner": "workspace:*", + "@deepseek-ai/dsh-cordis-host-runner": "workspace:*", + "@deepseek-ai/dsh-file-reference": "workspace:*", + "@deepseek-ai/dsh-file-reference-local": "workspace:*", + "@deepseek-ai/dsh-host-directory-picker-auto": "workspace:*", + "@deepseek-ai/dsh-host-directory-picker-browse": "workspace:*", + "@deepseek-ai/dsh-host-directory-picker-native": "workspace:*", + "@deepseek-ai/dsh-host-frontend-static": "workspace:*", + "@deepseek-ai/dsh-host-open-in-app": "workspace:*", + "@deepseek-ai/dsh-host-plugin-inventory": "workspace:*", + "@deepseek-ai/dsh-host-webserver": "workspace:*", + "@deepseek-ai/dsh-launch-environment": "workspace:*", + "@deepseek-ai/dsh-message-feedback": "workspace:*", + "@deepseek-ai/dsh-office-to-pdf": "workspace:*", + "@deepseek-ai/dsh-persona": "workspace:*", + "@deepseek-ai/dsh-plan-mode": "workspace:*", + "@deepseek-ai/dsh-plugin-manager": "workspace:*", + "@deepseek-ai/dsh-session-log-export": "workspace:*", + "@deepseek-ai/dsh-session-reference": "workspace:*", + "@deepseek-ai/dsh-session-stats": "workspace:*", + "@deepseek-ai/dsh-session-turn-outline": "workspace:*", + "@deepseek-ai/dsh-skill-filesystem": "workspace:*", + "@deepseek-ai/dsh-subprocess": "workspace:*", + "@deepseek-ai/dsh-terminal": "workspace:*", + "@deepseek-ai/dsh-terminal-bash": "workspace:*", + "@deepseek-ai/dsh-tool-ask-user": "workspace:*", + "@deepseek-ai/dsh-tool-bash": "workspace:*", + "@deepseek-ai/dsh-tool-bash-persistent": "workspace:*", + "@deepseek-ai/dsh-tool-cordis": "workspace:*", + "@deepseek-ai/dsh-tool-fs": "workspace:*", + "@deepseek-ai/dsh-tool-fs-search": "workspace:*", + "@deepseek-ai/dsh-tool-goal": "workspace:*", + "@deepseek-ai/dsh-tool-jobs": "workspace:*", + "@deepseek-ai/dsh-tool-present": "workspace:*", + "@deepseek-ai/dsh-tool-pwsh": "workspace:*", + "@deepseek-ai/dsh-tool-pwsh-persistent": "workspace:*", + "@deepseek-ai/dsh-tool-ralph": "workspace:*", + "@deepseek-ai/dsh-tool-skill": "workspace:*", + "@deepseek-ai/dsh-tool-subagent": "workspace:*", + "@deepseek-ai/dsh-tool-subagent-control": "workspace:*", + "@deepseek-ai/dsh-tool-todo": "workspace:*", + "@deepseek-ai/dsh-tool-web": "workspace:*", + "@deepseek-ai/dsh-tool-workflow": "workspace:*", + "@deepseek-ai/dsh-web-frontend": "workspace:*", + "@deepseek-ai/dsh-workflow-ptc": "workspace:*", + "@deepseek-ai/dsh-workspace": "workspace:*", + "@deepseek-ai/dsh-workspace-changes": "workspace:*", + "@deepseek-ai/schemastery": "workspace:~", "commander": "^15.0.0", "open": "^11.0.0", - "@deepseek-ai/dsh-api-account-controller": "workspace:^", - "@deepseek-ai/dsh-client-ui-settings-account": "workspace:^" + "@deepseek-ai/dsh-api-account-controller": "workspace:*", + "@deepseek-ai/dsh-client-ui-settings-account": "workspace:*" }, "peerDependencies": { - "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/cordis-plugin-loader": "workspace:^", - "@deepseek-ai/dsh-shell-env": "workspace:^", - "@deepseek-ai/dsh-system-prompt": "workspace:^" + "@deepseek-ai/cordis": "workspace:~", + "@deepseek-ai/cordis-plugin-loader": "workspace:~", + "@deepseek-ai/dsh-shell-env": "workspace:*", + "@deepseek-ai/dsh-system-prompt": "workspace:*" }, "devDependencies": { - "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/cordis-plugin-include": "workspace:^", - "@deepseek-ai/cordis-plugin-loader": "workspace:^", - "@deepseek-ai/dsh-fs": "workspace:^", - "@deepseek-ai/dsh-fs-local": "workspace:^", - "@deepseek-ai/dsh-sandbox-policy": "workspace:^", - "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/dsh-session-projection": "workspace:^", - "@deepseek-ai/dsh-shell-env": "workspace:^", - "@deepseek-ai/dsh-system-prompt": "workspace:^", - "@deepseek-ai/dsh-typert-registry": "workspace:^", - "@deepseek-ai/libreoffice-kit": "0.0.1" + "@deepseek-ai/cordis": "workspace:~", + "@deepseek-ai/cordis-plugin-include": "workspace:~", + "@deepseek-ai/cordis-plugin-loader": "workspace:~", + "@deepseek-ai/dsh-fs": "workspace:*", + "@deepseek-ai/dsh-fs-local": "workspace:*", + "@deepseek-ai/dsh-sandbox-policy": "workspace:*", + "@deepseek-ai/dsh-session": "workspace:*", + "@deepseek-ai/dsh-session-projection": "workspace:*", + "@deepseek-ai/dsh-shell-env": "workspace:*", + "@deepseek-ai/dsh-system-prompt": "workspace:*", + "@deepseek-ai/dsh-typert-registry": "workspace:*", + "@deepseek-ai/libreoffice-kit": "^0.1.0" } } diff --git a/packages/bundle/web-app/tests/document-preview.spec.ts b/packages/bundle/web-app/tests/document-preview.spec.ts index 0c7f0d5c77..dcf2bcff18 100644 --- a/packages/bundle/web-app/tests/document-preview.spec.ts +++ b/packages/bundle/web-app/tests/document-preview.spec.ts @@ -60,7 +60,12 @@ it('loads the shipped Office rows with separately patched settings and authorize await writeFile(outputPath, pdf) return { backend: 'native', missingFonts: ['Missing Serif'] } }) - kit.create.mockReset().mockResolvedValue({ backend: 'native', render, dispose: async () => {} }) + kit.create.mockReset().mockResolvedValue({ + backend: 'native', render, dispose: async () => {}, + renderImages: vi.fn().mockRejectedValue(new Error('Unexpected Converter.renderImages call')), + convert: vi.fn().mockRejectedValue(new Error('Unexpected Converter.convert call')), + recalculate: vi.fn().mockRejectedValue(new Error('Unexpected Converter.recalculate call')), + }) ctx.baseUrl = pathToFileURL(directory).href + '/' await ctx.plugin(Loader) ctx.loader.builtins.include = Include diff --git a/packages/client/AGENTS.md b/packages/client/AGENTS.md index 1a729d3fa5..68b17f21c8 100644 --- a/packages/client/AGENTS.md +++ b/packages/client/AGENTS.md @@ -110,7 +110,7 @@ One UI feature = one plugin package (`src/client/` browser half). A multi-domain ## Styling and localization -[docs/web-styling.md](../../docs/web-styling.md) is authoritative. Shared `--dsw-*` tokens and global sheets live in `ui-theme/src/styles/`; feature components consume semantic aliases through CSS Modules and `clsx`, with no literal colors, component library, or Tailwind. Code comments are English. +[docs/web-styling.md](../../docs/web-styling.md) is authoritative. Shared `--dsw-*` tokens and global sheets live in `ui-theme/src/styles/`; feature components consume semantic aliases through CSS Modules and `clsx`, with no literal colors, component library, or Tailwind. Code comments are English. Design judgment beyond those rules — feedback surfaces, loading states, overlay safety, platform window adaptation — follows [dsh-client-ui-ux](../../.agents/skills/dsh-client-ui-ux/SKILL.md). Every product-visible string—including text, accessibility names, tooltips, placeholders, status/unit formatters, and primitive chrome—lives in a typed locale dictionary and reaches components through the standard `t` seat or an already-localized prop. Cordis-free primitives require complete label props and own no fallback copy. Keep user/model/wire data and code tokens verbatim; internal matching uses discriminants or stable ids, never localized text. `pnpm run verify-client-ui-i18n` enforces source ownership ([decision](../../.agents/notes/implemented/architecture/2026-08-23-locale-owned-client-ui-copy.md)). diff --git a/packages/client/connection/package.json b/packages/client/connection/package.json index 5ce575e9f3..572fc80588 100644 --- a/packages/client/connection/package.json +++ b/packages/client/connection/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-connection", "description": "Authenticated RPC transport and generation lifecycle", - "version": "0.1.7-alpha.1", + "version": "0.1.7-alpha.2", "publishConfig": { "access": "public" }, @@ -34,8 +34,8 @@ }, "license": "MIT", "dependencies": { - "@deepseek-ai/dsh-credentials": "workspace:^", - "@deepseek-ai/schemastery": "workspace:^", + "@deepseek-ai/dsh-credentials": "workspace:*", + "@deepseek-ai/schemastery": "workspace:~", "zod": "^4.4.3" }, "files": [ @@ -44,17 +44,17 @@ "lib/types/**/*.d.ts" ], "peerDependencies": { - "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-scope": "workspace:^" + "@deepseek-ai/cordis": "workspace:~", + "@deepseek-ai/dsh-scope": "workspace:*" }, "devDependencies": { - "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-attachment": "workspace:^", - "@deepseek-ai/dsh-brand": "workspace:^", - "@deepseek-ai/dsh-host-webserver": "workspace:^", - "@deepseek-ai/dsh-llm": "workspace:^", - "@deepseek-ai/dsh-scope": "workspace:^", - "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/dsh-typert-protocol": "workspace:^" + "@deepseek-ai/cordis": "workspace:~", + "@deepseek-ai/dsh-attachment": "workspace:*", + "@deepseek-ai/dsh-brand": "workspace:*", + "@deepseek-ai/dsh-host-webserver": "workspace:*", + "@deepseek-ai/dsh-llm": "workspace:*", + "@deepseek-ai/dsh-scope": "workspace:*", + "@deepseek-ai/dsh-session": "workspace:*", + "@deepseek-ai/dsh-typert-protocol": "workspace:*" } } diff --git a/packages/client/file-upload/package.json b/packages/client/file-upload/package.json index 756c1fd785..c46f823fdf 100644 --- a/packages/client/file-upload/package.json +++ b/packages/client/file-upload/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-file-upload", "description": "Agent-scoped browser file upload, streaming intake, and staged receipt service", - "version": "0.1.7-alpha.1", + "version": "0.1.7-alpha.2", "publishConfig": { "access": "public" }, @@ -63,22 +63,22 @@ ], "dependencies": { "zod": "^4.4.3", - "@deepseek-ai/dsh-typert-protocol": "workspace:^", - "@deepseek-ai/dsh-brand": "workspace:^" + "@deepseek-ai/dsh-typert-protocol": "workspace:*", + "@deepseek-ai/dsh-brand": "workspace:*" }, "peerDependencies": { - "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-scope": "workspace:^" + "@deepseek-ai/cordis": "workspace:~", + "@deepseek-ai/dsh-scope": "workspace:*" }, "devDependencies": { - "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-agent": "workspace:^", - "@deepseek-ai/dsh-client-connection": "workspace:^", - "@deepseek-ai/dsh-commands": "workspace:^", - "@deepseek-ai/dsh-scope": "workspace:^", - "@deepseek-ai/dsh-util-crypto": "workspace:^", - "@deepseek-ai/dsh-api-remotes": "workspace:^", - "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/dsh-attachment": "workspace:^" + "@deepseek-ai/cordis": "workspace:~", + "@deepseek-ai/dsh-agent": "workspace:*", + "@deepseek-ai/dsh-client-connection": "workspace:*", + "@deepseek-ai/dsh-commands": "workspace:*", + "@deepseek-ai/dsh-scope": "workspace:*", + "@deepseek-ai/dsh-util-crypto": "workspace:*", + "@deepseek-ai/dsh-api-remotes": "workspace:*", + "@deepseek-ai/dsh-session": "workspace:*", + "@deepseek-ai/dsh-attachment": "workspace:*" } } diff --git a/packages/client/hmr/package.json b/packages/client/hmr/package.json index 99db6b8511..7a94aabed6 100644 --- a/packages/client/hmr/package.json +++ b/packages/client/hmr/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-hmr", "description": "Web client graph synchronization and rebuilt-bundle reload transport", - "version": "0.1.7-alpha.1", + "version": "0.1.7-alpha.2", "publishConfig": { "access": "public" }, @@ -40,17 +40,17 @@ }, "license": "MIT", "dependencies": { - "@deepseek-ai/schemastery": "workspace:^" + "@deepseek-ai/schemastery": "workspace:~" }, "peerDependencies": { - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:~" }, "devDependencies": { - "@deepseek-ai/cordis-plugin-loader": "workspace:^", - "@deepseek-ai/dsh-client-modules": "workspace:^", - "@deepseek-ai/dsh-host-webserver": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis-plugin-loader": "workspace:~", + "@deepseek-ai/dsh-client-modules": "workspace:*", + "@deepseek-ai/dsh-host-webserver": "workspace:*", + "@deepseek-ai/dsh-invariants": "workspace:*", + "@deepseek-ai/cordis": "workspace:~" }, "files": [ "lib/index.js", diff --git a/packages/client/locale/package.json b/packages/client/locale/package.json index 00332a7c88..a57fdc884f 100644 --- a/packages/client/locale/package.json +++ b/packages/client/locale/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-locale", "description": "Locale plugin: Host-backed preference, extensible language catalog, browser fallback, and typed built-in dictionaries", - "version": "0.1.7-alpha.1", + "version": "0.1.7-alpha.2", "publishConfig": { "access": "public" }, @@ -39,25 +39,25 @@ }, "license": "MIT", "peerDependencies": { - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:~" }, "devDependencies": { - "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-api-remotes": "workspace:^", - "@deepseek-ai/dsh-client-store": "workspace:^", - "@deepseek-ai/dsh-client-test-runtime": "workspace:^", - "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", - "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", - "@deepseek-ai/dsh-client-ui-settings": "workspace:^", - "@deepseek-ai/dsh-client-ui-slots": "workspace:^", - "@deepseek-ai/dsh-package-manifest": "workspace:^", + "@deepseek-ai/cordis": "workspace:~", + "@deepseek-ai/dsh-api-remotes": "workspace:*", + "@deepseek-ai/dsh-client-store": "workspace:*", + "@deepseek-ai/dsh-client-test-runtime": "workspace:*", + "@deepseek-ai/dsh-client-ui-primitives": "workspace:*", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:*", + "@deepseek-ai/dsh-client-ui-settings": "workspace:*", + "@deepseek-ai/dsh-client-ui-slots": "workspace:*", + "@deepseek-ai/dsh-package-manifest": "workspace:*", "@types/react": "~18.3.1", "react": "^18.2.0", - "@deepseek-ai/dsh-client-connection": "workspace:^", - "@deepseek-ai/dsh-settings": "workspace:^" + "@deepseek-ai/dsh-client-connection": "workspace:*", + "@deepseek-ai/dsh-settings": "workspace:*" }, "dependencies": { - "@deepseek-ai/schemastery": "workspace:^" + "@deepseek-ai/schemastery": "workspace:~" }, "files": [ "lib/index.js", diff --git a/packages/client/modules/package.json b/packages/client/modules/package.json index 7a727b9a81..d9cd2df64a 100644 --- a/packages/client/modules/package.json +++ b/packages/client/modules/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-modules", "description": "Client module system, dual-face: node half composes the __DSH_BOOT__ entry graph (incremental dsh.client scan, bundle route, index tap, webPlugins service); browser half is the lazy-CJS module table the vendored cordis Loader consumes as its internal seam", - "version": "0.1.7-alpha.1", + "version": "0.1.7-alpha.2", "publishConfig": { "access": "public" }, @@ -42,12 +42,12 @@ }, "license": "MIT", "devDependencies": { - "@deepseek-ai/cordis-plugin-loader": "workspace:^", - "@deepseek-ai/dsh-host-webserver": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-package-manifest": "workspace:^", - "@deepseek-ai/dsh-client-store": "workspace:^" + "@deepseek-ai/cordis-plugin-loader": "workspace:~", + "@deepseek-ai/dsh-host-webserver": "workspace:*", + "@deepseek-ai/dsh-invariants": "workspace:*", + "@deepseek-ai/cordis": "workspace:~", + "@deepseek-ai/dsh-package-manifest": "workspace:*", + "@deepseek-ai/dsh-client-store": "workspace:*" }, "files": [ "lib/index.js", @@ -56,6 +56,6 @@ "lib/types/**/*.d.ts" ], "peerDependencies": { - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:~" } } diff --git a/packages/client/resources/package.json b/packages/client/resources/package.json index bb919f4fb5..cf6c67bcd0 100644 --- a/packages/client/resources/package.json +++ b/packages/client/resources/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-resources", "description": "Unified client resource model: protocol-registered providers turn URL addresses into live values, consumed through the useResource global standard hook", - "version": "0.1.7-alpha.1", + "version": "0.1.7-alpha.2", "publishConfig": { "access": "public" }, @@ -39,15 +39,15 @@ }, "license": "MIT", "peerDependencies": { - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:~" }, "devDependencies": { - "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-client-store": "workspace:^", - "@deepseek-ai/dsh-client-test-runtime": "workspace:^", - "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", - "@deepseek-ai/dsh-client-ui-slots": "workspace:^", - "@deepseek-ai/dsh-typert-protocol": "workspace:^" + "@deepseek-ai/cordis": "workspace:~", + "@deepseek-ai/dsh-client-store": "workspace:*", + "@deepseek-ai/dsh-client-test-runtime": "workspace:*", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:*", + "@deepseek-ai/dsh-client-ui-slots": "workspace:*", + "@deepseek-ai/dsh-typert-protocol": "workspace:*" }, "files": [ "lib/index.js", diff --git a/packages/client/store/package.json b/packages/client/store/package.json index 89ff9d212b..3a8a609385 100644 --- a/packages/client/store/package.json +++ b/packages/client/store/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-store", "description": "React-free observable and snapshot-store contracts with the shared Zustand/Immer engine", - "version": "0.1.7-alpha.1", + "version": "0.1.7-alpha.2", "publishConfig": { "access": "public" }, @@ -23,10 +23,10 @@ }, "license": "MIT", "peerDependencies": { - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:~" }, "devDependencies": { - "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/cordis": "workspace:~", "zustand": "~4.4.7", "immer": "^10.1.1" }, diff --git a/packages/client/ui-agent-preset/README.i18n.yaml b/packages/client/ui-agent-preset/README.i18n.yaml index 961b1712a0..4b97f37141 100644 --- a/packages/client/ui-agent-preset/README.i18n.yaml +++ b/packages/client/ui-agent-preset/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-agent-preset/README.md -README.md: 176957e32695debf6a8aedd98368ec0d985d9182 -README.zh.md: a9496780ce5ea48ae95420d11d9f182134518b93 +README.md: f529c62f16873ce7b10fce70907f56630b133bdb +README.zh.md: 281a667980c66ca90c4180b5ae6bd176d34568f4 diff --git a/packages/client/ui-agent-preset/README.md b/packages/client/ui-agent-preset/README.md index 176957e326..f529c62f16 100644 --- a/packages/client/ui-agent-preset/README.md +++ b/packages/client/ui-agent-preset/README.md @@ -1,5 +1,5 @@ --- -description: "Choose Agent presets and the new-task default in Web, and read what each mode does. Authoring is guided to Creator mode." +description: "Choose Agent presets and the new-task default in Web, read what each mode does and what it declares. Authoring is guided to Creator mode." kind: "package-reference" --- @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -Choose Agent presets and the new-task default in Web, and read what each mode does. Authoring is guided to Creator mode. +Choose Agent presets and the new-task default in Web, read what each mode does and what it declares. Authoring is guided to Creator mode. ## Table of Contents @@ -25,7 +25,7 @@ Choose Agent presets and the new-task default in Web, and read what each mode do ## Use this package -Settings shows the built-in and custom card groups with default highlighting and card-body selection; a group without presets is omitted, except the custom group, which keeps its Creator entry on screen. The page edits nothing: that entry starts a Creator-mode task that authors or overrides a preset as a bundle, offered while the `cordis` preset is on the roster and a conversation flow exists. +Settings shows the built-in and custom card groups with default highlighting and card-body selection; a group without presets is omitted, except the custom group, which keeps its Creator entry on screen. Every card offers “View configuration”, which opens the preset's declared plugin list as read-only YAML in the Loader's own dialect (`!!js` conditions included); a failed preset stays readable because its diagnostic points into that YAML. Escape closes only the viewer and returns focus to its card; leaving Settings clears the viewer, and a late read does not reopen it. The page edits nothing: the Creator entry starts a Creator-mode task that authors or overrides a preset as a bundle, offered while the `cordis` preset is on the roster and a conversation flow exists. The “Choose a mode for new tasks” switch controls whether the saved user default is active. Hiding selection uses the deployment default; showing it restores the user preference. Choosing a healthy default also synchronizes the blank session on the current new-task surface. Creator starts a new task using the `cordis` preset. The new-session picker additionally requires Developer tools in General Settings. @@ -37,7 +37,7 @@ Known shipped presets offer mode details and usage examples in a read-only dialo
Implementation internals — click to expand -`agentPresets/list` supplies the roster and the chooser policy; default and visibility changes write the `agent-presets` settings namespace. The picker, blank-session synchronization and read-only session label use recorded preset identities. Connection resets and settings updates refresh the roster. +`agentPresets/list` supplies the roster and the chooser policy, and `agentPresets/read` one declaration's YAML for the viewer; default and visibility changes write the `agent-presets` settings namespace. The picker, blank-session synchronization and read-only session label use recorded preset identities. Connection resets and settings updates refresh the roster.
@@ -61,7 +61,7 @@ Selection changes affect only later tasks; existing plugins and prompts remain u -- Web creates and edits no preset: a bundle installed through Creator mode declares a new preset or overrides a shipped one by row id, replacing its complete child list. +- Web creates and edits no preset: the configuration viewer is read-only, and a bundle installed through Creator mode declares a new preset or overrides a shipped one by row id, replacing its complete child list. ### Dev Note diff --git a/packages/client/ui-agent-preset/README.zh.md b/packages/client/ui-agent-preset/README.zh.md index a9496780ce..281a667980 100644 --- a/packages/client/ui-agent-preset/README.zh.md +++ b/packages/client/ui-agent-preset/README.zh.md @@ -1,5 +1,5 @@ --- -description: "在 Web 中选择 Agent preset 和新任务默认值,并查看各模式的说明。创建与修改引导至创造模式。" +description: "在 Web 中选择 Agent preset 和新任务默认值,查看各模式的说明与声明的配置。创建与修改引导至创造模式。" kind: "package-reference" --- @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -在 Web 中选择 Agent preset 和新任务默认值,并查看各模式的说明。创建与修改引导至创造模式。 +在 Web 中选择 Agent preset 和新任务默认值,查看各模式的说明与声明的配置。创建与修改引导至创造模式。 ## 目录 @@ -25,7 +25,7 @@ kind: "package-reference" ## 使用此包 -设置页显示内置与自定义卡片分组、默认项高亮和点击卡片选择;没有 preset 的分组不显示,但自定义分组会保留其创造入口。本页不编辑任何内容:该入口启动一个创造模式任务,以 bundle 形式创建或覆盖 preset;当 `cordis` preset 在列表中且存在会话流程时提供。 +设置页显示内置与自定义卡片分组、默认项高亮和点击卡片选择;没有 preset 的分组不显示,但自定义分组会保留其创造入口。每张卡片都提供「查看配置」,以只读 YAML 打开该 preset 声明的插件列表,使用 Loader 自己的方言(含 `!!js` 条件);加载失败的 preset 同样可读,因为其诊断信息正指向这份 YAML。Escape 只关闭查看器并将焦点还给卡片;离开设置页会清空查看器,迟到的读取结果不会重新打开它。本页不编辑任何内容:创造入口启动一个创造模式任务,以 bundle 形式创建或覆盖 preset;当 `cordis` preset 在列表中且存在会话流程时提供。 「新任务可选择模式」开关控制保存的用户默认值是否生效。关闭时使用部署默认值,重新开启则恢复用户偏好。选择健康定义作为默认值也会同步当前新任务页面的空白会话。Creator 入口开启一个使用 `cordis` preset 的新任务。新会话选择器还要求通用设置开启开发者工具。 @@ -37,7 +37,7 @@ kind: "package-reference"
实现细节 — 点击展开 -`agentPresets/list` 提供列表和选择器策略;默认值与可见性的修改写入 `agent-presets` settings 命名空间。选择器、空白会话同步和只读会话标签使用记录的 preset 标识。连接重置和设置更新会刷新列表。 +`agentPresets/list` 提供列表和选择器策略,`agentPresets/read` 为查看器提供一条声明的 YAML;默认值与可见性的修改写入 `agent-presets` settings 命名空间。选择器、空白会话同步和只读会话标签使用记录的 preset 标识。连接重置和设置更新会刷新列表。
@@ -61,7 +61,7 @@ kind: "package-reference" -- Web 不创建也不编辑 preset:通过创造模式安装的 bundle 声明新 preset,或按行 id 覆盖内置 preset,覆盖会替换整个子插件列表。 +- Web 不创建也不编辑 preset:配置查看器是只读的;通过创造模式安装的 bundle 声明新 preset,或按行 id 覆盖内置 preset,覆盖会替换整个子插件列表。 ### 开发备注 diff --git a/packages/client/ui-agent-preset/package.json b/packages/client/ui-agent-preset/package.json index fd83da3206..785c41cbe5 100644 --- a/packages/client/ui-agent-preset/package.json +++ b/packages/client/ui-agent-preset/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-agent-preset", "description": "Agent-preset surfaces: the default for later sessions, this session's seat, and the composition editor", - "version": "0.1.7-alpha.1", + "version": "0.1.7-alpha.2", "publishConfig": { "access": "public" }, @@ -46,26 +46,26 @@ }, "license": "MIT", "peerDependencies": { - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:~" }, "devDependencies": { - "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-agent-preset-registry": "workspace:^", - "@deepseek-ai/dsh-api-remotes": "workspace:^", - "@deepseek-ai/dsh-api-session-controller": "workspace:^", - "@deepseek-ai/dsh-client-connection": "workspace:^", - "@deepseek-ai/dsh-client-locale": "workspace:^", - "@deepseek-ai/dsh-client-store": "workspace:^", - "@deepseek-ai/dsh-client-test-runtime": "workspace:^", - "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", - "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", - "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", - "@deepseek-ai/dsh-client-ui-session": "workspace:^", - "@deepseek-ai/dsh-client-ui-settings": "workspace:^", - "@deepseek-ai/dsh-client-ui-slots": "workspace:^", - "@deepseek-ai/dsh-client-ui-workspace": "workspace:^", - "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/dsh-util-values": "workspace:^", + "@deepseek-ai/cordis": "workspace:~", + "@deepseek-ai/dsh-agent-preset-registry": "workspace:*", + "@deepseek-ai/dsh-api-remotes": "workspace:*", + "@deepseek-ai/dsh-api-session-controller": "workspace:*", + "@deepseek-ai/dsh-client-connection": "workspace:*", + "@deepseek-ai/dsh-client-locale": "workspace:*", + "@deepseek-ai/dsh-client-store": "workspace:*", + "@deepseek-ai/dsh-client-test-runtime": "workspace:*", + "@deepseek-ai/dsh-client-ui-conversation": "workspace:*", + "@deepseek-ai/dsh-client-ui-primitives": "workspace:*", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:*", + "@deepseek-ai/dsh-client-ui-session": "workspace:*", + "@deepseek-ai/dsh-client-ui-settings": "workspace:*", + "@deepseek-ai/dsh-client-ui-slots": "workspace:*", + "@deepseek-ai/dsh-client-ui-workspace": "workspace:*", + "@deepseek-ai/dsh-session": "workspace:*", + "@deepseek-ai/dsh-util-values": "workspace:*", "@types/react": "~18.3.1", "react": "^18.2.0" }, diff --git a/packages/client/ui-agent-preset/src/client/AgentPresetLabel.module.css b/packages/client/ui-agent-preset/src/client/AgentPresetLabel.module.css index 6d2cdd814b..dfcc2acc90 100644 --- a/packages/client/ui-agent-preset/src/client/AgentPresetLabel.module.css +++ b/packages/client/ui-agent-preset/src/client/AgentPresetLabel.module.css @@ -11,7 +11,7 @@ background: var(--dsw-alias-fill-tsp-secondary); font-size: 12px; line-height: 22px; - color: var(--dsw-alias-label-secondary); + color: var(--dsw-alias-label-tertiary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; @@ -21,3 +21,12 @@ flex: none; opacity: 0.7; } + +/* First sacrifice when the title row (the enclosing inline-size container) + runs out of width: the preset is passive chrome, so it yields before any + interactive header action loses its label. */ +@container (max-width: 540px) { + .label { + display: none; + } +} diff --git a/packages/client/ui-agent-preset/src/client/AgentPresetSection.module.css b/packages/client/ui-agent-preset/src/client/AgentPresetSection.module.css index de2c4f6e7b..eedbaa9dd2 100644 --- a/packages/client/ui-agent-preset/src/client/AgentPresetSection.module.css +++ b/packages/client/ui-agent-preset/src/client/AgentPresetSection.module.css @@ -317,6 +317,77 @@ color: var(--dsw-alias-label-primary); } +/* Icon-only actions: the label rides `data-tip` so the row stays quiet until + someone reaches for it. */ +.iconButton { + position: relative; + appearance: none; + border: 0; + border-radius: 7px; + padding: 6px; + background: none; + color: var(--dsw-alias-label-tertiary); + cursor: pointer; + display: inline-flex; + align-items: center; +} + +.iconButton:hover { + background: var(--dsw-alias-bg-layer-1); + color: var(--dsw-alias-label-primary); +} + +.iconButton:focus-visible { + outline: 2px solid var(--dsw-alias-brand-primary); + outline-offset: -1px; +} + +.iconButton::after { + content: attr(data-tip); + position: absolute; + bottom: calc(100% + 6px); + left: 50%; + transform: translateX(-50%); + padding: 3px 8px; + border-radius: 6px; + background: var(--dsw-alias-label-primary); + color: var(--dsw-alias-bg-layer-3); + font-size: 11px; + line-height: 17px; + white-space: nowrap; + opacity: 0; + pointer-events: none; + transition: opacity .12s; +} + +.iconButton:hover::after, +.iconButton:focus-visible::after { + opacity: 1; +} + +.dialog { + width: min(720px, 100%); +} + +/* A declared composition can be long; the dialog scrolls it rather than grow. */ +.viewerCode { + margin: 0; + padding: 12px; + max-height: min(60vh, 560px); + overflow: auto; + border: 0.5px solid var(--dsw-alias-border-l4); + border-radius: 10px; + background: var(--dsw-alias-bg-layer-2); + color: var(--dsw-alias-label-secondary); + font-family: var(--dsw-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace); + font-size: 12.5px; + line-height: 1.5; + white-space: pre; + tab-size: 2; + --dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2); + --dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2); +} + .error { margin: 0; font-size: 12px; diff --git a/packages/client/ui-agent-preset/src/client/AgentPresetSection.tsx b/packages/client/ui-agent-preset/src/client/AgentPresetSection.tsx index 6650be7d9e..d15cb50649 100644 --- a/packages/client/ui-agent-preset/src/client/AgentPresetSection.tsx +++ b/packages/client/ui-agent-preset/src/client/AgentPresetSection.tsx @@ -1,12 +1,14 @@ -/** Preset selection settings: the roster, its default, mode help and the Creator-mode entry. */ +/** Preset selection settings: the roster, its default, mode help, a read-only view of each composition, and the Creator-mode entry. */ import type { ReactNode } from 'react' import { useEffect, useLayoutEffect, useRef, useState } from 'react' -import { Button, IconPlusOutlineRegular, Switch, Tag, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives' +import { + Button, IconBrowseOutlineRegular, IconPlusOutlineRegular, Modal, Switch, Tag, Tooltip, +} from '@deepseek-ai/dsh-client-ui-primitives' import type { ObservableSnapshot, SnapshotStore } from '@deepseek-ai/dsh-client-store' import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import type { AgentPresetSectionState } from './section-store.ts' import { isBuiltInPreset, presetDisplayText } from './locales.ts' -import { PresetGuideDialog, presetGuide, type PresetGuidePage } from './PresetGuideDialog.tsx' +import { PresetGuideDialog, presetGuide, trapPresetReaderTab, type PresetGuidePage } from './PresetGuideDialog.tsx' import css from './AgentPresetSection.module.css' /** Settings actions and their shared controller state. */ @@ -19,6 +21,10 @@ export interface AgentPresetSectionInjected { /** Stage the `cordis` preset and start a Creator-mode task; absent without a conversation flow. */ startCreatorDraft?: () => void load: () => Promise + /** Open one preset's declared composition in the read-only viewer. */ + view: (id: string) => Promise + /** Close the read-only viewer. */ + closeView: () => void makeDefault: (id: string) => Promise setPickerVisible: (visible: boolean) => Promise } @@ -51,12 +57,13 @@ function CardDescription({ text }: { text: string }): ReactNode { ) } -/** Render the roster with its default, mode help, and the guidance to Creator mode. +/** Render the roster with its default, mode help, composition viewer, and the guidance to Creator mode. * @param props Settings actions, snapshot hooks and localized text. * @returns The preset settings section. */ export function AgentPresetSection({ - useAgentPresetSection, load, makeDefault, setPickerVisible, startCreatorDraft, close: closeSettings, useDeveloperTools, t, + useAgentPresetSection, load, view, closeView, makeDefault, setPickerVisible, startCreatorDraft, + close: closeSettings, useDeveloperTools, t, }: AgentPresetSectionProps) { const state = useAgentPresetSection(value => value) const developerTools = useDeveloperTools(enabled => enabled) @@ -64,7 +71,15 @@ export function AgentPresetSection({ content: NonNullable> page: PresetGuidePage } | null>(null) + const viewTrigger = useRef(null) + const closeViewOnUnmount = useRef(closeView) useEffect(() => { void load() }, [load]) + useLayoutEffect(() => { closeViewOnUnmount.current = closeView }, [closeView]) + useEffect(() => () => { closeViewOnUnmount.current() }, []) + const closeViewer = () => { closeView(); viewTrigger.current?.focus() } + const viewed = state.view + const viewedRow = viewed === null ? undefined : state.rows.find(row => row.id === viewed.id) + const viewedTitle = viewed === null ? '' : viewedRow === undefined ? viewed.title : presetDisplayText(viewedRow, t).name // Creator mode authors presets in conversation; it needs the flow to land a // session in and the self-referential preset on the roster. const creator = startCreatorDraft !== undefined && state.rows.some(row => row.id === 'cordis') ? startCreatorDraft : undefined @@ -138,14 +153,21 @@ export function AgentPresetSection({ {row.broken === undefined ? null : {row.broken}} - {help === undefined ? null :
-
+
+ {help === undefined ? null :
-
-
} +
} + {/* Reading the declaration is the one thing this page offers + beyond choosing: a broken preset's YAML is also where its + diagnostic points, so the viewer stays available for it. */} + +
})} } @@ -153,5 +175,17 @@ export function AgentPresetSection({ })} {guide === null ? null : { setGuide(null) }} />} + { + if (event.key === 'Escape') { + event.preventDefault() + event.stopPropagation() + closeViewer() + } else trapPresetReaderTab(event) + }} + title={viewed === null ? '' : `${t('view')} · ${viewedTitle}`} className={css.dialog as string} + footer={}> + {viewed === null ? null :
{viewed.content}
} +
} diff --git a/packages/client/ui-agent-preset/src/client/PresetGuideDialog.tsx b/packages/client/ui-agent-preset/src/client/PresetGuideDialog.tsx index 8ca5270d52..310d397dbc 100644 --- a/packages/client/ui-agent-preset/src/client/PresetGuideDialog.tsx +++ b/packages/client/ui-agent-preset/src/client/PresetGuideDialog.tsx @@ -33,6 +33,25 @@ export function presetGuide(id: string, trust: string): PresetGuide | undefined return trust === 'system' ? guides.get(id) : undefined } +/** Keep keyboard focus inside a preset reader while Tab moves through its controls. + * @param event Keyboard event from the active reader. + */ +export function trapPresetReaderTab(event: KeyboardEvent): void { + if (event.key !== 'Tab') return + const targets = Array.from(event.currentTarget.querySelectorAll( + 'button:not([disabled]):not([tabindex="-1"]), [tabindex="0"]', + )).filter(element => !element.closest('[hidden]')) + const first = targets[0] + const last = targets[targets.length - 1] + if (event.shiftKey && document.activeElement === first) { + event.preventDefault() + last?.focus() + } else if (!event.shiftKey && document.activeElement === last) { + event.preventDefault() + first?.focus() + } +} + /** Curated usage dictionaries contain only level-three example sections. */ function GuideUsage({ text, t }: { text: string @@ -90,20 +109,7 @@ export function PresetGuideDialog({ guide, initialPage, t, onClose }: { event.preventDefault() event.stopPropagation() onClose() - } else if (event.key === 'Tab') { - const targets = Array.from(event.currentTarget.querySelectorAll( - 'button:not([disabled]):not([tabindex="-1"]), [tabindex="0"]', - )).filter(element => !element.closest('[hidden]')) - const first = targets[0] - const last = targets[targets.length - 1] - if (event.shiftKey && document.activeElement === first) { - event.preventDefault() - last?.focus() - } else if (!event.shiftKey && document.activeElement === last) { - event.preventDefault() - first?.focus() - } - } + } else trapPresetReaderTab(event) } return ( diff --git a/packages/client/ui-agent-preset/src/client/index.ts b/packages/client/ui-agent-preset/src/client/index.ts index 9121d5b07d..a253ac3888 100644 --- a/packages/client/ui-agent-preset/src/client/index.ts +++ b/packages/client/ui-agent-preset/src/client/index.ts @@ -2,7 +2,8 @@ * Agent-preset surface plugin, browser half — three surfaces over one roster: * a chip on the new-session screen for the session about to start, a * read-only label in the session header, and a settings section that lists - * the roster (selection, the new-task default, and the way into Creator mode). + * the roster (selection, the new-task default, a read-only view of each + * declared composition, and the way into Creator mode). * * A running session keeps the composition it began with (the host refuses to * adopt an existing session under a different preset). That is what splits @@ -49,7 +50,7 @@ export type { AgentPresetLabelInjected, AgentPresetLabelProps } from './AgentPre export type { AgentPresetSeatInjected, AgentPresetSeatProps } from './AgentPresetSeat.tsx' export type { AgentPresetSectionInjected, AgentPresetSectionProps } from './AgentPresetSection.tsx' export type { AgentPresetSeatState } from './seat-store.ts' -export type { AgentPresetSectionState } from './section-store.ts' +export type { AgentPresetSectionState, PresetView } from './section-store.ts' export type { AgentPresetOption, AgentPresetSettingsState } from './settings-store.ts' export { AGENT_PRESET_SETTINGS_NS, writeDefaultPreset } from './settings-store.ts' @@ -199,6 +200,8 @@ export function apply(ctx: ClientContext): void { const sectionInjected = (): AgentPresetSectionInjected => ({ hooks: { agentPresetSection: section.store, developerTools: ctx.configForms.developerTools.enabled }, load: () => section.load(), + view: (id: string) => section.view(id), + closeView: () => { section.closeView() }, ...creatorDraft === undefined ? {} : { startCreatorDraft: creatorDraft }, makeDefault: (id: string) => section.makeDefault(id, captureBlankSessionSync()), setPickerVisible: (showPicker: boolean) => section.setPickerVisible(showPicker, captureBlankSessionSync()), diff --git a/packages/client/ui-agent-preset/src/client/locales.ts b/packages/client/ui-agent-preset/src/client/locales.ts index 4137600704..885ff581a2 100644 --- a/packages/client/ui-agent-preset/src/client/locales.ts +++ b/packages/client/ui-agent-preset/src/client/locales.ts @@ -12,6 +12,7 @@ export type AgentPresetSettingsKey = | 'nav' | 'sectionIntro' | 'setDefault' + | 'view' | 'presetStandardName' | 'presetStandardDescription' | 'presetPtcName' @@ -44,6 +45,7 @@ export const en: Record = { nav: 'Agent presets', setDefault: 'Set as new task default', + view: 'View configuration', presetStandardName: 'Standard mode', presetStandardDescription: @@ -71,7 +73,7 @@ export const en: Record = { creatorDraft: 'Let the agent help me create a preset', showPicker: 'Choose a mode for new tasks', - showPickerBeta: 'Beta', + showPickerBeta: 'Experimental', showPickerDescription: 'When enabled, each new task can choose a mode and the default is set here. When disabled, new tasks use the application default preset. Existing tasks are unaffected.', enablePickerToSetDefault: 'Turn on mode selection for new tasks to choose a default', @@ -89,6 +91,7 @@ export const zh: Record = { nav: 'Agent 预设', setDefault: '设为新任务默认', + view: '查看配置', presetStandardName: '标准模式', presetStandardDescription: '处理代码、文件和资料,适合大多数任务。Agent 会按需使用检索、编辑和终端等工具。', @@ -112,7 +115,7 @@ export const zh: Record = { creatorDraft: '让 Agent 帮我创建预设模式', showPicker: '新任务可选择模式', - showPickerBeta: 'Beta', + showPickerBeta: '实验性', showPickerDescription: '开启后,可为每个新任务选择模式,并在这里设置默认值。关闭后,新任务使用应用配置的默认预设。已有任务不受影响。', enablePickerToSetDefault: '请先开启新任务模式选择,再设置默认值', enablePickerToCreate: '请先开启新任务模式选择,再启动创造模式', diff --git a/packages/client/ui-agent-preset/src/client/section-store.ts b/packages/client/ui-agent-preset/src/client/section-store.ts index 334a134d87..7ce1154b2f 100644 --- a/packages/client/ui-agent-preset/src/client/section-store.ts +++ b/packages/client/ui-agent-preset/src/client/section-store.ts @@ -1,10 +1,19 @@ -/** Preset roster and selection policy for the settings section. */ +/** Preset roster, selection policy and the read-only composition viewer for the settings section. */ import type { Context } from '@deepseek-ai/cordis' import type {} from '@deepseek-ai/dsh-api-remotes/client' import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store' import type { AgentPresetRow } from '@deepseek-ai/dsh-agent-preset-registry/types' import { writeDefaultPreset, writeModeSelectionEnabled } from './settings-store.ts' +/** The read-only composition viewer over one preset. */ +export interface PresetView { + /** The preset being read. */ + id: string + /** Display name the preset published, or its id. */ + title: string + /** The declared child plugin list as YAML. */ + content: string +} /** Settings page state. */ export interface AgentPresetSectionState { status: 'idle' | 'loading' | 'ready' | 'error' @@ -12,15 +21,18 @@ export interface AgentPresetSectionState { showPicker: boolean policySaving: boolean rows: readonly AgentPresetRow[] + /** The open viewer, or null. */ + view: PresetView | null } -const INITIAL: AgentPresetSectionState = { status: 'idle', error: null, showPicker: true, policySaving: false, rows: [] } +const INITIAL: AgentPresetSectionState = { status: 'idle', error: null, showPicker: true, policySaving: false, rows: [], view: null } const message = (error: unknown): string => error instanceof Error ? error.message : String(error) -/** Loads the roster and writes the default and chooser policy. */ +/** Loads the roster, writes the default and chooser policy, and reads one composition at a time. */ export class AgentPresetSectionController { - /** Observable roster and selection state. */ + /** Observable roster, selection and viewer state. */ readonly store: SnapshotStore = createSnapshotStore(INITIAL) private loading: Promise | undefined + private viewRequest = 0 constructor(private readonly ctx: Context) {} private set(patch: Partial): void { this.store.set({ ...this.store.getSnapshot(), ...patch }) } @@ -39,6 +51,24 @@ export class AgentPresetSectionController { } catch (error) { this.set({ status: 'error', error: message(error) }) } } + /** Open one preset's declared composition in the viewer. + * @param id Preset to read. + * @returns Once the read settles; a current failure lands in `error`, while a read superseded by close or another read is ignored. + */ + async view(id: string): Promise { + const request = ++this.viewRequest + this.set({ error: null, view: null }) + try { + const result = await this.ctx.remote.agentPresets.read(id) + if (request !== this.viewRequest) return + if (!result.ok) throw new Error(result.error.message) + const { name, content } = result.value + this.set({ view: { id, title: name ?? id, content } }) + } catch (error) { if (request === this.viewRequest) this.set({ error: message(error) }) } + } + /** Close the viewer. */ + closeView(): void { this.viewRequest++; this.set({ view: null }) } + /** Set the default and synchronize the current blank task when supplied. * @param id Selected default. * @param sync Blank-session synchronization callback. diff --git a/packages/client/ui-agent-preset/tests/apply.client.spec.ts b/packages/client/ui-agent-preset/tests/apply.client.spec.ts index 23e75612d0..ed6e9698a5 100644 --- a/packages/client/ui-agent-preset/tests/apply.client.spec.ts +++ b/packages/client/ui-agent-preset/tests/apply.client.spec.ts @@ -131,9 +131,9 @@ async function bench(options: { } return Promise.resolve(ROSTER) }, - read: () => Promise.resolve({ + read: (id: string) => Promise.resolve({ ok: true as const, - value: { agentPreset: 'standard', content: '', revision: 'rev' }, + value: { agentPreset: id, content: `# ${id}\n[]\n` }, }), save: (id: string) => { calls.push(`save:${id}`) @@ -350,6 +350,11 @@ describe('ui-agent-preset apply', () => { await section.makeDefault('standard') expect(section.hooks.agentPresetSection.getSnapshot().rows) .toEqual([{ id: 'standard', isDefault: true }]) + await section.view('standard') + expect(section.hooks.agentPresetSection.getSnapshot().view) + .toEqual({ id: 'standard', title: 'standard', content: '# standard\n[]\n' }) + section.closeView() + expect(section.hooks.agentPresetSection.getSnapshot().view).toBeNull() }) it('refreshes a showing surface when its namespace changes, and ignores others', async () => { diff --git a/packages/client/ui-agent-preset/tests/section-store.client.spec.ts b/packages/client/ui-agent-preset/tests/section-store.client.spec.ts index 1a708fd4ec..22a76c2df0 100644 --- a/packages/client/ui-agent-preset/tests/section-store.client.spec.ts +++ b/packages/client/ui-agent-preset/tests/section-store.client.spec.ts @@ -4,6 +4,7 @@ import { AgentPresetSectionController } from '../src/client/section-store.ts' function fixture() { const remote = { agentPresets: { list: vi.fn(async () => ({ ok: true as const, value: { presets: [{ id: 'standard', isDefault: true }], modeSelectionEnabled: true } })), + read: vi.fn(async (id: string) => ({ ok: true as const, value: { agentPreset: id, name: 'Standard', content: '- name: fs\n' } })), }, settings: { update: vi.fn(async () => ({ ok: true as const, value: {} })) } } const controller = new AgentPresetSectionController({ remote } as never) return { remote, controller, state: () => controller.store.getSnapshot() } @@ -14,7 +15,7 @@ describe('the preset roster', () => { const { controller, remote, state } = fixture() await Promise.all([controller.load(), controller.load()]) expect(remote.agentPresets.list).toHaveBeenCalledOnce() - expect(state()).toMatchObject({ status: 'ready', rows: [{ id: 'standard', isDefault: true }], showPicker: true }) + expect(state()).toMatchObject({ status: 'ready', rows: [{ id: 'standard', isDefault: true }], showPicker: true, view: null }) remote.agentPresets.list.mockRejectedValueOnce(new Error('offline')) await controller.load() expect(state()).toMatchObject({ status: 'error', error: 'offline' }) @@ -26,6 +27,43 @@ describe('the preset roster', () => { expect(state().error).toBe('gone') }) + it('opens one declared composition for reading and keeps a failed read out of the viewer', async () => { + const { controller, remote, state } = fixture() + await controller.view('standard') + expect(remote.agentPresets.read).toHaveBeenCalledWith('standard') + expect(state().view).toEqual({ id: 'standard', title: 'Standard', content: '- name: fs\n' }) + controller.closeView() + expect(state().view).toBeNull() + remote.agentPresets.read.mockResolvedValueOnce({ ok: true, value: { agentPreset: 'mine', content: '[]\n' } } as never) + await controller.view('mine') + expect(state().view).toEqual({ id: 'mine', title: 'mine', content: '[]\n' }) + remote.agentPresets.read.mockResolvedValueOnce({ ok: false, error: { message: 'Unknown agent preset: gone' } } as never) + await controller.view('gone') + expect(state()).toMatchObject({ view: null, error: 'Unknown agent preset: gone' }) + remote.agentPresets.read.mockRejectedValueOnce(new Error('offline')) + await controller.view('standard') + expect(state()).toMatchObject({ view: null, error: 'offline' }) + }) + + it('ignores a read that settles after the viewer closes or a newer read opens', async () => { + const { controller, remote, state } = fixture() + const late = Promise.withResolvers>>() + remote.agentPresets.read.mockImplementationOnce(() => late.promise) + const first = controller.view('standard') + controller.closeView() + late.resolve({ ok: true, value: { agentPreset: 'standard', name: 'Standard', content: 'old' } }) + await first + expect(state().view).toBeNull() + + const failed = Promise.withResolvers>>() + remote.agentPresets.read.mockImplementationOnce(() => failed.promise) + const stale = controller.view('standard') + await controller.view('mine') + failed.reject(new Error('stale read')) + await stale + expect(state()).toMatchObject({ error: null, view: { id: 'mine', content: '- name: fs\n' } }) + }) + it('keeps default selection and blank-session synchronization on their existing settings path', async () => { const { controller, remote, state } = fixture() const sync = vi.fn(async () => undefined) diff --git a/packages/client/ui-agent-preset/tests/section.client.spec.tsx b/packages/client/ui-agent-preset/tests/section.client.spec.tsx index 9add87de92..8bad88af50 100644 --- a/packages/client/ui-agent-preset/tests/section.client.spec.tsx +++ b/packages/client/ui-agent-preset/tests/section.client.spec.tsx @@ -3,6 +3,7 @@ import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testi import { afterEach, expect, it, vi } from 'vitest' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' +import { Modal } from '@deepseek-ai/dsh-client-ui-primitives' import { AgentPresetSection, type AgentPresetSectionProps } from '../src/client/AgentPresetSection.tsx' import type { AgentPresetSectionState } from '../src/client/section-store.ts' import { en } from '../src/client/locales.ts' @@ -11,10 +12,12 @@ const translations: ReadonlyMap = new Map(Object.entries(en)) function unusedHook(): never { throw new Error('This section does not read global slot sources') } -function view(partial: Partial = {}, startCreatorDraft?: () => void, developerTools = true) { +function view(partial: Partial = {}, startCreatorDraft?: () => void, developerTools = true, + outerClose?: () => void) { const store = createSnapshotStore({ status: 'ready', error: null, - showPicker: true, policySaving: false, rows: [{ id: 'standard', isDefault: true }, { id: 'mine', name: 'Mine', isDefault: false }], ...partial }) - const actions = { load: vi.fn(async () => {}), makeDefault: vi.fn(async () => {}), + showPicker: true, policySaving: false, rows: [{ id: 'standard', isDefault: true }, { id: 'mine', name: 'Mine', isDefault: false }], + view: null, ...partial }) + const actions = { load: vi.fn(async () => {}), view: vi.fn(async () => {}), closeView: vi.fn(), makeDefault: vi.fn(async () => {}), setPickerVisible: vi.fn(async () => {}), close: vi.fn() } const props: AgentPresetSectionProps = { ...actions, ...(startCreatorDraft === undefined ? {} : { startCreatorDraft }), @@ -23,8 +26,9 @@ function view(partial: Partial = {}, startCreatorDraft? useAgentPresetSection: bindSnapshotSelector(store), useDeveloperTools: bindSnapshotSelector(createSnapshotStore(developerTools)), t: key => translations.get(key) ?? key } - render() - return actions + render(outerClose === undefined ? + : ) + return { ...actions, store } } function rowFor(id: string): HTMLElement { const row = document.querySelector(`[data-agent-preset-id="${id}"]`) @@ -87,6 +91,52 @@ it('disables the Creator entry when mode selection is hidden', () => { fireEvent.click(button) expect(launch).not.toHaveBeenCalled() }) +it('reads a declared composition read-only from every card, broken ones included', () => { + const actions = view({ rows: [{ id: 'standard', isDefault: true }, { id: 'broken', isDefault: false, broken: 'Missing plugin' }] }) + expect(screen.queryByRole('dialog')).toBeNull() + fireEvent.click(within(rowFor('standard')).getByRole('button', { name: `${en.view}: ${en.presetStandardName}` })) + expect(actions.view).toHaveBeenCalledWith('standard') + fireEvent.click(within(rowFor('broken')).getByRole('button', { name: `${en.view}: broken` })) + expect(actions.view).toHaveBeenLastCalledWith('broken') + expect(actions.makeDefault).not.toHaveBeenCalled() +}) +it('shows the open composition under the preset display name, without copy, and closes it from the footer', () => { + const actions = view({ view: { id: 'standard', title: 'standard', content: '- id: tool-fs\n name: fs\n' } }) + const dialog = screen.getByRole('dialog', { name: `${en.view} · ${en.presetStandardName}` }) + expect(dialog.querySelector('pre')?.textContent).toBe('- id: tool-fs\n name: fs\n') + expect(within(dialog).queryByRole('textbox')).toBeNull() + expect(dialog.querySelectorAll('p')).toHaveLength(0) + fireEvent.click(within(dialog).getAllByRole('button', { name: en.close }).at(-1)!) + expect(actions.closeView).toHaveBeenCalledOnce() + cleanup() + view({ view: { id: 'gone', title: 'Gone', content: '[]\n' } }) + expect(screen.getByRole('dialog', { name: `${en.view} · Gone` })).toBeTruthy() +}) +it('keeps Escape inside the composition viewer when Settings is also open', () => { + const closeSettings = vi.fn() + const actions = view({}, undefined, true, closeSettings) + const trigger = within(rowFor('standard')).getByRole('button', { name: `${en.view}: ${en.presetStandardName}` }) + trigger.focus() + fireEvent.click(trigger) + act(() => { actions.store.set({ ...actions.store.getSnapshot(), view: { id: 'standard', title: 'standard', content: '[]\n' } }) }) + const dialog = screen.getByRole('dialog', { name: `${en.view} · ${en.presetStandardName}` }) + const [header, footer] = within(dialog).getAllByRole('button', { name: en.close }) + expect(document.activeElement).toBe(footer) + fireEvent.keyDown(footer!, { key: 'Tab' }) + expect(document.activeElement).toBe(header) + fireEvent.keyDown(header!, { key: 'Tab', shiftKey: true }) + expect(document.activeElement).toBe(footer) + fireEvent.keyDown(footer!, { key: 'Escape' }) + expect(actions.closeView).toHaveBeenCalledOnce() + expect(closeSettings).not.toHaveBeenCalled() + expect(actions.close).not.toHaveBeenCalled() + expect(document.activeElement).toBe(trigger) +}) +it('clears an open viewer when the settings section unmounts', () => { + const actions = view({ view: { id: 'standard', title: 'standard', content: '[]\n' } }) + cleanup() + expect(actions.closeView).toHaveBeenCalledOnce() +}) it('shows roster errors while the policy switch stays usable', () => { const actions = view({ error: 'Roster stale', rows: [{ id: 'broken', isDefault: false, broken: 'Missing plugin' }] }) expect(screen.getAllByRole('alert').map(node => node.textContent)).toEqual(['Roster stale', 'Missing plugin']) @@ -193,13 +243,19 @@ it('closes help even when the browser reports no previously focused element', () it.each([false, true])('only offers a description tooltip when the card clips it: %s', (overflow) => { vi.spyOn(Element.prototype, 'scrollHeight', 'get').mockReturnValue(overflow ? 400 : 80) vi.spyOn(Element.prototype, 'clientHeight', 'get').mockReturnValue(80) - const disconnect = vi.fn() - vi.stubGlobal('ResizeObserver', class { observe() {} disconnect = disconnect }) + const disconnect = vi.spyOn(ResizeObserver.prototype, 'disconnect') vi.useFakeTimers() view({ rows: [{ id: 'mine', isDefault: false, description: 'Preset description' }] }) fireEvent.mouseEnter(screen.getByText('Preset description')) act(() => { vi.advanceTimersByTime(400) }) expect(screen.queryByRole('tooltip')?.textContent ?? null).toBe(overflow ? 'Preset description' : null) cleanup() - expect(disconnect).toHaveBeenCalledOnce() + expect(disconnect).toHaveBeenCalledTimes(overflow ? 2 : 1) +}) +it('renders descriptions and allows selection without resize observation', () => { + vi.stubGlobal('ResizeObserver', undefined) + const actions = view({ rows: [{ id: 'mine', name: 'Mine', isDefault: false, description: 'Preset description' }] }) + expect(screen.getByText('Preset description')).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: `${en.setDefault}: Mine` })) + expect(actions.makeDefault).toHaveBeenCalledWith('mine') }) diff --git a/packages/client/ui-approval/package.json b/packages/client/ui-approval/package.json index acb0a0c9ed..2159fa5af9 100644 --- a/packages/client/ui-approval/package.json +++ b/packages/client/ui-approval/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-approval", "description": "Approval composer takeover over the scoped Remote Event waterfall", - "version": "0.1.7-alpha.1", + "version": "0.1.7-alpha.2", "publishConfig": { "access": "public" }, @@ -44,21 +44,21 @@ }, "license": "MIT", "peerDependencies": { - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:~" }, "devDependencies": { - "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-api-remotes": "workspace:^", - "@deepseek-ai/dsh-api-session-controller": "workspace:^", - "@deepseek-ai/dsh-client-locale": "workspace:^", - "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", - "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", - "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", - "@deepseek-ai/dsh-client-ui-session": "workspace:^", - "@deepseek-ai/dsh-client-ui-slots": "workspace:^", - "@deepseek-ai/dsh-llm": "workspace:^", - "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/dsh-typert-protocol": "workspace:^", + "@deepseek-ai/cordis": "workspace:~", + "@deepseek-ai/dsh-api-remotes": "workspace:*", + "@deepseek-ai/dsh-api-session-controller": "workspace:*", + "@deepseek-ai/dsh-client-locale": "workspace:*", + "@deepseek-ai/dsh-client-ui-conversation": "workspace:*", + "@deepseek-ai/dsh-client-ui-primitives": "workspace:*", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:*", + "@deepseek-ai/dsh-client-ui-session": "workspace:*", + "@deepseek-ai/dsh-client-ui-slots": "workspace:*", + "@deepseek-ai/dsh-llm": "workspace:*", + "@deepseek-ai/dsh-session": "workspace:*", + "@deepseek-ai/dsh-typert-protocol": "workspace:*", "@types/react": "~18.3.1", "react": "^18.2.0" }, diff --git a/packages/client/ui-attachment/package.json b/packages/client/ui-attachment/package.json index d767cdc8b2..ec5cab12d3 100644 --- a/packages/client/ui-attachment/package.json +++ b/packages/client/ui-attachment/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-attachment", "description": "Dynamic attachment presentation plugin for conversation input, message-image, and trajectory image slots", - "version": "0.1.7-alpha.1", + "version": "0.1.7-alpha.2", "publishConfig": { "access": "public" }, @@ -43,19 +43,19 @@ }, "license": "MIT", "devDependencies": { - "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/cordis": "workspace:~", "@types/react": "~18.3.1", "@types/react-dom": "~18.3.0", - "@deepseek-ai/dsh-client-ui-chat": "workspace:^", - "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", - "@deepseek-ai/dsh-client-ui-trajectory": "workspace:^", - "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", - "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", - "@deepseek-ai/dsh-client-ui-slots": "workspace:^", + "@deepseek-ai/dsh-client-ui-chat": "workspace:*", + "@deepseek-ai/dsh-client-ui-conversation": "workspace:*", + "@deepseek-ai/dsh-client-ui-trajectory": "workspace:*", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:*", + "@deepseek-ai/dsh-client-ui-primitives": "workspace:*", + "@deepseek-ai/dsh-client-ui-slots": "workspace:*", "react": "^18.2.0", "react-dom": "^18.2.0", - "@deepseek-ai/dsh-attachment": "workspace:^", - "@deepseek-ai/dsh-client-ui-tool": "workspace:^", + "@deepseek-ai/dsh-attachment": "workspace:*", + "@deepseek-ai/dsh-client-ui-tool": "workspace:*", "clsx": "^2.0.0" }, "files": [ @@ -64,6 +64,6 @@ "lib/types/**/*.d.ts" ], "peerDependencies": { - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:~" } } diff --git a/packages/client/ui-brand-official/package.json b/packages/client/ui-brand-official/package.json index e99d48b16e..822b612aff 100644 --- a/packages/client/ui-brand-official/package.json +++ b/packages/client/ui-brand-official/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-brand-official", "description": "Official DeepSeek Harness brand occupants for the Web client's sidebar slots", - "version": "0.1.7-alpha.1", + "version": "0.1.7-alpha.2", "publishConfig": { "access": "public" }, @@ -40,13 +40,13 @@ }, "license": "MIT", "peerDependencies": { - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:~" }, "devDependencies": { - "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", - "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", - "@deepseek-ai/dsh-client-ui-sidebar": "workspace:^", - "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-client-ui-primitives": "workspace:*", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:*", + "@deepseek-ai/dsh-client-ui-sidebar": "workspace:*", + "@deepseek-ai/cordis": "workspace:~", "@testing-library/react": "^16.1.0", "@types/react": "~18.3.1", "react": "^18.2.0", diff --git a/packages/client/ui-chat/README.i18n.yaml b/packages/client/ui-chat/README.i18n.yaml index 4f36865018..a272be594a 100644 --- a/packages/client/ui-chat/README.i18n.yaml +++ b/packages/client/ui-chat/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-chat/README.md -README.md: 285242c9dc9b8225711096aed03f183f5a8e36b7 -README.zh.md: 8844c9c361f7404af4ea22c645f18e2c9c4e1799 +README.md: b3d649c319c58ff807f11cc6b6d0e634eb931711 +README.zh.md: 6feb9d459299755968a3d99d5789d0cfae8b6e8e diff --git a/packages/client/ui-chat/README.md b/packages/client/ui-chat/README.md index 285242c9dc..b3d649c319 100644 --- a/packages/client/ui-chat/README.md +++ b/packages/client/ui-chat/README.md @@ -72,7 +72,9 @@ The completed-turn action footer follows the recorded Turn end. Its action row s ## Turn Process Folding -During uninterrupted following, local steering echoes remain mounted through Inbox acceptance and claim until the durable message arrives, without triggering tail following twice. Pending inputs follow Inbox order across clients, using matching local echoes in place. After reconnect, Host-owned rows replace receipt-confirmed local echoes; a claim awaiting admission may briefly have no bubble. +During uninterrupted following, local transcript and steering echoes remain mounted through Inbox acceptance and claim until the durable message arrives, without triggering tail following twice. Pending steering follows Inbox order across clients, using matching local echoes in place. Admitted local steering also suppresses matching stale Inbox rows until the claim projection arrives; steering without a locally tracked submission continues to follow the Inbox projection. After reconnect, Host-owned rows replace receipt-confirmed local echoes; a claim awaiting admission may briefly have no bubble. + +When Chat ends with an open Turn control and that Turn has no visible input, the first local transcript echo precedes the control. Other echoes remain at the flow tail. The control and echoes share one keyed list, so arrival of the control preserves the echo's mounted identity. Durable inputs replace their matching echoes in the same render. Work-details modes control process-group display and reasoning previews; eligible completed Turns fold their process without hiding the final answer. The [business-rule reference](src/client/conversation-nodes/README.md#display-modes) contains the mode table, title behavior, whole-Turn eligibility, clocks, and disclosure resets. @@ -83,7 +85,9 @@ Work-details modes control process-group display and reasoning previews; eligibl Chat registers its process Group Definition through `uiConversation.groups`. React renders the mixed `node`/`group` root sequence through stable Group and Node seats; group headers subscribe to data separately from member arrays. Settled group titles remain independent of the live-detail preference; only running titles update when that preference changes. [Process-group business rules](src/client/conversation-nodes/README.md#process-grouping) define segmentation and activity summaries. -`groupPart` selects reasoning or response in the Assistant renderer without copying Node payloads. Each part has a distinct DOM anchor for reading-position restoration; Turn navigation addresses the original Node key and lands on its first visible part. Group sources, member parents, and keys survive display-mode changes and newly loaded prefixes that extend an intact group. The source Node Store remains the only Node-data owner, and a replaced Builder rebinds keyed subscriptions without remounting seats. Mode changes retain size observers and reuse the Turn-state selector. +`groupPart` selects reasoning or response in the Assistant renderer without copying Node payloads. A Tool node owns its preparing, dispatched, and result stages under one callId. Each part has a distinct DOM anchor for reading-position restoration; Turn navigation addresses the original Node key and lands on its first visible part. Group sources, member parents, and keys survive display-mode changes and newly loaded prefixes that extend an intact group. The source Node Store remains the only Node-data owner, and a replaced Builder rebinds keyed subscriptions without remounting seats. Mode changes retain size observers and reuse the Turn-state selector. + +Live tool deltas share reasoning's frame-batched publication; durable calls and results publish immediately. Repeated named deltas retain the Tool node and its data when the projected call, anchor, location, and visibility are unchanged. The process group uses a stable `div` layout box, a scroll body, and an uncapped content box that reports growth inside the body. Business styles must adapt spacing within and across groups, including hidden or empty members and the answer-spacing exception. CSS variables do not belong in the Group Definition. @@ -100,6 +104,19 @@ The Chat-node slot injects a reset-bound `useDisclosure` Hook for reasoning and Chat restores semantic anchors across history prepend and renderer remounts, with browser scroll anchoring disabled on its scrollport only while following the tail. Pinned scroll deliveries without reader movement, and reader input that reaches the exact floor, update follow ownership immediately, before subsequent layout changes can invalidate their floor. Other reader movement remains pending until the sampling interval or `scrollend`, even inside the follow threshold, so layout growth cannot erase small scroll gestures. Submitting transcript input or steering immediately restores tail following and clears an older pending reader sample. While the reader is pinned to the floor, `ResizeObserver` follows the new floor and selects the latest loaded Turn without reading row geometry. Once the reader moves away, flow-height changes preserve the top position and the reading-line geometry selects the active Turn. Turn-rail previews paint above sticky Markdown code-block banners, while the rail frame remains inside the transcript band above the composer. +The turn rail and back-to-bottom button sit outside the clipped transcript. They use the shared conversation scrollport for sticky positioning, or the Chat frame for absolute positioning when Chat owns its scrollport. The rail hides when the transcript's available width, excluding its horizontal padding, is at most 900px; the browser viewport width is not the criterion. + +The transcript root uses `overflow-x: visible; overflow-y: clip`: vertical overflow is clipped without creating a scroll container. Sticky Markdown code banners and expanded compaction headers therefore retain the actual conversation scrollport as their reference when no nearer scrolling ancestor exists. Capped process groups and terminal sections keep their own scrollports. + +Outer transcript following and each open capped group's following are independent. Native animation progress retains follow intent; a reader gesture interrupts the animation, and actual movement determines whether following remains enabled. Scroll chaining can move the outer transcript, which then applies its own distance threshold. The back-to-bottom button restores only outer following. + +| Outer follows | Open group follows | Back-to-bottom button | New content | +|---|---|---|---| +| Yes | Yes | Hidden | Each scrollport follows its own floor. | +| Yes | No | Hidden | The outer transcript follows; the group retains its position. | +| No | Yes | Visible | The group follows; the outer transcript retains its reading position. | +| No | No | Visible | Both retain their reading positions. | + The turn rail mounts only visible marks, overscan, and the focused mark's neighbors. Its fixed pitch and observed viewport size determine scroll offsets without reading the DOM scroll extent. Initial placement waits for the body's restored active Turn and the rail's first usable viewport size. The ref controls activate a Turn or scroll the rail independently; the transcript itself remains fully mounted. While the pointer is outside the rail, automatic follow keeps the rail still when the active mark's center is inside the fade-free band and centers it after it leaves that band. Previews follow pointer movement or focus; marks scrolling under a stationary pointer do not select another preview. @@ -107,7 +124,11 @@ While the pointer is outside the rail, automatic follow keeps the rail still whe
Scroll implementation — click to expand -`useChatViewport` owns turn-aware DOM reads, clamped writes, native events, and one retained paging anchor. For Load older, Node and Group seats mark eligible anchors from their existing disclosure state. The viewport selects the first nonempty, unhidden marker in transcript order without hit testing or geometry-based search, then measures that element and its scroll containers. It compensates the anchor's capped group first, then gives the remaining displacement to the transcript scrollport. Commits and later content resizes reuse that anchor; a remounted row is resolved by the same semantic key. Compensation stays within the actual scroll ranges without adding bottom space. +Within a process group, wheel, touchstart, and any pointerdown interrupt an active smooth animation, including presses on tool cards. ArrowUp/ArrowDown, PageUp/PageDown, Home/End, and Space keys also interrupt it unless a child has prevented the key's default action; editable controls are not excluded. These events stop the animation without requiring a scroll displacement. Subsequent position sampling determines whether following continues. + +`useScrollFollow` supplies independent controllers for shared bottom thresholds, follow intent, and native scrolling. `useProcessScroll` owns group observation, initial placement, and edge fades. Outer following remains immediate; group growth uses native smooth scrolling unless reduced motion is requested. Growth retains an in-flight target until `scrollend`; arrival at that target or its shrink-clamped position continues toward the latest floor, while another endpoint releases following. Opening placement remains immediate. A bottom-follow request within tolerance uses immediate positioning when no animation is outstanding, so a fractional no-op cannot leave a pending smooth target. + +`useChatViewport` owns turn-aware DOM reads, clamped writes, native events, and one retained paging anchor. For Load older, Node and Group seats mark eligible anchors from their existing disclosure state. The viewport selects the first nonempty, unhidden marker in transcript order without hit testing or geometry-based search, then measures that element and its scroll containers. It compensates the anchor's capped group first, then gives the remaining displacement to the transcript scrollport. An inner compensation write cancels that group's animation through its bound controller and pauses following; reader scrolling back to the bottom resumes it. Commits and later content resizes reuse that anchor; a remounted row is resolved by the same semantic key. Compensation stays within the actual scroll ranges without adding bottom space. `useChatReading` owns follow policy, sampled reader input, and semantic memory; `useChatNavigation` owns turn jumps and requests preservation from the viewport. Reading gestures release the paging anchor, but composer clicks, typing, and non-scrolling keys retain it; while a page is still loading, `scrollend` captures the reader's new position. `useChatScroll` coordinates their committed inputs. Explicit navigation carries its measured landing into reading policy, so it does not rediscover the known target with a hit test. @@ -133,6 +154,8 @@ None; Chat presentation does not assemble or mutate provider requests. - **Developer messages are not displayed** — presentation is intentionally deferred; encountering `developer/message` throws instead of rendering a fallback row. +- **Opening echoes predict local order** — several submissions made before the running update can all remain in Chat. Their initial order follows local submission order, not Host queue order; admission can reposition them when the Host receives requests in a different order. + - **The transcript reflects the loaded Session window** — older transcript nodes become available only after Session Controller loads the preceding event page. Turn navigation is wider than the window: the rail merges the loaded Turns with the host `turnOutline` projection, so every started Turn gets a fixed-pitch mark (10px apart; a ladder taller than the frame scrolls inside it with gradient fades), and activating an unloaded mark pages history through the Turn's `turn/start` seq before landing on its row. Without the projection (assemblies not mounting `dsh-session-turn-outline`) the rail falls back to loaded Turns only. - **Rail previews are card-sized** — one prompt line (50 characters) and up to three response lines (120), on loaded and unloaded Turns alike; an unloaded Turn's response arrives from the outline only once the Turn settled, so an open Turn previews its prompt (or just the Turn number) until then. diff --git a/packages/client/ui-chat/README.zh.md b/packages/client/ui-chat/README.zh.md index 8844c9c361..6feb9d4592 100644 --- a/packages/client/ui-chat/README.zh.md +++ b/packages/client/ui-chat/README.zh.md @@ -72,7 +72,9 @@ Assistant 尝试结束且没有可见消息时,Chat 隐藏已发布的 Node, ## 轮次过程折叠 -正常在线时,本地 steering 回显在 Inbox 接受与领取期间保持挂载,直到持久消息到达,不会重复触发跟随底部。跨客户端的待处理输入遵循 Inbox 顺序,在匹配位置使用本地回显。重连后,Host 消息替代已有接收回执的本地回显;已领取但尚未入档时,气泡可能短暂消失。 +正常在线时,本地 transcript 与 steering 回显在 Inbox 接受与领取期间保持挂载,直到持久消息到达,不会重复触发跟随底部。跨客户端的待处理 steering 遵循 Inbox 顺序,在匹配位置使用本地回显。已入档的本地 steering 还会排除匹配的旧 Inbox 行,直到领取投影到达;没有本地提交身份的 steering 仍按 Inbox 投影显示。重连后,Host 消息替代已有接收回执的本地回显;已领取但尚未入档时,气泡可能短暂消失。 + +Chat 末尾为进行中的 Turn 控制行,且该轮尚无可见输入时,第一条本地 transcript 回显显示在控制行前。其他回显保留在正文末尾。控制行与回显共用一个 keyed 列表,因此控制行到达时不会重新挂载回显。持久输入在同一次渲染中替换匹配的回显。 工作过程展示模式控制过程组显示与推理预览;符合条件的已完成轮次折叠过程,但不隐藏最终答案。[业务规则明细](src/client/conversation-nodes/README.zh.md#display-modes) 统一说明模式表、组头行为、整轮折叠资格、时钟与开合重置。 @@ -83,7 +85,9 @@ Assistant 尝试结束且没有可见消息时,Chat 隐藏已发布的 Node, Chat 通过 `uiConversation.groups` 注册过程 Group Definition。React 通过稳定的 Group 与 Node 容器渲染混合 `node`/`group` 根序列,组头数据与成员数组分别订阅。已结束组的标题独立于实时详情偏好,只有运行中的标题在该偏好变化时更新。[过程分组业务规则](src/client/conversation-nodes/README.zh.md#process-grouping) 定义切分方式与活动摘要。 -`groupPart` 在 Assistant 渲染器中选择推理或回复,不复制 Node 载荷。每个部分有独立的 DOM 锚点用于恢复阅读位置;轮次导航使用原 Node key,落到它的第一个可见部分。展示模式切换以及为完整旧组补入更早成员时,保留组来源、成员父级及 key。原 Node Store 仍是唯一节点数据所有者,替换 Builder 时重新绑定按键订阅,不重挂载容器。模式变化保留尺寸观察器,并复用整轮状态选择器。 +`groupPart` 在 Assistant 渲染器中选择推理或回复,不复制 Node 载荷。同一个 callId 的准备、派发与结果阶段由 Tool 节点自己拥有。每个部分有独立的 DOM 锚点用于恢复阅读位置;轮次导航使用原 Node key,落到它的第一个可见部分。展示模式切换以及为完整旧组补入更早成员时,保留组来源、成员父级及 key。原 Node Store 仍是唯一节点数据所有者,替换 Builder 时重新绑定按键订阅,不重挂载容器。模式变化保留尺寸观察器,并复用整轮状态选择器。 + +实时工具 delta 与推理共用按帧合并的发布节奏;持久调用和结果立即发布。重复具名 delta 在投影调用、锚点、位置及可见性均未变化时保留 Tool 节点及其数据引用。 过程组使用稳定的 `div` 布局盒子、滚动正文及不限高的内容盒子,后者报告正文内部的内容增长。业务样式必须适配组内及组边界间距,处理隐藏或空成员以及回答前的间距特例。CSS 变量不属于 Group Definition。 @@ -100,6 +104,19 @@ Chat 节点 slot 为推理与工具注入绑定重置来源的 `useDisclosure` Chat 会在历史前插与 renderer 重新挂载时恢复语义锚点,并且只在跟随底部时禁用自己滚动区域的浏览器自动锚定。没有读者移动的贴底滚动事件,以及确实到达最底部的读者输入,会立即更新跟随归属,避免后续布局变化使其底部位置失效。其他读者移动即使位于跟随阈值内,也保持待处理直到采样周期或 `scrollend`,防止布局增长抵消小幅滚动操作。提交正文输入或 steering 时立即恢复跟随底部,并清除先前待处理的读者滚动采样。读者跟随底部时,`ResizeObserver` 追随新的底部,并且无需读取行几何就选中最后一个已加载轮次;读者离开底部后,高度变化会保持顶部位置,再由阅读线几何选择活跃轮次。轮次导航预览位于 Markdown 代码块粘性头栏上方,而导航外框始终处于 composer 上方的 transcript 区域内。 +轮次轨道与回到底部按钮位于正文裁剪层外。它们相对共享会话滚动容器做 sticky 定位;Chat 自己持有滚动容器时,则相对 Chat 外框做 absolute 定位。正文扣除水平内边距后的可用宽度不超过 900px 时,隐藏轮次轨道;判断依据不是浏览器视口宽度。 + +正文根节点使用 `overflow-x: visible; overflow-y: clip`:裁剪纵向溢出,但不创建滚动容器。因此,在没有更近的滚动祖先时,Markdown 代码块的 sticky 头栏和展开的压缩摘要头栏仍以实际会话滚动容器为参照。限高过程组和终端区域保留各自的滚动容器。 + +外层文本记录与每个已展开限高组的跟随状态彼此独立。原生动画的中间滚动保留跟随意图;读者手势中断动画,是否继续跟随由实际位移决定。滚动传递可以带动外层,外层再按自身离底距离判断。回到底部按钮只恢复外层跟随。 + +| 外层跟随 | 已展开小组跟随 | 回到底部按钮 | 新增内容 | +|---|---|---|---| +| 开启 | 开启 | 隐藏 | 各自跟随自己的底部。 | +| 开启 | 关闭 | 隐藏 | 外层跟随,小组保持原位置。 | +| 关闭 | 开启 | 显示 | 小组跟随,外层保持阅读位置。 | +| 关闭 | 关闭 | 显示 | 两者都保持阅读位置。 | + 轮次轨道只挂载可见刻度、预加载范围及键盘焦点刻度的相邻项。固定间距和观察到的视口尺寸决定滚动偏移,不读取 DOM 滚动总高度。初始定位等待正文恢复活跃轮次,以及轨道首次获得可用视口尺寸。ref 控制接口分别支持激活轮次和仅滚动轨道;正文自身仍完整挂载。 指针位于轨道外时,自动跟随在活跃刻度中心处于非渐隐区域内时保持轨道不动,越界后才将其居中。预览随指针移动或焦点切换;刻度在静止指针下滚动不会选中另一个预览。 @@ -107,7 +124,11 @@ Chat 会在历史前插与 renderer 重新挂载时恢复语义锚点,并且
滚动实现——点击展开 -`useChatViewport` 负责识别轮次的 DOM 读取、限制范围后的写入、原生事件及一个持续保留的分页锚点。加载更早时,Node 与 Group 容器根据已有开合状态标记可选锚点。视口按正文顺序选择第一个非空、未隐藏的标记,不做命中测试或按几何位置搜索,再测量该元素及其滚动容器。先补偿锚点所在的限高组,再把剩余位移交给整段文本的滚动区域。提交和后续内容尺寸变化复用该锚点;消息行重挂载时按同一语义 key 重新定位。补偿限于实际可滚动范围,不增加底部空白。 +过程组内的 wheel、touchstart 和任意 pointerdown 都会中断正在进行的平滑动画,包括按下工具卡片。上下方向键、PageUp/PageDown、Home/End 和空格键也会中断动画,除非子控件已阻止该按键的默认行为;输入控件不被排除。这些事件无需产生滚动位移就会停止动画,之后的位置采样再决定是否继续跟随。 + +`useScrollFollow` 提供各自独立的控制器,共用触底阈值判断、跟随意图与原生滚动实现。`useProcessScroll` 负责小组观察、初始定位及边缘渐隐。外层保持即时跟随;组内增长使用原生平滑滚动,减少动态效果偏好开启时改为即时滚动。`scrollend` 前的增长保留当前动画目标;到达该目标或因内容缩短而钳制的位置后,再追随最新底部;停在其他位置则释放跟随。展开时仍即时定位。没有进行中动画时,容差范围内的贴底请求使用即时定位,避免小数位置上的无位移操作留下未结束的平滑目标。 + +`useChatViewport` 负责识别轮次的 DOM 读取、限制范围后的写入、原生事件及一个持续保留的分页锚点。加载更早时,Node 与 Group 容器根据已有开合状态标记可选锚点。视口按正文顺序选择第一个非空、未隐藏的标记,不做命中测试或按几何位置搜索,再测量该元素及其滚动容器。先补偿锚点所在的限高组,再把剩余位移交给整段文本的滚动区域。内层补偿写入通过该组绑定的控制器取消动画并暂停跟随,读者滚回底部后恢复。提交和后续内容尺寸变化复用该锚点;消息行重挂载时按同一语义 key 重新定位。补偿限于实际可滚动范围,不增加底部空白。 `useChatReading` 负责跟随策略、读者输入采样和语义位置记忆;`useChatNavigation` 负责轮次跳转,并向视口请求位置保持。阅读手势释放分页锚点,composer 内的点击、打字和非滚动按键则保留锚点;分页仍在加载时,`scrollend` 会记录读者的新位置。`useChatScroll` 协调已提交的输入。显式导航把测得的落点交给阅读策略,因此无需通过命中测试重新寻找已知目标。 @@ -133,6 +154,8 @@ Chat 会在历史前插与 renderer 重新挂载时恢复语义锚点,并且 - **不展示 developer 消息** — 展示能力有意留待后续实现;遇到 `developer/message` 时抛出错误,不渲染回退行。 +- **开场回显预测本地顺序**——运行状态更新前连续发出的多条消息可能都留在 Chat。初始排列遵循本地提交顺序,而非 Host 队列顺序;Host 按不同顺序接收请求时,入档可能调整它们的位置。 + - **transcript 只反映已加载的 Session 窗口**——只有会话控制器加载前一页事件后,更早的 transcript node 才会出现。轮次导航比窗口更宽:轨道把已加载的轮次与宿主 `turnOutline` 投影合并,每个已开始的轮次都有固定间距刻度(相隔 10px;阶梯高于外框时在框内滚动并以渐变淡出标示可滚方向),激活未加载刻度会先把历史分页拉到该轮次的 `turn/start` seq 再落到它的行上。没有该投影时(未挂载 `dsh-session-turn-outline` 的装配),轨道回退到仅显示已加载轮次。 - **导航预览按卡片尺寸截断**——提示词一行(50 字符)、回复至多三行(120 字符),已加载与未加载 Turn 一致;未加载 Turn 的回复要等该轮落定后才随大纲到达,进行中的轮次在此之前只预览提示词(或仅轮次号)。 diff --git a/packages/client/ui-chat/package.json b/packages/client/ui-chat/package.json index a81325b054..61174be32d 100644 --- a/packages/client/ui-chat/package.json +++ b/packages/client/ui-chat/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-chat", "description": "Chat Conversation target, node definitions, renderers, and details surface", - "version": "0.1.7-alpha.1", + "version": "0.1.7-alpha.2", "publishConfig": { "access": "public" }, @@ -49,53 +49,53 @@ }, "license": "MIT", "peerDependencies": { - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:~" }, "devDependencies": { - "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-agent": "workspace:^", - "@deepseek-ai/dsh-api-remotes": "workspace:^", - "@deepseek-ai/dsh-api-session-controller": "workspace:^", - "@deepseek-ai/dsh-api-workspace-controller": "workspace:^", - "@deepseek-ai/dsh-api-workspace-files": "workspace:^", - "@deepseek-ai/dsh-attachment": "workspace:^", - "@deepseek-ai/dsh-brand": "workspace:^", - "@deepseek-ai/dsh-client-locale": "workspace:^", - "@deepseek-ai/dsh-client-store": "workspace:^", - "@deepseek-ai/dsh-client-ui-approval": "workspace:^", - "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", - "@deepseek-ai/dsh-client-ui-input-trigger": "workspace:^", - "@deepseek-ai/dsh-client-ui-layout": "workspace:^", - "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", - "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", - "@deepseek-ai/dsh-client-ui-session": "workspace:^", - "@deepseek-ai/dsh-client-ui-settings": "workspace:^", - "@deepseek-ai/dsh-client-ui-sidebar-browser": "workspace:^", - "@deepseek-ai/dsh-client-ui-sidebar-documentpreview": "workspace:^", - "@deepseek-ai/dsh-client-ui-sidebar-right": "workspace:^", - "@deepseek-ai/dsh-client-ui-slots": "workspace:^", - "@deepseek-ai/dsh-client-ui-workspace": "workspace:^", - "@deepseek-ai/dsh-commands": "workspace:^", - "@deepseek-ai/dsh-compaction": "workspace:^", - "@deepseek-ai/dsh-llm": "workspace:^", - "@deepseek-ai/dsh-llm-retry": "workspace:^", - "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/dsh-session-stats": "workspace:^", - "@deepseek-ai/dsh-session-turn-outline": "workspace:^", - "@deepseek-ai/dsh-subagent": "workspace:^", - "@deepseek-ai/dsh-token-meter": "workspace:^", - "@deepseek-ai/dsh-tools": "workspace:^", - "@deepseek-ai/dsh-util-workspace-path": "workspace:^", - "@deepseek-ai/dsh-util-values": "workspace:^", + "@deepseek-ai/cordis": "workspace:~", + "@deepseek-ai/dsh-agent": "workspace:*", + "@deepseek-ai/dsh-api-remotes": "workspace:*", + "@deepseek-ai/dsh-api-session-controller": "workspace:*", + "@deepseek-ai/dsh-api-workspace-controller": "workspace:*", + "@deepseek-ai/dsh-api-workspace-files": "workspace:*", + "@deepseek-ai/dsh-attachment": "workspace:*", + "@deepseek-ai/dsh-brand": "workspace:*", + "@deepseek-ai/dsh-client-locale": "workspace:*", + "@deepseek-ai/dsh-client-store": "workspace:*", + "@deepseek-ai/dsh-client-ui-approval": "workspace:*", + "@deepseek-ai/dsh-client-ui-conversation": "workspace:*", + "@deepseek-ai/dsh-client-ui-input-trigger": "workspace:*", + "@deepseek-ai/dsh-client-ui-layout": "workspace:*", + "@deepseek-ai/dsh-client-ui-primitives": "workspace:*", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:*", + "@deepseek-ai/dsh-client-ui-session": "workspace:*", + "@deepseek-ai/dsh-client-ui-settings": "workspace:*", + "@deepseek-ai/dsh-client-ui-sidebar-browser": "workspace:*", + "@deepseek-ai/dsh-client-ui-sidebar-documentpreview": "workspace:*", + "@deepseek-ai/dsh-client-ui-sidebar-right": "workspace:*", + "@deepseek-ai/dsh-client-ui-slots": "workspace:*", + "@deepseek-ai/dsh-client-ui-workspace": "workspace:*", + "@deepseek-ai/dsh-commands": "workspace:*", + "@deepseek-ai/dsh-compaction": "workspace:*", + "@deepseek-ai/dsh-llm": "workspace:*", + "@deepseek-ai/dsh-llm-retry": "workspace:*", + "@deepseek-ai/dsh-session": "workspace:*", + "@deepseek-ai/dsh-session-stats": "workspace:*", + "@deepseek-ai/dsh-session-turn-outline": "workspace:*", + "@deepseek-ai/dsh-subagent": "workspace:*", + "@deepseek-ai/dsh-token-meter": "workspace:*", + "@deepseek-ai/dsh-tools": "workspace:*", + "@deepseek-ai/dsh-util-workspace-path": "workspace:*", + "@deepseek-ai/dsh-util-values": "workspace:*", "@tanstack/react-virtual": "^3.14.9", "@types/react": "~18.3.1", "@types/react-dom": "~18.3.0", "react": "^18.2.0", "react-dom": "^18.2.0", - "@deepseek-ai/dsh-settings": "workspace:^" + "@deepseek-ai/dsh-settings": "workspace:*" }, "dependencies": { - "@deepseek-ai/schemastery": "workspace:^" + "@deepseek-ai/schemastery": "workspace:~" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-chat/src/client/chat/ApprovalCommand.tsx b/packages/client/ui-chat/src/client/chat/ApprovalCommand.tsx index 32c3d3e415..f042628086 100644 --- a/packages/client/ui-chat/src/client/chat/ApprovalCommand.tsx +++ b/packages/client/ui-chat/src/client/chat/ApprovalCommand.tsx @@ -32,7 +32,7 @@ export function ApprovalCommand({ callId, useChat }: PropsRuntime<'conversation. const command = useChat((snapshot) => { for (const node of snapshot.nodes.values()) { const root = node.kind === 'tool-call' ? (node as ChatNode<'tool-call'>).data.root : undefined - if (root !== undefined && root.callId === callId && !('kind' in root)) return commandOf(root) + if (root !== undefined && root.callId === callId && !('kind' in root) && root.phase === 'start') return commandOf(root) } return undefined }) diff --git a/packages/client/ui-chat/src/client/chat/ChatGroupSeat.tsx b/packages/client/ui-chat/src/client/chat/ChatGroupSeat.tsx index d56d652db8..2944f85c38 100644 --- a/packages/client/ui-chat/src/client/chat/ChatGroupSeat.tsx +++ b/packages/client/ui-chat/src/client/chat/ChatGroupSeat.tsx @@ -1,5 +1,5 @@ /** Stable process container; display policy changes visibility, never member parents. */ -import { memo, useCallback, useEffect, useId, useLayoutEffect, useRef, useState, type ComponentProps, type ReactNode } from 'react' +import { memo, useCallback, useEffect, useId, useRef, useState, type ComponentProps, type ReactNode } from 'react' import { IconAgentPresetOutlineRegular, IconApiOutlineRegular, IconBrowseOutlineRegular, IconChevronDownOutlineRegular, IconChevronUpOutlineRegular, IconCodeOutlineRegular, IconEditOutlineRegular, IconGlobeOutlineRegular, @@ -16,6 +16,7 @@ import { chatRenderKey } from './render-entry.ts' import { processTitle } from './step-process.ts' import { useSearchableHidden } from './searchable-hidden.ts' import { useDisclosure } from './use-disclosure.ts' +import { useProcessScroll } from './use-process-scroll.ts' import css from './ChatGroupSeat.module.css' type SeatProps = Omit, 'nodeKey' | 'groupPart'> @@ -23,8 +24,6 @@ type ChatGroupSeatProps = SeatProps & { readonly groupKey: GroupKey readonly useChatGroup: ChatViewSlotProps['useChatGroup'] } -interface ProcessScrollEdges { readonly canScrollUp: boolean; readonly canScrollDown: boolean } -const PROCESS_SCROLL_AT_REST: ProcessScrollEdges = { canScrollUp: false, canScrollDown: false } const PROCESS_TITLE_MINIMUM_MS = 150 type ProcessTitleActivity = ProcessActivity | 'thinking' @@ -32,13 +31,16 @@ type ProcessTitleActivity = ProcessActivity | 'thinking' interface LiveProcessTitle { readonly activity: ProcessTitleActivity readonly detail: string + readonly preparing: boolean } const PROCESS_ICONS: Record = { thinking: , read: , + readImage: , search: , edit: , + write: , commands: , code: , webSearch: , @@ -49,19 +51,8 @@ const PROCESS_ICONS: Record = { tools: , } -function processScrollEdges(element: HTMLElement): ProcessScrollEdges { - return { - canScrollUp: element.scrollTop > 1, - canScrollDown: element.scrollTop < element.scrollHeight - element.clientHeight - 1, - } -} - -function sameProcessScrollEdges(left: ProcessScrollEdges, right: ProcessScrollEdges): boolean { - return left.canScrollUp === right.canScrollUp && left.canScrollDown === right.canScrollDown -} - function sameLiveProcessTitle(left: LiveProcessTitle, right: LiveProcessTitle): boolean { - return left.activity === right.activity && left.detail === right.detail + return left.activity === right.activity && left.detail === right.detail && left.preparing === right.preparing } function useStableLiveProcessTitle(desired: LiveProcessTitle, active: boolean): LiveProcessTitle { @@ -85,7 +76,7 @@ function useStableLiveProcessTitle(desired: LiveProcessTitle, active: boolean): } const timer = setTimeout(commit, remaining) return () => { clearTimeout(timer) } - }, [active, desired.activity, desired.detail]) + }, [active, desired.activity, desired.detail, desired.preparing]) return active ? displayed : desired } @@ -111,9 +102,12 @@ const ProcessGroupHeader = memo(function ProcessGroupHeader({ groupKey, useChatG const live = useStableLiveProcessTitle({ activity: data?.summary.running ?? 'thinking', detail: data?.summary.runningDetail ?? '', + preparing: data?.summary.preparing === true, }, data !== undefined && !data.closed) if (data === undefined) return null - const label = data.closed ? processTitle(data.summary, t) : t(`message.stepProcess.${live.activity}`) + const label = data.closed ? processTitle(data.summary, t) + : live.preparing ? t(`message.stepProcess.prepare.${live.activity === 'thinking' ? 'tools' : live.activity}`) + : t(`message.stepProcess.${live.activity}`) const detail = detailed && !data.closed ? live.detail : '' const title = detail === '' ? label : `${label}${t('message.turnProcess.separator')}${detail}` const activity = data.closed ? data.summary.counts[0]?.kind ?? 'thinking' : live.activity @@ -135,8 +129,9 @@ const ProcessGroupHeader = memo(function ProcessGroupHeader({ groupKey, useChatG export const ChatGroupSeat = memo(function ChatGroupSeat({ groupKey, useChatGroup, ...props }: ChatGroupSeatProps) { const members = useChatGroup(groupKey, group => group?.members) const turn = useChatGroup(groupKey, group => group?.data.turn) + const closed = useChatGroup(groupKey, group => group?.data.closed) const foldCompleted = props.usePresentation(policy => policy.foldCompletedTurns) - const { expanded: open, setExpanded: setOpen, toggle } = useDisclosure() + const { expanded: open, setExpanded: setOpen } = useDisclosure() const firstKey = members?.[0]?.key ?? '' const presentation = props.useChatNodeProcess(firstKey) const turnLocation = props.useChatNode(firstKey, (node) => { @@ -164,22 +159,11 @@ export const ChatGroupSeat = memo(function ChatGroupSeat({ groupKey, useChatGrou const bodyRef = useSearchableHidden(grouped && !open, reveal) const contentRef = useRef(null) const bodyId = useId() - const [edges, setEdges] = useState(PROCESS_SCROLL_AT_REST) - const sync = useCallback(() => { - const body = bodyRef.current - const next = body === null || body.closest('[hidden], [data-group-expanded-mode]') !== null - ? PROCESS_SCROLL_AT_REST : processScrollEdges(body) - setEdges(previous => sameProcessScrollEdges(previous, next) ? previous : next) - }, [bodyRef]) - // The content box reports growth even when the scroll body remains height-capped. - useLayoutEffect(() => { - const body = bodyRef.current - if (body === null || !open || typeof ResizeObserver === 'undefined') return - const observer = new ResizeObserver(sync) - observer.observe(body) - if (contentRef.current !== null) observer.observe(contentRef.current) - return () => { observer.disconnect() } - }, [bodyRef, open, sync]) + const { edges, events, initialize } = useProcessScroll(bodyRef, contentRef, open, grouped) + const toggle = useCallback(() => { + if (!open) initialize(closed === false ? 'bottom' : 'top') + setOpen(!open) + }, [closed, initialize, open, setOpen]) if (members === undefined) return null const classes = [css.body, !grouped ? css.expandedBody : '', grouped && edges.canScrollUp ? css.fadeTop : '', grouped && edges.canScrollDown ? css.fadeBottom : ''] @@ -194,7 +178,7 @@ export const ChatGroupSeat = memo(function ChatGroupSeat({ groupKey, useChatGrou
+ {...events}>
diff --git a/packages/client/ui-chat/src/client/chat/ChatView.module.css b/packages/client/ui-chat/src/client/chat/ChatView.module.css index ce6e7b94ea..a6b3955f8c 100644 --- a/packages/client/ui-chat/src/client/chat/ChatView.module.css +++ b/packages/client/ui-chat/src/client/chat/ChatView.module.css @@ -1,9 +1,22 @@ +.frame { + position: relative; + display: flex; + flex-direction: column; + min-height: 0; + flex: 1 1 auto; + /* Floating controls query this frame outside the clipped transcript. */ + container-type: inline-size; +} + .root { position: relative; display: flex; flex-direction: column; min-height: 0; flex: 1 1 auto; + + overflow-x: visible; + overflow-y: clip; } .scroll { @@ -25,6 +38,7 @@ overflow-anchor: none; } +:global([data-conversation-scroll]) .frame, :global([data-conversation-scroll]) .root { flex: 0 0 auto; min-height: auto; @@ -113,12 +127,13 @@ opacity: 0.6; } -/* Back-to-bottom: zero-height sticky slot so the control does not extend - scrollHeight; the button translates up into the viewport. Under the - conversation host, clearance sits above the sticky composer stack. */ +/* Floating controls sit outside the clipped transcript. A standalone Chat + anchors them to its frame; the shared host supplies the sticky scrollport. */ .toBottomSlot { - position: sticky; + position: absolute; + right: 0; bottom: 16px; + left: 0; /* Above the sticky composer (z-index 7) so the control stays clickable and visible over the input card. Exception: while an @/slash menu is open the seat lifts to 9 (ui-conversation ConversationRoot) so the menu is never @@ -127,11 +142,15 @@ height: 0; display: flex; justify-content: flex-end; - padding-right: max(0px, calc((100% - var(--dsh-chat-content-width)) / 2)); + padding-right: max( + calc(var(--dsh-composer-side-clearance) + 16px), + calc((100% - var(--dsh-chat-content-width)) / 2) + ); pointer-events: none; } :global([data-conversation-scroll]) .toBottomSlot { + position: sticky; /* Clears the sticky composer stack (docks + input card + stats): the live height rides --dsh-composer-height (ConversationRoot's seat observer) so the control follows a growing textarea; the fallback covers the first diff --git a/packages/client/ui-chat/src/client/chat/ChatView.tsx b/packages/client/ui-chat/src/client/chat/ChatView.tsx index 69f2d58286..a7c9ef0bae 100644 --- a/packages/client/ui-chat/src/client/chat/ChatView.tsx +++ b/packages/client/ui-chat/src/client/chat/ChatView.tsx @@ -6,6 +6,7 @@ import type { NodeKey, RenderEntry, RenderMessageImages, } from '@deepseek-ai/dsh-client-ui-conversation/client' import type { InboxState } from '@deepseek-ai/dsh-agent/types' +import type { PendingSubmission } from '@deepseek-ai/dsh-api-session-controller/client' import { Button, IconChevronDownOutlineRegular, MarkdownDelegateProvider, Modal, } from '@deepseek-ai/dsh-client-ui-primitives' @@ -28,40 +29,40 @@ function openFailureMessage(error: unknown, fallback: string): string { } /** - * Prompt-RPC identities already rendered by durable material: user/steering - * node sources plus queue occurrences. A submission echo whose identity - * appears here is hidden in the same render, so the echo→durable swap is - * atomic — no duplicate, no gap — regardless of when the echo leaves the - * session snapshot. + * Durable input identities suppress matching echoes in the same render. + * The last input's Turn also distinguishes an empty opening control from + * one whose human input or trigger notice is already present. */ -function observedRpcIds( +function observedInputs( order: readonly string[], nodes: ChatSnapshot['nodes'], - inbox: InboxState | undefined, -): { readonly durable: ReadonlySet; readonly pending: ReadonlySet } { +): { readonly rpcIds: ReadonlySet; readonly lastInputTurn: number | undefined } { const observed = new Set() + let lastInputTurn: number | undefined for (const key of order) { const node = nodes.get(key) - if (node === undefined || (node.kind !== 'user' && node.kind !== 'steering')) continue + if (node === undefined || (node.kind !== 'user' && node.kind !== 'steering' && node.kind !== 'turn-trigger')) continue + if (node.location.kind === 'turn' || node.location.kind === 'step') lastInputTurn = node.location.turn.turn + if (node.kind === 'turn-trigger') continue const source = (node.data as { readonly source?: unknown }).source as | { readonly kind?: unknown; readonly rpcId?: unknown } | undefined if (source?.kind === 'user' && typeof source.rpcId === 'string') observed.add(source.rpcId) } - const pending = new Set() - for (const { source } of [...inbox?.['next-turn'] ?? [], ...inbox?.['next-step'] ?? []]) { - if (source.kind === 'user' && 'rpcId' in source) pending.add(source.rpcId) - } - return { durable: observed, pending } + return { rpcIds: observed, lastInputTurn } } +type PendingInput = PendingSubmission | InboxState['next-step'][number] + type ChatNodeListProps = Omit, 'nodeKey' | 'groupPart'> & { readonly entries: readonly RenderEntry[] readonly useChatGroup: ChatViewSlotProps['useChatGroup'] + readonly pendingInputs: readonly PendingInput[] + readonly lastInputTurn: number | undefined } -const ChatNodeList = memo(function ChatNodeList({ entries, useChatGroup, ...seatProps }: ChatNodeListProps) { - return entries.map((entry) => { +const ChatNodeList = memo(function ChatNodeList({ entries, useChatGroup, pendingInputs, lastInputTurn, ...seatProps }: ChatNodeListProps) { + const rows = entries.map((entry) => { switch (entry.kind) { case 'node': return 'requestId' in item ? ( + + ) : ( + + )) + const tail = entries.at(-1) + const node = tail?.kind === 'node' ? seatProps.nodeStore.get(tail.key) : undefined + // An empty opening control follows one local transcript echo, never steering. + // All rows share this keyed list so inserting the control keeps the echo mounted. + if (node?.kind === 'turn-process' && node.location.kind === 'turn' + && node.location.turn.status === 'open' && node.location.turn.turn !== lastInputTurn) { + const index = pendingInputs.findIndex(item => 'requestId' in item && item.placement === 'transcript') + if (index !== -1) rows.splice(rows.length - 1, 0, ...pendingRows.splice(index, 1)) + } + return [...rows, ...pendingRows] }) /** @@ -150,26 +168,28 @@ export function ChatView({ // Submission echoes still awaiting their durable counterpart. `order` is the // recompute trigger: durable user material always arrives as an append, and // every append replaces the order array. - const visibleSubmissions = useMemo(() => { - if (pendingSubmissions.length === 0) return pendingSubmissions - const observed = observedRpcIds(order, nodeStore, inbox) - return pendingSubmissions.filter(submission => ( - submission.placement !== 'queued' && !observed.durable.has(submission.requestId) - && (submission.placement === 'steering' || !observed.pending.has(submission.requestId)) - )) - }, [pendingSubmissions, order, nodeStore, inbox]) + const [visibleSubmissions, lastInputTurn] = useMemo(() => { + if (pendingSubmissions.length === 0) return [pendingSubmissions, undefined] as const + const observed = observedInputs(order, nodeStore) + return [pendingSubmissions.filter(submission => ( + submission.placement !== 'queued' && !observed.rpcIds.has(submission.requestId) + )), observed.lastInputTurn] as const + }, [pendingSubmissions, order, nodeStore]) const pendingInputs = useMemo(() => { const local = new Map(visibleSubmissions.map(submission => [submission.requestId, submission])) - const pending = inboxSteering.map((item) => { + // Admitted local identities outlive their bubbles until the Inbox claim watermark. + const localIds = new Set(pendingSubmissions.filter(submission => submission.placement !== 'queued') + .map(submission => submission.requestId)) + const pending = inboxSteering.flatMap((item) => { const source = item.source - if (source.kind !== 'user' || !('rpcId' in source)) return item + if (source.kind !== 'user' || !('rpcId' in source)) return [item] const submission = local.get(source.rpcId) - if (submission === undefined) return item + if (submission === undefined) return localIds.has(source.rpcId) ? [] : [item] local.delete(source.rpcId) - return submission + return [submission] }) return [...pending, ...local.values()] - }, [inboxSteering, visibleSubmissions]) + }, [inboxSteering, pendingSubmissions, visibleSubmissions]) const renderMessageImages = useCallback( owner => renderSlot('conversation.message.images', { ...owner, loadImage }), [loadImage, renderSlot], @@ -191,85 +211,74 @@ export function ChatView({ }) return ( -
-
- {scroll.initialized && ( - - )} -
- {openState === 'loading' &&
{t('chat.loadingHistory')}
} - {openState === 'error' && openError !== null && ( -
- {t('chat.loadError', { message: openError.message, code: openError.code })} -
- )} - {hasMore && ( -
- -
- )} - - - - {/* No pending placeholders: questions (ui-user-questions) and approvals - (ApprovalPanel) both take over the composer, so a flow card would - double-render the same wait. */} - {pendingInputs.map(item => 'requestId' in item ? ( - - ) : ( - - ))} -
- {!scroll.followingTail && ( -
- +
+ {scroll.initialized && ( + + )} +
+
+
+ {openState === 'loading' &&
{t('chat.loadingHistory')}
} + {openState === 'error' && openError !== null && ( +
+ {t('chat.loadError', { message: openError.message, code: openError.code })} +
+ )} + {hasMore && ( +
+ +
+ )} + + + + {/* No pending placeholders: questions (ui-user-questions) and approvals + (ApprovalPanel) both take over the composer, so a flow card would + double-render the same wait. */}
- )} +
+ {!scroll.followingTail && ( +
+ +
+ )} {fileOpenError !== null && ( void, + private readonly follow: ScrollFollow, ) {} /** @@ -76,7 +78,7 @@ export class ChatReading { /** Land at the current floor and clear saved reader position. */ followTail(): void { - const landing = this.viewport.scrollToBottom() + const landing = this.viewport.scrollToBottom(this.follow) if (landing === null) return this.cancelPending() this.commit(landing, true, this.viewport.latestTurn) @@ -89,7 +91,7 @@ export class ChatReading { const landing = this.viewport.restore(saved) if (landing === null) return this.cancelPending() - const following = this.nearBottom(landing.metrics) + const following = this.follow.nearBottom(landing.metrics) this.commit(landing, following, following ? this.viewport.latestTurn : this.state.activeTurn, following) if (!this.state.followingTail && landing.position === null) { const position = this.viewport.capturePosition() @@ -104,7 +106,7 @@ export class ChatReading { */ acceptNavigation(landing: ViewportLanding): void { this.cancelPending() - const following = this.nearBottom(landing.metrics) + const following = this.follow.nearBottom(landing.metrics) this.commit(landing, following, landing.turn ?? (following ? this.viewport.latestTurn : this.state.activeTurn)) } @@ -153,10 +155,6 @@ export class ChatReading { else this.probeFrame = requestAnimationFrame(this.probe) } - private nearBottom(metrics: ViewportMetrics): boolean { - return metrics.floor - metrics.top <= FOLLOW_THRESHOLD + 1 - } - private commit( landing: ViewportLanding, followingTail: boolean, activeTurn: number | null, initialized = true, ): void { @@ -166,6 +164,7 @@ export class ChatReading { } private publish(state: ChatReadingState): void { + this.follow.setFollowing(state.followingTail) if (state.initialized === this.state.initialized && state.followingTail === this.state.followingTail && state.activeTurn === this.state.activeTurn) return this.state = state @@ -184,7 +183,7 @@ export class ChatReading { if (this.pending) return const scroll = this.viewport.readScroll() if (scroll === null) return - const activeTurn = this.nearBottom(scroll.metrics) ? this.viewport.latestTurn : this.viewport.readVisibleTurn(scroll.metrics) + const activeTurn = this.follow.nearBottom(scroll.metrics) ? this.viewport.latestTurn : this.viewport.readVisibleTurn(scroll.metrics) this.publish({ ...this.state, initialized: true, activeTurn }) } @@ -193,14 +192,14 @@ export class ChatReading { this.cancelPending() const scroll = this.viewport.readScroll() if (scroll === null) return - const followingTail = scroll.movedByReader ? this.nearBottom(scroll.metrics) : this.state.followingTail + const followingTail = this.follow.sample(scroll.metrics, scroll.movedByReader) let position: ChatScrollPosition | null = null if (!scroll.movedByReader && followingTail) this.followTail() else { position = followingTail ? null : this.viewport.capturePosition() this.viewport.acknowledge(scroll.metrics) if (followingTail || position !== null) this.store.save(position) - const activeTurn = this.nearBottom(scroll.metrics) ? this.viewport.latestTurn : this.viewport.readVisibleTurn(scroll.metrics) + const activeTurn = this.follow.nearBottom(scroll.metrics) ? this.viewport.latestTurn : this.viewport.readVisibleTurn(scroll.metrics) this.publish({ initialized: true, followingTail, activeTurn }) } this.sampled?.({ position, movedByReader: scroll.movedByReader, followingTail }) @@ -222,7 +221,8 @@ export function useChatReading( followingTail: store.read() === null, activeTurn: initialTurn, })) - const [reading] = useState(() => new ChatReading(viewport, store, state, setState)) + const follow = useScrollFollow(state.followingTail, FOLLOW_THRESHOLD + 1) + const [reading] = useState(() => new ChatReading(viewport, store, state, setState, follow)) useLayoutEffect(() => { reading.setStore(store) }, [reading, store]) useLayoutEffect(() => () => { reading.dispose() }, [reading]) return { reading, state } diff --git a/packages/client/ui-chat/src/client/chat/use-chat-viewport.ts b/packages/client/ui-chat/src/client/chat/use-chat-viewport.ts index e61020e8ff..f74153097c 100644 --- a/packages/client/ui-chat/src/client/chat/use-chat-viewport.ts +++ b/packages/client/ui-chat/src/client/chat/use-chat-viewport.ts @@ -2,13 +2,8 @@ import { useLayoutEffect, useRef, useState, type RefObject } from 'react' import type { ChatScrollPosition } from '../contract/slots.ts' import type { ChatSnapshot } from '../contract/snapshot.ts' - -/** Scroll position, maximum top, and viewport height from one geometry read. */ -export interface ViewportMetrics { - readonly top: number - readonly floor: number - readonly height: number -} +import { scrollMetrics, ScrollFollow, type ViewportMetrics } from './use-scroll-follow.ts' +export type { ViewportMetrics } from './use-scroll-follow.ts' /** Scroll geometry attributed against the last acknowledged position. */ export interface ViewportScroll { @@ -150,8 +145,7 @@ export class ChatViewport { private metrics(): ViewportMetrics | null { const scroller = this.elements?.scroller if (scroller === undefined) return null - const height = scroller.clientHeight - return { top: scroller.scrollTop, height, floor: Math.max(0, scroller.scrollHeight - height) } + return scrollMetrics(scroller) } private anchor(key: string, identity: 'position' | 'node' = 'position'): HTMLElement | null { @@ -343,6 +337,7 @@ export class ChatViewport { /** * Compensate inner scrolling first, then the outer scrollport, within their actual scroll ranges. + * An inner write pauses its bound follow controller so the reading anchor takes priority. * @returns the actual landing, or null when no visible retained row remains. */ preserve(): ViewportLanding | null { @@ -366,9 +361,16 @@ export class ChatViewport { } if (group !== null && group.body.contains(row)) { const top = row.getBoundingClientRect().top - group.body.getBoundingClientRect().top - const floor = Math.max(0, group.body.scrollHeight - group.body.clientHeight) - const target = Math.max(0, Math.min(floor, group.body.scrollTop + top - group.top)) - if (group.body.scrollTop !== target) group.body.scrollTop = target + const metrics = scrollMetrics(group.body) + const target = Math.max(0, Math.min(metrics.floor, metrics.top + top - group.top)) + if (metrics.top !== target) { + const follow = ScrollFollow.forElement(group.body) + if (follow === undefined) group.body.scrollTop = target + else { + follow.jump(group.body, metrics, target) + follow.setFollowing(false) + } + } } const metrics = this.metrics() if (metrics === null) return null @@ -379,11 +381,19 @@ export class ChatViewport { /** * Align the scrollport with its current floor. + * @param follow - independent follow intent and scrolling controller. * @returns the actual floor landing, or null while detached. */ - scrollToBottom(): ViewportLanding | null { + scrollToBottom(follow: ScrollFollow): ViewportLanding | null { const metrics = this.metrics() - return metrics === null ? null : this.write(metrics.floor, metrics, this.latestTurn) + if (metrics === null || this.elements === null) return null + const landing: ViewportLanding = { + metrics: follow.toBottom(this.elements.scroller, metrics, 'instant'), + position: null, + turn: this.latestTurn, + } + this.observation = { top: landing.metrics.top, landing } + return landing } private align(row: HTMLElement, offset: number, turn: number | null): ViewportLanding | null { diff --git a/packages/client/ui-chat/src/client/chat/use-process-scroll.ts b/packages/client/ui-chat/src/client/chat/use-process-scroll.ts new file mode 100644 index 0000000000..07fc32fcef --- /dev/null +++ b/packages/client/ui-chat/src/client/chat/use-process-scroll.ts @@ -0,0 +1,88 @@ +/** Capped process-group scrolling and fades over the shared follow controller. */ +import { useCallback, useLayoutEffect, useMemo, useRef, useState, type DOMAttributes, type KeyboardEvent, type RefObject } from 'react' +import { scrollMetrics, useScrollFollow } from './use-scroll-follow.ts' + +interface ScrollEdges { readonly canScrollUp: boolean; readonly canScrollDown: boolean } +const AT_REST: ScrollEdges = { canScrollUp: false, canScrollDown: false } +const SCROLL_KEYS = new Set(['ArrowUp', 'ArrowDown', 'PageUp', 'PageDown', 'Home', 'End', ' ']) + +/** + * Observe one group's body and content without coupling its follow intent to the outer transcript. + * Wheel, touchstart, any pointerdown, and unprevented scroll keys interrupt active animations, + * including events from editable controls; subsequent position sampling determines follow intent. + * @param bodyRef - capped scrolling body. + * @param contentRef - uncapped content whose size reports growth. + * @param open - local disclosure state. + * @param grouped - whether the display mode retains the group's height cap. + * @returns edge fades, DOM event bindings, and one-shot positioning for manual opening. + */ +export function useProcessScroll( + bodyRef: RefObject, contentRef: RefObject, open: boolean, grouped: boolean, +): { + edges: ScrollEdges + events: Pick, 'onScroll' | 'onWheel' | 'onTouchStart' | 'onPointerDown' | 'onKeyDown'> + initialize: (position: 'top' | 'bottom') => void +} { + const follow = useScrollFollow(false, 1) + const initialPosition = useRef<'top' | 'bottom' | null>(null) + const [edges, setEdges] = useState(AT_REST) + const initialize = useCallback((position: 'top' | 'bottom') => { initialPosition.current = position }, []) + const sync = useCallback((cause: 'resize' | 'scroll' | 'scrollend') => { + const body = bodyRef.current + let next = AT_REST + if (body !== null && body.closest('[hidden], [data-group-expanded-mode]') === null) { + let metrics = scrollMetrics(body) + const initial = cause === 'resize' ? initialPosition.current : null + if (initial !== null) { + metrics = follow.jump(body, metrics, initial === 'bottom' ? metrics.floor : 0) + if (initial === 'top') follow.setFollowing(false) + initialPosition.current = null + } else { + const wasAnimating = follow.animating + if (cause === 'scrollend') follow.settle(metrics) + else follow.sample(metrics) + if (follow.active && (cause === 'resize' || (cause === 'scrollend' && wasAnimating))) { + metrics = follow.toBottom(body, metrics, 'smooth') + } + } + next = { canScrollUp: metrics.top > 1, canScrollDown: metrics.top < metrics.floor - 1 } + } else follow.reset() + setEdges(previous => previous.canScrollUp === next.canScrollUp && previous.canScrollDown === next.canScrollDown + ? previous : next) + }, [bodyRef, follow]) + const interrupt = useCallback(() => { + const body = bodyRef.current + if (body !== null && follow.animating) follow.interrupt(body, scrollMetrics(body)) + }, [bodyRef, follow]) + const events = useMemo(() => ({ + onScroll: () => { sync('scroll') }, + onWheel: interrupt, + onTouchStart: interrupt, + onPointerDown: interrupt, + onKeyDown: (event: KeyboardEvent) => { + if (!event.defaultPrevented && SCROLL_KEYS.has(event.key)) interrupt() + }, + }), [interrupt, sync]) + + useLayoutEffect(() => { + interrupt() + follow.reset() + if (!grouped || !open) initialPosition.current = null + }, [follow, grouped, interrupt, open]) + useLayoutEffect(() => { + const body = bodyRef.current + if (body === null || !open || typeof ResizeObserver === 'undefined') return + const unbind = follow.bind(body) + const observer = new ResizeObserver(() => { sync('resize') }) + const onScrollEnd = (event: Event): void => { if (event.target === body) sync('scrollend') } + body.addEventListener('scrollend', onScrollEnd) + observer.observe(body) + if (contentRef.current !== null) observer.observe(contentRef.current) + return () => { + unbind() + observer.disconnect() + body.removeEventListener('scrollend', onScrollEnd) + } + }, [bodyRef, contentRef, follow, open, sync]) + return { edges, events, initialize } +} diff --git a/packages/client/ui-chat/src/client/chat/use-scroll-follow.ts b/packages/client/ui-chat/src/client/chat/use-scroll-follow.ts new file mode 100644 index 0000000000..398e89e65f --- /dev/null +++ b/packages/client/ui-chat/src/client/chat/use-scroll-follow.ts @@ -0,0 +1,173 @@ +/** Independent bottom-follow intent and native scrolling, without paging or DOM observers. */ +import { useState } from 'react' + +/** Scroll position, maximum top, and viewport height from one geometry read. */ +export interface ViewportMetrics { + readonly top: number + readonly floor: number + readonly height: number +} + +/** + * Read one scrollport without measuring its children. + * @param element - scrolling element. + * @returns current position and range. + */ +export function scrollMetrics(element: HTMLElement): ViewportMetrics { + const height = element.clientHeight + return { top: element.scrollTop, height, floor: Math.max(0, element.scrollHeight - height) } +} + +/** One scrollport's follow intent; native animation progress does not count as reader movement. */ +export class ScrollFollow { + private static readonly owners = new WeakMap() + private target: number | null = null + private sampledTop: number | undefined + + /** + * @param following - initial follow intent. + * @param threshold - accepted distance from the floor, in pixels. + */ + constructor(private following: boolean, private readonly threshold: number) {} + + /** + * Find the mounted controller for reading-position compensation. + * @param element - scrollport with an optional follow owner. + * @returns its controller, when bound. + */ + static forElement(element: HTMLElement): ScrollFollow | undefined { return this.owners.get(element) } + + /** + * Share this controller with reading-position compensation for the same scrollport. + * @param element - owned scrollport. + * @returns release the association on unmount or close. + */ + bind(element: HTMLElement): () => void { + ScrollFollow.owners.set(element, this) + return () => { if (ScrollFollow.owners.get(element) === this) ScrollFollow.owners.delete(element) } + } + + /** + * Expose follow intent independently of the current offset. + * @returns whether content growth should follow the floor. + */ + get active(): boolean { return this.following } + + /** + * Expose outstanding native motion before accepting reader input. + * @returns whether a native follow animation has an outstanding target. + */ + get animating(): boolean { return this.target !== null } + + /** + * Classify bottom arrivals using this scrollport's own tolerance. + * @param metrics - current scroll geometry. + * @returns whether the position is within the follow threshold. + */ + nearBottom(metrics: ViewportMetrics): boolean { return metrics.floor - metrics.top <= this.threshold } + + /** + * Commit caller-owned follow decisions without moving the scrollport. + * @param active - externally committed follow intent. + */ + setFollowing(active: boolean): void { + this.following = active + if (!active) this.target = null + } + + /** Adopt the next visible layout as a fresh reader position. */ + reset(): void { + this.setFollowing(false) + this.sampledTop = undefined + } + + /** + * Adopt delivered scrolling while retaining intent during native animation. + * @param metrics - current geometry. + * @param movedByReader - caller attribution; omitted callers compare the last sampled position. + * @returns updated follow intent. + */ + sample(metrics: ViewportMetrics, movedByReader = this.sampledTop === undefined + || Math.abs(metrics.top - this.sampledTop) > 0.5): boolean { + this.sampledTop = metrics.top + if (!this.animating && movedByReader) this.following = this.nearBottom(metrics) + return this.active + } + + /** + * Settle native scrolling; an off-target stop releases follow intent. + * @param metrics - actual geometry delivered at scrollend. + * @returns follow intent after completing or interrupting native motion. + */ + settle(metrics: ViewportMetrics): boolean { + const target = this.target + this.target = null + return this.sample(metrics, target === null ? undefined + : Math.abs(metrics.top - Math.min(target, metrics.floor)) > this.threshold) + } + + /** + * Position immediately and adopt the resulting follow intent. + * @param element - scrolling element. + * @param metrics - geometry before positioning. + * @param top - requested offset, clamped to the measured range. + * @returns geometry after positioning. + */ + jump(element: HTMLElement, metrics: ViewportMetrics, top: number): ViewportMetrics { + const animated = this.animating + this.target = null + const target = Math.max(0, Math.min(metrics.floor, top)) + if (animated) element.scrollTo({ top: target, behavior: 'instant' }) + else if (target !== metrics.top) element.scrollTop = target + const landed = { ...metrics, top: element.scrollTop } + this.sampledTop = landed.top + this.following = this.nearBottom(landed) + return landed + } + + /** + * Follow the measured floor, respecting reduced motion for smooth requests. + * An outstanding smooth target finishes before another is issued. + * Within-tolerance positioning is immediate while no animation is outstanding. + * @param element - scrolling element. + * @param metrics - current geometry. + * @param behavior - native animation for growth, or immediate positioning. + * @returns current geometry; smooth requests retain their starting position until native scroll delivery. + */ + toBottom(element: HTMLElement, metrics: ViewportMetrics, behavior: 'instant' | 'smooth'): ViewportMetrics { + this.following = true + if (behavior === 'instant' || metrics.top >= metrics.floor + || (!this.animating && this.nearBottom(metrics))) return this.jump(element, metrics, metrics.floor) + if (typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches) { + return this.jump(element, metrics, metrics.floor) + } + if (this.target === null) { + this.target = metrics.floor + element.scrollTo({ top: metrics.floor, behavior: 'smooth' }) + } + return metrics + } + + /** + * Cancel native motion before a reader gesture; only subsequent actual movement changes follow intent. + * @param element - scrolling element. + * @param metrics - position at interruption. + */ + interrupt(element: HTMLElement, metrics: ViewportMetrics): void { + if (!this.animating) return + this.target = null + this.sampledTop = metrics.top + element.scrollTo({ top: metrics.top, behavior: 'instant' }) + } +} + +/** + * Retain one independent follow controller without React updates for scroll samples. + * @param initial - initial follow intent. + * @param threshold - accepted distance from the floor, in pixels. + * @returns the stable controller shared by the caller's scroll and resize handlers. + */ +export function useScrollFollow(initial: boolean, threshold: number): ScrollFollow { + const [follow] = useState(() => new ScrollFollow(initial, threshold)) + return follow +} diff --git a/packages/client/ui-chat/src/client/contract/process-groups.ts b/packages/client/ui-chat/src/client/contract/process-groups.ts index 0c7bc7445a..1c2cddb6fb 100644 --- a/packages/client/ui-chat/src/client/contract/process-groups.ts +++ b/packages/client/ui-chat/src/client/contract/process-groups.ts @@ -1,5 +1,5 @@ /** Business data for Chat process groups; source Nodes remain in the Chat store. */ -export type ProcessActivity = 'read' | 'search' | 'edit' | 'commands' | 'code' +export type ProcessActivity = 'read' | 'readImage' | 'search' | 'write' | 'edit' | 'commands' | 'code' | 'webSearch' | 'webFetch' | 'subagents' | 'plan' | 'questions' | 'tools' /** Distinct-call category ranking and the current live task detail. */ @@ -7,6 +7,8 @@ export interface ProcessActivitySummary { readonly counts: readonly { readonly kind: ProcessActivity; readonly count: number }[] readonly running: ProcessActivity | undefined readonly runningDetail: string + /** Present only while the selected live activity has not reached tool/call. */ + readonly preparing?: true } /** Presentation facts for one group between independent replies or input. */ diff --git a/packages/client/ui-chat/src/client/contract/snapshot.ts b/packages/client/ui-chat/src/client/contract/snapshot.ts index bc080c3c6f..80d19ba50a 100644 --- a/packages/client/ui-chat/src/client/contract/snapshot.ts +++ b/packages/client/ui-chat/src/client/contract/snapshot.ts @@ -8,7 +8,7 @@ import type { TurnProcessSpec } from './turn-process.ts' export type { AssistantBlock, AssistantMessageNode, AssistantProviderMetadataView, AssistantRequestConfig, AssistantTiming, CommandNode, CompactionSummaryNode, ContextMessageNode, ConversationNode, - ModelRetryNode, PartialAssistant, RunningToolCall, SteeringMessageNode, TodoItem, + ModelRetryNode, PartialAssistant, PreparingToolCall, RunningToolCall, StartedToolCall, SteeringMessageNode, TodoItem, ToolCallBlock, ToolResultNode, TurnErrorNode, TurnMaxTokensNode, UnknownSurfaceNode, UserMessageNode, } from '@deepseek-ai/dsh-client-ui-conversation/client' diff --git a/packages/client/ui-chat/src/client/conversation-nodes/README.i18n.yaml b/packages/client/ui-chat/src/client/conversation-nodes/README.i18n.yaml index e3771b719e..d93cefddcd 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/README.i18n.yaml +++ b/packages/client/ui-chat/src/client/conversation-nodes/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-chat/src/client/conversation-nodes/README.md -README.md: 9dca6245db9383f8632f45b514454ad3ef201f35 -README.zh.md: b0cc2cfa51306a614da36cb9f21734136864932a +README.md: 3b6b82685c763837e706c60d8ea69b896f582ae2 +README.zh.md: ae43726417becea973b22e2f2a16871cec116584 diff --git a/packages/client/ui-chat/src/client/conversation-nodes/README.md b/packages/client/ui-chat/src/client/conversation-nodes/README.md index 9dca6245db..3b6b82685c 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/README.md +++ b/packages/client/ui-chat/src/client/conversation-nodes/README.md @@ -122,13 +122,17 @@ A closed group's header names the first three categories from its ranked summary ### Group-title rules +All three stages share the tool-name classification below. A preparing Tool node uses its category's preparation label: read files for `read`, read images for `read_image`, write files for `write`, edit files for `edit` and `apply_patch`, and update the plan for `todo_write` and goal tools. Only the generic “Preparing tool calls” category appends the wire tool name in Detailed mode; other categories omit it. It contributes one call without parsing arguments and renders one non-expandable row. A named live delta can create this node; historical calls start directly from tool/call without replaying preparation. + The labels below describe recorded activity, not successful outcomes. For example, a failed read still participates in the “Read files” category. | Category | Running label | Closed label | |---|---|---| | No live category / no counted categories | Analyzing the request | Analysis completed | | `read` | Reading files | Read files | +| `readImage` | Reading images | Read images | | `search` | Searching code | Searched code | +| `write` | Writing files | Wrote files | | `edit` | Editing files | Edited files | | `commands` | Running commands | Ran commands | | `code` | Running code | Ran code | @@ -153,6 +157,8 @@ English lowercases the initial letter of joined labels after the first. Closing Group headers show a category icon, replace it with a down arrow on hover or keyboard focus, and show an up arrow while open. Manually expanded group bodies use 8px row spacing, a `min(400px, 50vh)` height cap, and 24px directional fades. Wheel scrolling can continue into the outer transcript at an edge. Expanded mode removes the group-level cap and uses 16px row spacing only in running Turns. +An open capped group follows content growth only while its own scroll position is at the bottom. Scrolling away pauses that group's following; returning to the bottom resumes it, independently of outer transcript following. Manually opening an unclosed group starts at the bottom and follows growth; manually opening a closed group starts at the top with following disabled, even when its initial content fits without scrolling. Closing the group in the data or restoring its height cap through a mode change does not reset an already-open reader's position. Browser find retains its own reveal position. + Individual reasoning starts collapsed, including while streaming. All modes preview the latest paragraph whose first line ends with a newline; an unfinished single line has no preview. Later text in that paragraph does not change the preview. After settlement, the mode table applies. Expanded reasoning uses compact Markdown typography. Switching modes retains manually opened groups and inner disclosures. It changes visibility and sizing without recreating the message rows. Expanded does not mean opening every individual disclosure or unfolding the whole Turn. @@ -208,7 +214,7 @@ Counts describe calls in this group, not successful operations, files changed, c | Call evidence in this group | Counting rule | |---|---| -| Running call, successful result, failed result, or projected interrupted result with its original call | Count one call in the category selected by its recorded tool name. Settlement and failure do not add or subtract a call. | +| Preparing call, running call, successful result, failed result, or projected interrupted result with its original call | Count one call in the category selected by its recorded tool name. Settlement and failure do not add or subtract a call. | | Repeated `callId` | Count only the first occurrence, including when it appears both as a root and as a nested call. Later occurrences and their subtrees are skipped. | | Parent and child with different `callId` values | Count both, in their own categories; visit the parent before its children and siblings in recorded order. | | Repeated calls to the same tool with different `callId` values | Count each call, even with identical arguments or the same terminal/session target. | @@ -223,9 +229,11 @@ Classification uses the recorded tool name exactly: no case folding, namespace s | Category | Tool-name match | |---|---| -| `read` | `read`, `read_image`, `list_mcp_resources`, `list_mcp_resource_templates`, `read_mcp_resource` | +| `read` | `read` | +| `readImage` | `read_image` | | `search` | `grep`, `glob`, or suffix `_inspect` | -| `edit` | `write`, `edit`, `apply_patch` | +| `write` | `write` | +| `edit` | `edit`, `apply_patch` | | `commands` | `bash`, `pwsh`, `exec_command`, `write_stdin`, or prefix `terminal_` | | `code` | `run_code` | | `webSearch` | `web_search` | @@ -233,7 +241,7 @@ Classification uses the recorded tool name exactly: no case folding, namespace s | `subagents` | `subagent` or prefix `subagent_` | | `plan` | `todo_write`, `create_goal`, `update_goal`, `get_goal` | | `questions` | `ask_user_question`, `request_user_input` | -| `tools` | Every other name | +| `tools` | Every other name, including `list_mcp_resources`, `list_mcp_resource_templates`, and `read_mcp_resource` | ### Subagents, background jobs, and nested calls @@ -254,6 +262,8 @@ Exact-name rules also mean that a recorded name such as `functions.read`, `mcp.r ### Live activity and detail +Preparing calls use their first named delta time; only the generic tool category provides the tool name as detail. Dispatched calls use their tool/call time and complete arguments. + Among running calls, the greatest `time` selects the live category and detail; equal times select the later visited call. With no running call, the category is absent and detail comes from the last nonempty reasoning paragraph of the latest running Assistant with nonempty reasoning, in member order. Reasoning detail removes `**` markers and does not require a newline-terminated first line; the individual reasoning-row preview has separate rules. Live selection uses call start `time`, not the latest output/progress time. A call is a running candidate while its projected tool value has no result; pending approval or waiting for a result does not receive a separate category. diff --git a/packages/client/ui-chat/src/client/conversation-nodes/README.zh.md b/packages/client/ui-chat/src/client/conversation-nodes/README.zh.md index b0cc2cfa51..ae43726417 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/README.zh.md +++ b/packages/client/ui-chat/src/client/conversation-nodes/README.zh.md @@ -122,19 +122,23 @@ description: "Chat 过程分组与活动摘要的明细业务规则。" ### 组标题规则 +三个阶段共用下方工具名分类。准备中的 Tool 节点使用对应类别的准备文案;`read` 为「准备读取文件」,`read_image` 为「准备读取图片」,`write` 为「准备写入文件」,`edit` 和 `apply_patch` 为「准备编辑文件」,`todo_write` 及目标工具为「准备更新计划」。只有通用类别的「准备调用工具」在 Detailed 中追加协议工具名,其他类别不追加。它计为一次调用,不解析参数,只渲染不可展开的一行。具名实时 delta 可以创建该节点;历史调用直接从 tool/call 开始,不重现准备过程。 + 以下文案描述已记录的活动,不保证操作成功;例如失败的读取仍参与「已读取文件」类别。 | 类别 | 未结束组的活动文案 | 已结束组的摘要文案 | |---|---|---| | 无实时类别 / 无计数类别 | 正在分析请求 | 已完成分析 | | `read` | 正在读取文件 | 已读取文件 | +| `readImage` | 正在读取图片 | 已读取图片 | | `search` | 正在搜索代码 | 已搜索代码 | +| `write` | 正在写入文件 | 已写入文件 | | `edit` | 正在编辑文件 | 修改了文件 | | `commands` | 正在运行命令 | 执行了命令 | | `code` | 正在运行代码 | 运行了代码 | | `webSearch` | 正在搜索网页 | 已搜索网页 | | `webFetch` | 正在访问网页 | 已访问网页 | -| `subagents` | 正在协调子任务 | 已协调子任务 | +| `subagents` | 正在协调子代理 | 已协调子代理 | | `plan` | 正在更新计划 | 更新了计划 | | `questions` | 等待你的操作 | 向用户提出了问题 | | `tools` | 正在调用工具 | 已调用工具 | @@ -153,6 +157,8 @@ description: "Chat 过程分组与活动摘要的明细业务规则。" 组头显示类别图标,悬停或键盘聚焦时替换为向下箭头,展开时显示向上箭头。手动展开的组正文使用 8px 行间距、`min(400px, 50vh)` 限高及 24px 方向渐隐;到达边缘时滚轮可以继续滚动外层文本记录。完全展开模式仅对运行中轮次取消组级限高,使用 16px 行间距。 +已展开的限高组仅在自己的滚动位置处于底部时跟随内容增长。滚离底部暂停该组跟随,滚回底部后恢复,与外层文本记录的跟随状态独立。手动展开未关闭的组时定位到底部并跟随增长;手动展开已关闭的组时从顶部开始且不启用跟随,即使初始内容无需滚动即可容纳。数据中的组变为已关闭,或通过模式切换恢复限高时,不重置已展开组的阅读位置。浏览器查找沿用自身的展开定位。 + 单个推理初始收起,包括流式输出期间。三档都预览最近一个首行以换行结束的段落;未完成的单行没有预览,该段落后续文本不改变预览。结算后按上表展示。展开的推理使用紧凑 Markdown 排版。 切换模式保留手动展开的组和内部开合状态,只改变显隐与尺寸,不重建消息行。完全展开不表示自动打开每个内部控件,也不表示展开整轮。 @@ -208,7 +214,7 @@ description: "Chat 过程分组与活动摘要的明细业务规则。" | 本组中的调用记录 | 计数规则 | |---|---| -| 运行中调用、成功结果、失败结果,或保留原调用信息的中断结果 | 按记录的工具名计入对应类别一次;结算或失败不会增加或扣除次数。 | +| 准备中调用、运行中调用、成功结果、失败结果,或保留原调用信息的中断结果 | 按记录的工具名计入对应类别一次;结算或失败不会增加或扣除次数。 | | 重复的 `callId` | 只计首次出现,包括同时作为根调用和嵌套调用出现的情况;后续重复记录及其子树不再处理。 | | 父子调用的 `callId` 不同 | 父子分别按各自类别计数;父调用先于子调用,同级按记录顺序处理。 | | 同一工具使用不同 `callId` 多次调用 | 每次都计数,即使参数相同,或操作的是同一个终端、Session。 | @@ -223,9 +229,11 @@ description: "Chat 过程分组与活动摘要的明细业务规则。" | 类别 | 工具名匹配 | |---|---| -| `read` | `read`、`read_image`、`list_mcp_resources`、`list_mcp_resource_templates`、`read_mcp_resource` | +| `read` | `read` | +| `readImage` | `read_image` | | `search` | `grep`、`glob`,或以 `_inspect` 结尾 | -| `edit` | `write`、`edit`、`apply_patch` | +| `write` | `write` | +| `edit` | `edit`、`apply_patch` | | `commands` | `bash`、`pwsh`、`exec_command`、`write_stdin`,或以 `terminal_` 开头 | | `code` | `run_code` | | `webSearch` | `web_search` | @@ -233,7 +241,7 @@ description: "Chat 过程分组与活动摘要的明细业务规则。" | `subagents` | `subagent`,或以 `subagent_` 开头 | | `plan` | `todo_write`、`create_goal`、`update_goal`、`get_goal` | | `questions` | `ask_user_question`、`request_user_input` | -| `tools` | 其他所有名称 | +| `tools` | 其他所有名称,包括 `list_mcp_resources`、`list_mcp_resource_templates`、`read_mcp_resource` | ### Subagent、后台任务与嵌套调用 @@ -254,6 +262,8 @@ Subagent 工具遵循普通分组边界,不会仅因委派任务就单独成 ### 实时活动与详情 +准备中的调用使用首个具名 delta 的时间,只有通用工具类别提供工具名详情。已派发调用使用 tool/call 的时间和完整参数。 + 运行中的调用以最大 `time` 决定实时类别和详情;时间相同时取后遍历的调用。没有运行调用时,类别为空,详情取成员顺序中最近一个含有非空推理段落的运行中 Assistant 的最后一个非空段落。推理详情移除 `**` 标记,不要求首行以换行结束;单个推理行的预览另有规则。 实时选择依据调用开始的 `time`,不是最近一次输出或进度的时间。投影后的工具值尚无结果时就参与运行中候选;等待批准或等待结果不会产生另一个类别。 diff --git a/packages/client/ui-chat/src/client/conversation-nodes/process-activity.ts b/packages/client/ui-chat/src/client/conversation-nodes/process-activity.ts index d6766b1954..b072b29d7e 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/process-activity.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/process-activity.ts @@ -5,9 +5,11 @@ import { isRunningTool } from '../contract/chat-nodes.ts' import type { ToolCallBlock } from '../contract/snapshot.ts' function activity(name: string): ProcessActivity { - if (['read', 'read_image', 'list_mcp_resources', 'list_mcp_resource_templates', 'read_mcp_resource'].includes(name)) return 'read' + if (name === 'read') return 'read' + if (name === 'read_image') return 'readImage' if (name === 'grep' || name === 'glob' || name.endsWith('_inspect')) return 'search' - if (['write', 'edit', 'apply_patch'].includes(name)) return 'edit' + if (name === 'write') return 'write' + if (name === 'edit' || name === 'apply_patch') return 'edit' if (['bash', 'pwsh', 'exec_command', 'write_stdin'].includes(name) || name.startsWith('terminal_')) return 'commands' if (name === 'run_code') return 'code' if (name === 'web_search') return 'webSearch' @@ -95,6 +97,7 @@ export function processActivity(nodes: readonly ChatNode[]): ProcessActivitySumm let running: ProcessActivity | undefined let runningDetail = '' let runningTime = -Infinity + let preparing: boolean | undefined const visit = (tool: ToolCallBlock): void => { if (seen.has(tool.callId)) return seen.add(tool.callId) @@ -103,7 +106,10 @@ export function processActivity(nodes: readonly ChatNode[]): ProcessActivitySumm const kind = activity(call.name) if (isRunningTool(tool) && tool.time >= runningTime) { running = kind - runningDetail = liveToolDetail(tool.name, tool.argsRaw) + preparing = tool.phase === 'preparing' + runningDetail = tool.phase === 'preparing' + ? kind === 'tools' ? tool.name : '' + : liveToolDetail(tool.name, tool.argsRaw) runningTime = tool.time } counts.set(kind, (counts.get(kind) ?? 0) + 1) @@ -118,5 +124,6 @@ export function processActivity(nodes: readonly ChatNode[]): ProcessActivitySumm counts: [...counts].map(([kind, count]) => ({ kind, count })).sort((a, b) => b.count - a.count), running, runningDetail, + ...preparing ? { preparing: true } : {}, } } diff --git a/packages/client/ui-chat/src/client/conversation-nodes/process-groups.ts b/packages/client/ui-chat/src/client/conversation-nodes/process-groups.ts index bff0c532ac..36cb552d32 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/process-groups.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/process-groups.ts @@ -29,6 +29,7 @@ function reply(node: ChatNode): boolean { function sameSummary(left: ProcessActivitySummary, right: ProcessActivitySummary): boolean { return left.running === right.running && left.runningDetail === right.runningDetail + && left.preparing === right.preparing && left.counts.length === right.counts.length && left.counts.every((value, index) => value.kind === right.counts[index]?.kind && value.count === right.counts[index].count) } @@ -66,7 +67,7 @@ class ProcessGroup { const unchanged = nodes.length === this.nodes.length && nodes.every((node, index) => node === this.nodes[index]) const previous = this.snapshot.data const activity = unchanged && previous.closed === closed ? previous.summary : processActivity(nodes) - const summary = closed ? { ...activity, running: undefined, runningDetail: '' } : activity + const summary = closed ? { counts: activity.counts, running: undefined, runningDetail: '' } : activity this.nodes = nodes if (previous.closed !== closed || !sameSummary(previous.summary, summary)) { this.snapshot = { diff --git a/packages/client/ui-chat/src/client/conversation-nodes/request-prompt.ts b/packages/client/ui-chat/src/client/conversation-nodes/request-prompt.ts index fd14a8115c..fbaf31f882 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/request-prompt.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/request-prompt.ts @@ -68,6 +68,7 @@ export function systemMessageDefinition(inspect: SystemPromptInspector): Convers ? { id: String(event.seq), role: 'start' } : null, start: (_context, match, reader) => { + if (match.event.type === 'assistant/live-chunk') throw new Error('system-message requires a durable event') return inspect(reader.previous('system-message')?.state, match.event) }, update: context => context.state, diff --git a/packages/client/ui-chat/src/client/conversation-nodes/tool.ts b/packages/client/ui-chat/src/client/conversation-nodes/tool.ts index 2e914e5072..ffbc94de5a 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/tool.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/tool.ts @@ -1,12 +1,12 @@ import type { Context } from '@deepseek-ai/cordis' import type { - ConversationMatch, ConversationNodeContext, ConversationNodeDefinition, RunningToolCall, + ConversationMatch, ConversationNodeContext, ConversationNodeDefinition, RunningToolCall, StartedToolCall, ToolCallBlock, ToolResultNode, } from '@deepseek-ai/dsh-client-ui-conversation/client' import { isAppendSurfaceEvent } from '@deepseek-ai/dsh-session/surface' import type {} from '@deepseek-ai/dsh-tools/types' -import type { ToolChatData } from '../contract/chat-nodes.ts' -import { CHAT_SYNTHETIC_SEQ_OFFSETS, chatNode } from './common.ts' +import type { ChatNode, ToolChatData } from '../contract/chat-nodes.ts' +import { CHAT_SYNTHETIC_SEQ_OFFSETS, chatNode, contextLocation } from './common.ts' declare module '../contract/chat-nodes.ts' { interface ChatNodeDataMap { @@ -37,8 +37,20 @@ function jsonArguments(value: unknown): string { } function rootCall(match: ConversationMatch): RunningToolCall { + const event = match.event + if (event.type === 'assistant/live-chunk') { + const chunk = event.data.chunk + if (chunk.type !== 'tool-call-delta' || !chunk.name) throw new Error('tool preparation requires a named call delta') + return { + phase: 'preparing', + callId: String(chunk.id), name: chunk.name, + turn: event.data.turn, step: event.data.step, time: event.time, + subCalls: [], + } + } if (match.event.type !== 'tool/call') throw new Error('tool-call start requires tool/call') return { + phase: 'start', callId: String(match.event.data.callId), name: match.event.data.name, argsRaw: match.event.data.arguments, @@ -49,7 +61,7 @@ function rootCall(match: ConversationMatch): RunningToolCall { } } -function rootResult(match: ConversationMatch, previous?: RunningToolCall): ToolResultNode | undefined { +function rootResult(match: ConversationMatch, previous?: StartedToolCall): ToolResultNode | undefined { if (match.event.type !== 'tool/result') return undefined const message = match.event.data.message return { @@ -77,8 +89,9 @@ interface DispatchData { readonly content?: ToolResultNode['content'] } -function childCall(match: ConversationMatch, data: DispatchData): RunningToolCall { +function childCall(match: ConversationMatch, data: DispatchData): StartedToolCall { return { + phase: 'start', callId: data.subCallId, parentCallId: data.parentCallId, name: data.name, @@ -174,6 +187,7 @@ function projectBlock( visited = new Set(), depth = 1, ): ToolCallBlock { + if (!('kind' in block) && block.phase === 'preparing') return block if (visited.has(block.callId) || depth > MAX_DEPTH) return { ...block, subCalls: [] } const nextVisited = new Set(visited) nextVisited.add(block.callId) @@ -229,11 +243,16 @@ function fallbackState(context: ConversationNodeContext): ToolState | return state } -/** Root Tool lifecycle and nested PTC dispatch Definition. */ +/** Root Tool preparation, dispatch, result, and nested PTC calls. */ export const toolDefinition: ConversationNodeDefinition = { kind: 'tool-call', target: 'chat', match: (event) => { + if (event.type === 'assistant/live-chunk') { + const chunk = event.data.chunk + return chunk.type === 'tool-call-delta' && chunk.name + ? { id: String(chunk.id), role: 'start' } : null + } if (event.type === 'tool/call') return { id: String(event.data.callId), role: 'start' } if (event.type === 'tool/result' && isAppendSurfaceEvent(event)) { return { id: String(event.data.message.source.callId), role: 'update' } @@ -248,20 +267,33 @@ export const toolDefinition: ConversationNodeDefinition = { }, start: (_context, match) => ({ root: rootCall(match), children: new Map(), parents: new Map() }), update: (context, match) => { + if (match.event.type === 'tool/call') return { ...context.state, root: rootCall(match) } if (match.event.type === 'tool/result') { - const running = 'kind' in context.state.root ? undefined : context.state.root + const root = context.state.root + const running = !('kind' in root) && root.phase === 'start' ? root : undefined const result = rootResult(match, running) return result === undefined ? context.state : { ...context.state, root: result } } return updateDispatch(context.state, match) }, + publication: match => match.event.type === 'assistant/live-chunk' ? 'animation-frame' : 'immediate', buildViewNode: (context) => { + const current = context.current.get('chat') as ChatNode<'tool-call'> | null | undefined const state = context.state ?? fallbackState(context) - if (state === undefined) return null - const projected = projectBlock(state.root, state, interruption(context)) + if (state === undefined) { + return current == null ? null : current.visibility === 'hidden' ? current : { ...current, visibility: 'hidden' } + } + const interruptedAt = interruption(context) + const projected = projectBlock(state.root, state, interruptedAt) const anchor = context.start?.event.seq ?? ('kind' in state.root ? state.root.seq : context.matches[0]?.event.seq ?? 0) - return chatNode(context, 'tool-call', anchor, { root: projected } satisfies ToolChatData) + const preparing = !('kind' in projected) && projected.phase === 'preparing' + const visibility = preparing && interruptedAt !== undefined ? 'hidden' : 'visible' + const location = contextLocation(context) + const data = current?.data.root === projected ? current.data : { root: projected } satisfies ToolChatData + if (current?.data === data && current.anchorSeq === anchor + && current.visibility === visibility && current.location === location) return current + return chatNode(context, 'tool-call', anchor, data, { visibility, location }) }, } diff --git a/packages/client/ui-chat/src/client/index.ts b/packages/client/ui-chat/src/client/index.ts index b2a1ca67fe..0ef02f17c7 100644 --- a/packages/client/ui-chat/src/client/index.ts +++ b/packages/client/ui-chat/src/client/index.ts @@ -18,7 +18,7 @@ export type { AssistantTiming, ChatLocationNodeIndex, ChatNodeProcessSource, ChatNodeSource, ChatNodeStore, ChatSnapshot, ChatTurnNavigationIndex, ChatTurnProcessPresentation, CommandNode, CompactionSummaryNode, ContextMessageNode, ConversationNode, - LegacyConversationSlice, ModelRetryNode, PartialAssistant, RunningToolCall, + LegacyConversationSlice, ModelRetryNode, PartialAssistant, PreparingToolCall, RunningToolCall, StartedToolCall, SteeringMessageNode, ToolCallBlock, ToolResultNode, TurnErrorNode, TurnMaxTokensNode, TurnNavigationItem, UnknownSurfaceNode, UserMessageNode, } from './contract/snapshot.ts' diff --git a/packages/client/ui-chat/src/client/locale.ts b/packages/client/ui-chat/src/client/locale.ts index 9638f554e0..5a5eb9f094 100644 --- a/packages/client/ui-chat/src/client/locale.ts +++ b/packages/client/ui-chat/src/client/locale.ts @@ -7,25 +7,42 @@ export const NS = 'chat' export const zh = { 'message.stepProcess.thinking': '正在分析请求', 'message.stepProcess.read': '正在读取文件', + 'message.stepProcess.readImage': '正在读取图片', + 'message.stepProcess.write': '正在写入文件', 'message.stepProcess.search': '正在搜索代码', 'message.stepProcess.edit': '正在编辑文件', 'message.stepProcess.commands': '正在运行命令', 'message.stepProcess.code': '正在运行代码', 'message.stepProcess.webSearch': '正在搜索网页', 'message.stepProcess.webFetch': '正在访问网页', - 'message.stepProcess.subagents': '正在协调子任务', + 'message.stepProcess.subagents': '正在协调子智能体', 'message.stepProcess.plan': '正在更新计划', 'message.stepProcess.questions': '等待你的操作', 'message.stepProcess.tools': '正在调用工具', + 'message.stepProcess.prepare.read': '准备读取文件', + 'message.stepProcess.prepare.readImage': '准备读取图片', + 'message.stepProcess.prepare.write': '准备写入文件', + 'message.stepProcess.prepare.search': '准备搜索代码', + 'message.stepProcess.prepare.edit': '准备编辑文件', + 'message.stepProcess.prepare.commands': '准备运行命令', + 'message.stepProcess.prepare.code': '准备运行代码', + 'message.stepProcess.prepare.webSearch': '准备搜索网页', + 'message.stepProcess.prepare.webFetch': '准备访问网页', + 'message.stepProcess.prepare.subagents': '准备协调子智能体', + 'message.stepProcess.prepare.plan': '准备更新计划', + 'message.stepProcess.prepare.questions': '准备提问', + 'message.stepProcess.prepare.tools': '准备调用工具', 'message.stepProcess.done.thinking': '已完成分析', 'message.stepProcess.done.read': '已读取文件', + 'message.stepProcess.done.readImage': '已读取图片', + 'message.stepProcess.done.write': '已写入文件', 'message.stepProcess.done.search': '已搜索代码', 'message.stepProcess.done.edit': '修改了文件', 'message.stepProcess.done.commands': '执行了命令', 'message.stepProcess.done.code': '运行了代码', 'message.stepProcess.done.webSearch': '已搜索网页', 'message.stepProcess.done.webFetch': '已访问网页', - 'message.stepProcess.done.subagents': '已协调子任务', + 'message.stepProcess.done.subagents': '已协调子智能体', 'message.stepProcess.done.plan': '更新了计划', 'message.stepProcess.done.questions': '向用户提出了问题', 'message.stepProcess.done.tools': '已调用工具', @@ -165,6 +182,8 @@ export type ChatKey = keyof typeof zh export const en = { 'message.stepProcess.thinking': 'Analyzing the request', 'message.stepProcess.read': 'Reading files', + 'message.stepProcess.readImage': 'Reading images', + 'message.stepProcess.write': 'Writing files', 'message.stepProcess.search': 'Searching code', 'message.stepProcess.edit': 'Editing files', 'message.stepProcess.commands': 'Running commands', @@ -175,8 +194,23 @@ export const en = { 'message.stepProcess.plan': 'Updating the plan', 'message.stepProcess.questions': 'Waiting for your action', 'message.stepProcess.tools': 'Calling tools', + 'message.stepProcess.prepare.read': 'Preparing to read files', + 'message.stepProcess.prepare.readImage': 'Preparing to read images', + 'message.stepProcess.prepare.write': 'Preparing to write files', + 'message.stepProcess.prepare.search': 'Preparing to search code', + 'message.stepProcess.prepare.edit': 'Preparing to edit files', + 'message.stepProcess.prepare.commands': 'Preparing to run commands', + 'message.stepProcess.prepare.code': 'Preparing to run code', + 'message.stepProcess.prepare.webSearch': 'Preparing to search the web', + 'message.stepProcess.prepare.webFetch': 'Preparing to visit web pages', + 'message.stepProcess.prepare.subagents': 'Preparing to coordinate subagents', + 'message.stepProcess.prepare.plan': 'Preparing to update the plan', + 'message.stepProcess.prepare.questions': 'Preparing questions', + 'message.stepProcess.prepare.tools': 'Preparing tool calls', 'message.stepProcess.done.thinking': 'Analysis completed', 'message.stepProcess.done.read': 'Read files', + 'message.stepProcess.done.readImage': 'Read images', + 'message.stepProcess.done.write': 'Wrote files', 'message.stepProcess.done.search': 'Searched code', 'message.stepProcess.done.edit': 'Edited files', 'message.stepProcess.done.commands': 'Ran commands', diff --git a/packages/client/ui-chat/src/client/model/tool-call-tree.ts b/packages/client/ui-chat/src/client/model/tool-call-tree.ts index 8426873941..1f29e3c121 100644 --- a/packages/client/ui-chat/src/client/model/tool-call-tree.ts +++ b/packages/client/ui-chat/src/client/model/tool-call-tree.ts @@ -58,6 +58,7 @@ export class ToolCallTree { if (event.type === 'tool/ptc-dispatch-start') { const data = event.data const running: RunningToolCall = { + phase: 'start', callId: data.subCallId, parentCallId: data.parentCallId, name: data.name, diff --git a/packages/client/ui-chat/tests/approval-command.client.spec.tsx b/packages/client/ui-chat/tests/approval-command.client.spec.tsx index 5a38f1eb4e..6b18ea146a 100644 --- a/packages/client/ui-chat/tests/approval-command.client.spec.tsx +++ b/packages/client/ui-chat/tests/approval-command.client.spec.tsx @@ -30,18 +30,19 @@ describe('ApprovalCommand', () => { it('renders the running correlated Tool command', () => { render() expect(screen.getByText('pnpm test')).toBeTruthy() }) - it('omits absent, uncorrelated, and settled Tool calls', () => { + it('omits absent, uncorrelated, preparing, and settled Tool calls', () => { const { container, rerender } = render( { expect(container.textContent).toBe('') rerender() expect(container.textContent).toBe('') }) diff --git a/packages/client/ui-chat/tests/chat-view.client.spec.tsx b/packages/client/ui-chat/tests/chat-view.client.spec.tsx index c48aa12df5..3068bff875 100644 --- a/packages/client/ui-chat/tests/chat-view.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-view.client.spec.tsx @@ -9,7 +9,7 @@ import { useEffect } from 'react' import type { AssistantMessageNode, ChatNode, ChatNodeHookContext, ChatNodeOwnerProps, ChatSnapshot, ChatViewSlotProps, CommandNode, CompactionSummaryNode, ContextMessageNode, ConversationNode, - LegacyConversationSlice, ModelRetryNode, RunningToolCall, SteeringMessageNode, + LegacyConversationSlice, ModelRetryNode, StartedToolCall, SteeringMessageNode, ToolCallBlock, ToolResultNode, TurnErrorNode, TurnMaxTokensNode, UseChatNodeTurnData, TranscriptViewMode, UserMessageNode, } from '@deepseek-ai/dsh-client-ui-chat/client' @@ -206,8 +206,8 @@ const toolResult = (seq: number, callId: string, name = 'bash'): ToolResultNode callTime: seq * 1_000 - 500, content: [], isError: false, subCalls: [], }) -const runningCall = (callId: string, name = 'bash'): RunningToolCall => ({ - callId, name, argsRaw: `{"command":"cmd-${callId}"}`, turn: 2, step: 1, time: 1_000, subCalls: [], +const runningCall = (callId: string, name = 'bash'): StartedToolCall => ({ + phase: 'start' as const, callId, name, argsRaw: `{"command":"cmd-${callId}"}`, turn: 2, step: 1, time: 1_000, subCalls: [], }) const command = (over: Partial = {}): CommandNode => ({ kind: 'command', seq: 5, time: 5_000, commandId: 'cmd-1' as CommandNode['commandId'], @@ -755,7 +755,11 @@ describe('ChatView', () => { expect(bodies[1]!.hasAttribute('hidden')).toBe(true) }) - it('waits for observed layout before measuring an opened process group', () => { + it.each([ + { initialHeight: 200, closed: true }, + { initialHeight: 600, closed: true }, + { initialHeight: 600, closed: false }, + ])('opens and follows a $initialHeight px process group with closed=$closed', ({ initialHeight, closed }) => { const observers = new Set() class Observer implements ResizeObserver { readonly targets = new Set() @@ -769,12 +773,13 @@ describe('ChatView', () => { const h = makeHarness({}, {}, snapshot) const key = 'observed-process' as GroupKey const groups = new ConversationGroupStore() + const group: GroupSnapshot = { + key, members: [{ kind: 'node', key: snapshot.order[0] as NodeKey }], + data: { turn: 1, closed, summary: { counts: [], running: undefined, runningDetail: '' } }, + } groups.prepareAndInstall({ entries: [{ kind: 'group', key }], - groups: { kind: 'replace', snapshots: [{ - key, members: [{ kind: 'node', key: snapshot.order[0] as NodeKey }], - data: { turn: 1, closed: true, summary: { counts: [], running: undefined, runningDetail: '' } }, - }] }, + groups: { kind: 'replace', snapshots: [group] }, }, key => snapshot.nodes.get(key)) h.setGrouped(groups) const view = render() @@ -782,10 +787,23 @@ describe('ChatView', () => { const body = view.container.querySelector('[data-step-process-body]')! let reads = 0 let top = 0 + let height = initialHeight + let animatedTop: number | null = null + const scrollTo = vi.fn((options: ScrollToOptions) => { + if (options.behavior === 'smooth') animatedTop = options.top ?? top + else { animatedTop = null; top = options.top ?? top } + }) + const finishScroll = (): void => { + top = animatedTop ?? top + animatedTop = null + fireEvent.scroll(body) + fireEvent(body, new Event('scrollend')) + } Object.defineProperties(body, { - scrollTop: { get: () => { reads++; return top } }, + scrollTop: { get: () => { reads++; return top }, set: (value: number) => { top = value } }, clientHeight: { get: () => { reads++; return 200 } }, - scrollHeight: { get: () => { reads++; return 600 } }, + scrollHeight: { get: () => { reads++; return height } }, + scrollTo: { value: scrollTo }, }) fireEvent.click(header) @@ -793,16 +811,87 @@ describe('ChatView', () => { const observer = [...observers].find(observer => observer.targets.has(body))! expect(observer).toBeDefined() act(() => { observer.callback([], observer) }) - expect(body.hasAttribute('data-scroll-up')).toBe(false) - expect(body.hasAttribute('data-scroll-down')).toBe(true) + expect(top).toBe(closed ? 0 : initialHeight - 200) + expect(body.hasAttribute('data-scroll-up')).toBe(!closed && initialHeight > 200) + expect(body.hasAttribute('data-scroll-down')).toBe(closed && initialHeight > 200) + + height = 800 + act(() => { observer.callback([], observer) }) + finishScroll() + expect(top).toBe(closed ? 0 : 600) + top = 400 fireEvent.scroll(body) expect(body.hasAttribute('data-scroll-up')).toBe(true) + expect(body.hasAttribute('data-scroll-down')).toBe(true) + height = 1_000 + act(() => { observer.callback([], observer) }) + expect(top).toBe(400) + + top = 800 + fireEvent.scroll(body) + height = 1_200 + act(() => { observer.callback([], observer) }) + finishScroll() + expect(top).toBe(1_000) expect(body.hasAttribute('data-scroll-down')).toBe(false) + + height = 1_400 + act(() => { observer.callback([], observer) }) + expect(animatedTop).toBe(1_200) + const beforeNestedEnd = reads + fireEvent(body.firstElementChild!, new Event('scrollend', { bubbles: true })) + expect(reads).toBe(beforeNestedEnd) + // Find/focus can reposition the body without firing its input handlers. + top = 900 + animatedTop = null + const requests = scrollTo.mock.calls.length + finishScroll() + expect(animatedTop).toBeNull() + expect(scrollTo).toHaveBeenCalledTimes(requests) + height = 1_600 + act(() => { observer.callback([], observer) }) + expect(top).toBe(900) + expect(scrollTo).toHaveBeenCalledTimes(requests) const measured = reads fireEvent.click(header) expect(reads).toBe(measured) expect(observers.has(observer)).toBe(false) + + height = 1_400 + fireEvent.click(header) + expect(reads).toBe(measured) + const reopened = [...observers].find(observer => observer.targets.has(body))! + act(() => { reopened.callback([], reopened) }) + expect(top).toBe(closed ? 0 : 1_200) + expect(body.hasAttribute('data-scroll-down')).toBe(closed) + + top = 400 + fireEvent.scroll(body) + act(() => { + groups.prepareAndInstall({ groups: { kind: 'apply', removes: [], upserts: [{ + ...group, data: { ...group.data, closed: true }, + }] } }, key => snapshot.nodes.get(key)) + groups.publish() + }) + expect(top).toBe(400) + expect(observers.has(reopened)).toBe(true) + height = 1_600 + act(() => { reopened.callback([], reopened) }) + expect(top).toBe(400) + fireEvent.click(header) + fireEvent.click(header) + const historical = [...observers].find(observer => observer.targets.has(body))! + act(() => { historical.callback([], historical) }) + expect(top).toBe(0) + + top = 300 + fireEvent.scroll(body) + fireEvent.click(header) + fireEvent(body, new Event('beforematch')) + const found = [...observers].find(observer => observer.targets.has(body))! + act(() => { found.callback([], found) }) + expect(top).toBe(300) }) it('does not recalculate settled group titles when work-details mode changes', () => { @@ -972,7 +1061,7 @@ describe('ChatView', () => { const view = render() const second = await view.findByRole('button', { name: '跳转到第 2 轮' }) - const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement + const scroller = view.container.querySelector('[data-chat-flow]')!.parentElement as HTMLDivElement const metrics = installScrollMetrics(scroller, 1_000, 300) metrics.setLayout(1_000, 700) vi.spyOn(scroller, 'getBoundingClientRect').mockReturnValue({ top: 0, bottom: 300 } as DOMRect) @@ -1035,7 +1124,7 @@ describe('ChatView', () => { ] }) const view = render() const first = await view.findByRole('button', { name: '跳转到第 1 轮' }) - const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement + const scroller = view.container.querySelector('[data-chat-flow]')!.parentElement as HTMLDivElement installScrollMetrics(scroller, 1_500, 300) const row = view.container.querySelector('[data-chat-anchor-key="fixture:user:1"]') as HTMLElement vi.spyOn(scroller, 'getBoundingClientRect').mockReturnValue(new DOMRect(0, 0, 800, 300)) @@ -1080,7 +1169,7 @@ describe('ChatView', () => { expect(view.getByRole('button', { name: '回到底部' })).toBeTruthy() // ...so a non-reader scroll delivery at the floor (the first prepend's // compensation fires one) no longer snaps to the tail and cancel the jump. - const scroller = view.container.querySelector('[class*="scroll"]') as HTMLElement + const scroller = view.container.querySelector('[data-chat-flow]')!.parentElement as HTMLElement fireEvent.scroll(scroller) expect(first.getAttribute('aria-busy')).toBe('true') await act(async () => { releaseJump?.() }) @@ -1149,7 +1238,7 @@ describe('ChatView', () => { let releaseJump: (() => void) | undefined h.loadThrough.mockImplementation(() => new Promise((resolve) => { releaseJump = resolve })) const view = render() - const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement + const scroller = view.container.querySelector('[data-chat-flow]')!.parentElement as HTMLDivElement installScrollMetrics(scroller, 1_000, 300) scroller.scrollTop = 700 vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) { @@ -1197,7 +1286,7 @@ describe('ChatView', () => { { hasMore: true }, ) const view = render() - const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement + const scroller = view.container.querySelector('[data-chat-flow]')!.parentElement as HTMLDivElement const first = view.container.querySelector('[data-chat-flow-key="fixture:user:9"]') as HTMLDivElement const next = view.container.querySelector('[data-chat-flow-key="fixture:user:10"]') as HTMLDivElement let firstTop = 100 @@ -1256,7 +1345,7 @@ describe('ChatView', () => { const pinned = anchor === 'group' ? group : old const expectedTop = anchor === 'group' ? 30 : 60 const expectedScroll = anchor === 'group' ? 200 : 300 - const scroller = view.container.querySelector('[class*="scroll"]')! + const scroller = view.container.querySelector('[data-chat-flow]')!.parentElement! installScrollMetrics(scroller, 1500, 400) let paged = false vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) { @@ -1326,7 +1415,7 @@ describe('ChatView', () => { h.setGrouped(groups) h.setTranscriptView(mode) const view = render() - const scroller = view.container.querySelector('[class*="scroll"]')! + const scroller = view.container.querySelector('[data-chat-flow]')!.parentElement! const column = scroller.querySelector('[data-chat-flow]')! const answer = view.container.querySelector('[data-chat-node-key="fixture:assistant:6"]')! const first = hasSteering ? view.container.querySelector('[data-chat-node-key="fixture:steering:5"]')! : answer @@ -1380,7 +1469,7 @@ describe('ChatView', () => { try { const h = makeHarness({ nodes: [user(1, 'visible row')] }) const view = render() - const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement + const scroller = view.container.querySelector('[data-chat-flow]')!.parentElement as HTMLDivElement const anchor = view.container.querySelector('[data-chat-anchor-key="fixture:user:1"]') as HTMLElement installScrollMetrics(scroller, 4_000, 2_000) vi.spyOn(scroller, 'getBoundingClientRect').mockReturnValue({ @@ -1411,7 +1500,7 @@ describe('ChatView', () => { { hasMore: true }, ) const view = render() - const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement + const scroller = view.container.querySelector('[data-chat-flow]')!.parentElement as HTMLDivElement const rows = [...view.container.querySelectorAll('[data-chat-flow-key]')] let prepended = false let rowRectCalls = 0 @@ -1596,6 +1685,14 @@ describe('ChatView', () => { ) const view = render() expect(view.getByText('即发即显').closest('[data-submission-echo]')).not.toBeNull() + const echo = view.getByText('即发即显').closest('[data-submission-echo]') + act(() => { h.setSession({ testInbox: { 'next-step': [], 'next-turn': [{ + id: 'idle-occurrence' as never, role: 'user', source: { kind: 'user', rpcId: 'req-1' as never }, + content: [{ type: 'text', text: '即发即显' }], + }] } }) }) + expect(view.getByText('即发即显').closest('[data-submission-echo]')).toBe(echo) + act(() => { h.setSession({ testInbox: { 'next-step': [], 'next-turn': [] } }) }) + expect(view.getByText('即发即显').closest('[data-submission-echo]')).toBe(echo) // The durable node arrives while the echo is STILL in the session // snapshot: the render-time rpcId dedupe keeps exactly one bubble. @@ -1619,10 +1716,99 @@ describe('ChatView', () => { expect(view.getAllByText('即发即显')).toHaveLength(1) }) + it.each(['compact', 'detailed', 'expanded'] as const)( + 'keeps the opening echo above a new Turn title and steering below it (%s)', (mode) => { + const opening = { + requestId: 'opening' as never, placement: 'transcript' as const, + time: 5_000, text: 'opening input', attachments: [], + } + const steer = { + requestId: 'steer' as never, placement: 'steering' as const, + time: 6_000, text: 'later steering', attachments: [], + } + const h = makeHarness({}, { pendingSubmissions: [opening, steer] }) + h.setTranscriptView(mode) + const view = render() + const echo = view.getByText(opening.text).closest('[data-submission-echo]')! + const steeringEcho = view.getByText(steer.text).closest('[data-submission-echo]')! + act(() => { h.setChat({ turnTimings: new Map([[1, { startTime: 1_000 }]]) }) }) + const title = view.container.querySelector('[data-chat-flow-kind="turn-process"]')! + expect(title).not.toBeNull() + expect(view.getByText(opening.text).closest('[data-submission-echo]')).toBe(echo) + expect(echo.compareDocumentPosition(title) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy() + expect(title.compareDocumentPosition(steeringEcho) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy() + const admitted: UserMessageNode & { turn: number } = { + ...user(2, opening.text), turn: 1, source: { kind: 'user', rpcId: opening.requestId }, + } + act(() => { h.setChat({ nodes: [admitted] }) }) + expect(view.getAllByText(opening.text)).toHaveLength(1) + expect(view.getByText(steer.text).closest('[data-submission-echo]')).toBe(steeringEcho) + expect(view.getByText(opening.text).compareDocumentPosition(title) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy() + }, + ) + + it('hands off an opening echo when the Turn and input arrive in one render', () => { + const h = makeHarness({}, { pendingSubmissions: [{ + requestId: 'same-frame' as never, placement: 'transcript', time: 1_000, text: 'same frame', attachments: [], + }] }) + const view = render() + const admitted: UserMessageNode & { turn: number } = { + ...user(2, 'same frame'), turn: 1, source: { kind: 'user', rpcId: 'same-frame' }, + } + act(() => { h.setChat({ nodes: [admitted], turnTimings: new Map([[1, { startTime: 1_000 }]]) }) }) + expect(view.getAllByText('same frame')).toHaveLength(1) + expect(view.container.querySelector('[data-submission-echo]')).toBeNull() + expect(renderedFlowKinds(view.container)).toEqual(['user', 'turn-process']) + }) + + it.each(['ABC', 'ACB', 'BAC', 'BCA', 'CAB', 'CBA'])( + 'uses the first local opening echo until Host admission identifies the input (%s)', (hostOrder) => { + const pending: SessionSnapshot['pendingSubmissions'] = ['A', 'B', 'C'].map(text => ({ + requestId: text as never, placement: 'transcript' as const, + time: 1_000, text: `input ${text}`, attachments: [], + })) + const h = makeHarness({}, { pendingSubmissions: pending }) + const view = render() + const flow = () => [...view.container.querySelectorAll( + '[data-submission-echo], [data-chat-flow-kind="user"], [data-chat-flow-kind="turn-process"]', + )].map(element => element.matches('[data-chat-flow-kind="turn-process"]') ? 'title' + : pending.find(input => element.textContent?.includes(input.text))!.requestId + + (element.hasAttribute('data-submission-echo') ? '*' : '')) + expect(flow()).toEqual(['A*', 'B*', 'C*']) + const inbox: InboxState = { 'next-step': [], 'next-turn': hostOrder.split('').map(id => ({ + id: id as never, role: 'user', source: { kind: 'user', rpcId: id as never }, + content: [{ type: 'text', text: `input ${id}` }], + })) } + act(() => { h.setSession({ testInbox: inbox }) }) + act(() => { h.setChat({ turnTimings: new Map([[1, { startTime: 1_000 }]]) }) }) + expect(flow()).toEqual(['A*', 'title', 'B*', 'C*']) + const admitted: UserMessageNode & { turn: number } = { + ...user(2, `input ${hostOrder[0]}`), turn: 1, + source: { kind: 'user', rpcId: hostOrder[0] }, + } + act(() => { h.setChat({ nodes: [admitted] }) }) + expect(flow()).toEqual([hostOrder[0], 'title', ...pending + .filter(input => input.requestId !== hostOrder[0]).map(input => `${input.requestId}*`)]) + act(() => { h.setSession({ pendingSubmissions: pending.filter(input => input.requestId !== hostOrder[0]) }) }) + expect(view.getAllByText(`input ${hostOrder[0]}`)).toHaveLength(1) + }, + ) + + it('leaves an opening echo below an empty historical Turn title', () => { + const h = makeHarness({ turnTimings: new Map([[1, { startTime: 1_000 }]]), turnEnds: new Map([[1, 2]]) }, { + pendingSubmissions: [{ requestId: 'opening' as never, placement: 'transcript', + time: 3_000, text: 'next input', attachments: [] }], + }) + const view = render() + const title = view.container.querySelector('[data-chat-flow-kind="turn-process"]')! + const echo = view.getByText('next input').closest('[data-submission-echo]')! + expect(title.compareDocumentPosition(echo) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy() + }) + it('does not pull the reader back to the tail when a local steer becomes Host-pending', () => { const h = makeHarness({ nodes: [assistant(1, 'working')] }, { running: true }) const view = render() - const scroller = view.container.querySelector('[class*="scroll"]')! + const scroller = view.container.querySelector('[data-chat-flow]')!.parentElement! installScrollMetrics(scroller, 1_000, 300) act(() => { h.setSession({ pendingSubmissions: [{ requestId: 'req-steer' as never, placement: 'steering', time: 5_000, text: 'continue here', attachments: [], @@ -1639,6 +1825,39 @@ describe('ChatView', () => { expect(view.getByLabelText('回到底部')).toBeTruthy() }) + it.each(['compact', 'detailed', 'expanded'] as const)( + 'hides an admitted local steer while its Inbox claim projection is delayed (%s)', (mode) => { + const pending: InboxState['next-step'] = ['first', 'second'].map(text => ({ + id: text as never, role: 'user', source: { kind: 'user', rpcId: text as never }, + content: [{ type: 'text', text: `steer ${text}` }], + })) + const submissions: SessionSnapshot['pendingSubmissions'] = ['first', 'second'].map(text => ({ + requestId: text as never, placement: 'steering', time: 5_000, + text: `steer ${text}`, attachments: [], + })) + const h = makeHarness({ nodes: [assistant(1, 'working')] }, { + running: true, pendingSubmissions: submissions, + testInbox: { 'next-turn': [], 'next-step': pending }, + }) + h.setTranscriptView(mode) + const view = render() + const second = view.getByText('steer second').closest('[data-submission-echo]') + expect(view.getAllByText('steer first')).toHaveLength(1) + + act(() => { h.setChat({ nodes: [assistant(1, 'working'), { + ...steering(2, 'steer first', 1), source: pending[0]!.source, + }] }) }) + expect(view.getAllByText('steer first')).toHaveLength(1) + expect(view.getByText('steer first').closest('[data-pending-steering]')).toBeNull() + expect(view.getByText('steer second').closest('[data-submission-echo]')).toBe(second) + + act(() => { h.setSession({ testInbox: { 'next-turn': [], 'next-step': [pending[1]!] } }) }) + act(() => { h.setSession({ pendingSubmissions: [submissions[1]!] }) }) + expect(view.getAllByText('steer first')).toHaveLength(1) + expect(view.getByText('steer second').closest('[data-submission-echo]')).toBe(second) + }, + ) + it('retains the same local steer bubble through Host acceptance and claim until its durable node arrives', () => { const h = makeHarness( { nodes: [assistant(1, 'working')] }, @@ -2307,7 +2526,7 @@ describe('ChatView', () => { turnTimings: new Map([[1, { startTime: 0 }]]), }) const view = render() - const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement + const scroller = view.container.querySelector('[data-chat-flow]')!.parentElement as HTMLDivElement Object.defineProperty(scroller, 'scrollHeight', { value: 1_000, writable: true }) Object.defineProperty(scroller, 'clientHeight', { value: 300, writable: true }) const firstRow = view.getByText('first answer').closest('[data-chat-flow-kind="assistant-step"]') as HTMLElement @@ -2331,7 +2550,7 @@ describe('ChatView', () => { }) const view = render() expect(turnProcessControl(view.container)).toBeNull() - const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement + const scroller = view.container.querySelector('[data-chat-flow]')!.parentElement as HTMLDivElement Object.defineProperty(scroller, 'scrollHeight', { value: 1_000, writable: true }) Object.defineProperty(scroller, 'clientHeight', { value: 300, writable: true }) const processRow = view.container.querySelector('[data-chat-node-key="fixture:assistant:2"]')! @@ -3199,7 +3418,7 @@ describe('ChatView', () => { { hasMore: true }, ) const view = render() - const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement + const scroller = view.container.querySelector('[data-chat-flow]')!.parentElement as HTMLDivElement // jsdom has no layout: fake the metrics the anchor math reads. Object.defineProperty(scroller, 'scrollHeight', { value: 1000, writable: true }) Object.defineProperty(scroller, 'clientHeight', { value: 400, writable: true }) @@ -3229,7 +3448,7 @@ describe('ChatView', () => { it('back-to-bottom cancels an in-flight paging anchor', () => { const h = makeHarness({ nodes: [user(9, 'late')] }, { hasMore: true }) const view = render() - const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement + const scroller = view.container.querySelector('[data-chat-flow]')!.parentElement as HTMLDivElement Object.defineProperty(scroller, 'scrollHeight', { value: 800, writable: true }) Object.defineProperty(scroller, 'clientHeight', { value: 200, writable: true }) readerScroll(scroller, 50) @@ -3244,7 +3463,7 @@ describe('ChatView', () => { it('scrolling away disables follow and shows the back-to-bottom button; clicking returns', () => { const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'a')] }) const view = render() - const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement + const scroller = view.container.querySelector('[data-chat-flow]')!.parentElement as HTMLDivElement Object.defineProperty(scroller, 'scrollHeight', { value: 1000, writable: true }) Object.defineProperty(scroller, 'clientHeight', { value: 300, writable: true }) readerScroll(scroller, 100) // far from bottom @@ -3266,7 +3485,7 @@ describe('ChatView', () => { it('keeps following when a stream-finalization shrink clamp delivers its scroll', () => { const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'a')] }) const view = render() - const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement + const scroller = view.container.querySelector('[data-chat-flow]')!.parentElement as HTMLDivElement const metrics = installScrollMetrics(scroller, 1_000, 300) scroller.scrollTop = 700 fireEvent.scroll(scroller) @@ -3289,7 +3508,7 @@ describe('ChatView', () => { it('keeps following when a shrink clamp regrows before scrollend', () => { const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'a')] }) const view = render() - const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement + const scroller = view.container.querySelector('[data-chat-flow]')!.parentElement as HTMLDivElement const metrics = installScrollMetrics(scroller, 1_000, 300) scroller.scrollTop = 700 fireEvent.scroll(scroller) @@ -3319,7 +3538,7 @@ describe('ChatView', () => { vi.stubGlobal('ResizeObserver', ResizeObserverStub) const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'a')] }) const view = render() - const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement + const scroller = view.container.querySelector('[data-chat-flow]')!.parentElement as HTMLDivElement const metrics = installScrollMetrics(scroller, 9_931, 300) expect(notify).toBeDefined() scroller.scrollTop = 9_631 @@ -3357,7 +3576,7 @@ describe('ChatView', () => { vi.stubGlobal('ResizeObserver', ResizeObserverStub) const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'a')] }) const view = render() - const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement + const scroller = view.container.querySelector('[data-chat-flow]')!.parentElement as HTMLDivElement const metrics = installScrollMetrics(scroller, 1_000, 300) expect(notify).toBeDefined() scroller.scrollTop = 700 @@ -3380,7 +3599,7 @@ describe('ChatView', () => { it('keeps following when reader input reaches the floor before growth and scrollend', () => { const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'a')] }) const view = render() - const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement + const scroller = view.container.querySelector('[data-chat-flow]')!.parentElement as HTMLDivElement const metrics = installScrollMetrics(scroller, 1_000, 300) readerScroll(scroller, 100) expect(view.getByLabelText('回到底部')).toBeTruthy() @@ -3400,7 +3619,7 @@ describe('ChatView', () => { const nodes = [user(1, 'q'), assistant(2, 'a')] const h = makeHarness({ nodes }) const view = render() - const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement + const scroller = view.container.querySelector('[data-chat-flow]')!.parentElement as HTMLDivElement const metrics = installScrollMetrics(scroller, 1_000, 300) readerScroll(scroller, 700) scroller.scrollTop = 650 @@ -3431,7 +3650,7 @@ describe('ChatView', () => { it('clears an away sample when a back-to-bottom delivery restores pinned ownership', () => { const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'a')] }) const view = render() - const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement + const scroller = view.container.querySelector('[data-chat-flow]')!.parentElement as HTMLDivElement const metrics = installScrollMetrics(scroller, 1_000, 300) scroller.scrollTop = 700 fireEvent.scroll(scroller) @@ -3453,7 +3672,7 @@ describe('ChatView', () => { try { const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'a')] }) const view = render() - const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement + const scroller = view.container.querySelector('[data-chat-flow]')!.parentElement as HTMLDivElement installScrollMetrics(scroller, 1_000, 300) scroller.scrollTop = 700 fireEvent.scroll(scroller) @@ -3496,7 +3715,7 @@ describe('ChatView', () => { it('uses the last delivered top when compositor scrolling precedes scroll delivery', () => { const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'a')] }) const view = render() - const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement + const scroller = view.container.querySelector('[data-chat-flow]')!.parentElement as HTMLDivElement installScrollMetrics(scroller, 1_000, 300) scroller.scrollTop = 700 fireEvent.scroll(scroller) @@ -3524,7 +3743,7 @@ describe('ChatView', () => { vi.stubGlobal('ResizeObserver', ResizeObserverStub) const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'a')] }) const view = render() - const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement + const scroller = view.container.querySelector('[data-chat-flow]')!.parentElement as HTMLDivElement Object.defineProperty(scroller, 'scrollHeight', { value: 1_000, writable: true }) Object.defineProperty(scroller, 'clientHeight', { value: 300, writable: true }) scroller.scrollTop = 700 @@ -3578,7 +3797,7 @@ describe('ChatView', () => { }) const view = render() await view.findByRole('button', { name: '跳转到第 2 轮' }) - const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement + const scroller = view.container.querySelector('[data-chat-flow]')!.parentElement as HTMLDivElement const metrics = installScrollMetrics(scroller, 1_000, 300) scroller.scrollTop = 700 act(() => { @@ -3604,7 +3823,7 @@ describe('ChatView', () => { it('entering the at-bottom threshold does not snap the remaining scroll distance', () => { const h = makeHarness({ nodes: [user(1, 'q'), assistant(2, 'a')] }) const view = render() - const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement + const scroller = view.container.querySelector('[data-chat-flow]')!.parentElement as HTMLDivElement Object.defineProperty(scroller, 'scrollHeight', { value: 1000, writable: true }) Object.defineProperty(scroller, 'clientHeight', { value: 300, writable: true }) // Inside FOLLOW_THRESHOLD (24) but not flush with the floor — the chrome diff --git a/packages/client/ui-chat/tests/chat-viewport.client.spec.ts b/packages/client/ui-chat/tests/chat-viewport.client.spec.ts index a7e42c0f33..7dd39a8698 100644 --- a/packages/client/ui-chat/tests/chat-viewport.client.spec.ts +++ b/packages/client/ui-chat/tests/chat-viewport.client.spec.ts @@ -2,6 +2,7 @@ import { expect, it, onTestFinished, vi } from 'vitest' import { ChatViewport } from '../src/client/chat/use-chat-viewport.ts' +import { ScrollFollow, scrollMetrics } from '../src/client/chat/use-scroll-follow.ts' function fixture() { const column = document.createElement('div') @@ -430,6 +431,32 @@ it.each([ expect(h.row.getBoundingClientRect().top).toBe(top) }) +it('gives paging compensation priority over an active inner follow animation', () => { + const h = nestedFixture(600, 400) + const follow = new ScrollFollow(true, 1) + onTestFinished(follow.bind(h.body)) + const scrollTo = vi.fn((options?: ScrollToOptions | number, y?: number) => { + if (typeof options === 'number') h.body.scrollTop = y ?? 0 + else if (options?.behavior === 'instant') h.body.scrollTop = options.top ?? 0 + }) + h.body.scrollTo = scrollTo + h.body.scrollTop = 100 + const top = h.begin() + follow.toBottom(h.body, scrollMetrics(h.body), 'smooth') + expect(follow.animating).toBe(true) + h.prepend(200) + h.viewport.preserve() + expect(h.row.getBoundingClientRect().top).toBe(top) + expect(scrollTo).toHaveBeenLastCalledWith({ top: 300, behavior: 'instant' }) + expect(follow.animating).toBe(false) + expect(follow.active).toBe(false) + follow.sample(scrollMetrics(h.body)) + follow.settle(scrollMetrics(h.body)) + expect(follow.active).toBe(false) + h.body.scrollTop = scrollMetrics(h.body).floor + expect(follow.sample(scrollMetrics(h.body))).toBe(true) +}) + it.each(['wheel', 'touchstart', 'pointerdown', 'keydown', 'beforematch'])( 'retains the same paging anchor through later content resizes until %s', (intent) => { const h = nestedFixture(200, 400) diff --git a/packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts b/packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts index dfb75e4151..df659888d8 100644 --- a/packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts +++ b/packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts @@ -1002,7 +1002,8 @@ describe('built-in conversation node Definitions', () => { }, { surfaceOp: 'append' }), ]) const toolOnlySnapshot = snapshot(toolOnlyValue) - expect(toolOnlySnapshot.order.map(key => toolOnlySnapshot.nodes.get(key)?.kind)).toEqual(['turn-process']) + expect(toolOnlySnapshot.order.map(key => toolOnlySnapshot.nodes.get(key)?.kind)).toEqual(['turn-process', 'tool-call']) + expect(node(toolOnlySnapshot, 'tool-call')?.data).toMatchObject({ root: { phase: 'preparing' } }) expect(node(toolOnlySnapshot, 'assistant-step')?.visibility).toBe('hidden') expect(toolOnlySnapshot.legacy.nodes).toMatchObject([{ kind: 'assistant', diff --git a/packages/client/ui-chat/tests/process-groups.client.spec.ts b/packages/client/ui-chat/tests/process-groups.client.spec.ts index 8fdc292da1..2f98938274 100644 --- a/packages/client/ui-chat/tests/process-groups.client.spec.ts +++ b/packages/client/ui-chat/tests/process-groups.client.spec.ts @@ -35,7 +35,7 @@ function tool(key: string, seq: number, name = 'bash', owner = turn): ChatNode<' return { key, id: key, kind: 'tool-call', target: 'chat', anchorSeq: seq, location: { kind: 'turn', turn: owner }, visibility: 'visible', - data: { root: { callId: key, name, argsRaw: '{"command":"pwd"}', turn: owner.turn, step: 1, time: seq, subCalls: [] } }, + data: { root: { phase: 'start' as const, callId: key, name, argsRaw: '{"command":"pwd"}', turn: owner.turn, step: 1, time: seq, subCalls: [] } }, } } @@ -67,6 +67,56 @@ function harness(nodes: ChatNode[], currentTimeline = timeline) { } describe('Definition-owned Chat process groups', () => { + it('clears preparation from a closed group while retaining its recorded activity', () => { + const preparing: ChatNode<'tool-call'> = { + ...tool('call', 2, 'write'), + data: { root: { phase: 'preparing', callId: 'call', name: 'write', turn: 1, step: 1, time: 2, subCalls: [] } }, + } + const h = harness([preparing]) + const group = h.store.entries[0]! + if (group.kind !== 'group') throw new Error('expected group') + const source = h.store.groupSource(group.key) + expect(source.getSnapshot()?.data.summary.preparing).toBe(true) + h.builder.apply({ upserts: [separator(3, true)], timeline }) + h.commit() + expect(source.getSnapshot()?.data).toEqual({ + turn: 1, closed: true, + summary: { counts: [{ kind: 'write', count: 1 }], running: undefined, runningDetail: '' }, + }) + }) + + it.each([ + ['read', 'read'], ['read_image', 'readImage'], ['write', 'write'], ['edit', 'edit'], ['apply_patch', 'edit'], + ['todo_write', 'plan'], ['create_goal', 'plan'], ['update_goal', 'plan'], ['get_goal', 'plan'], + ['list_mcp_resources', 'tools'], ['list_mcp_resource_templates', 'tools'], ['read_mcp_resource', 'tools'], + ] as const)('%s keeps its activity category through preparation, dispatch, and result', (name, kind) => { + const started = tool('call', 2, name) + const preparing: ChatNode<'tool-call'> = { + ...started, + data: { root: { phase: 'preparing', callId: 'call', name, turn: 1, step: 1, time: 2, subCalls: [] } }, + } + const h = harness([preparing]) + const group = h.store.entries[0]! + if (group.kind !== 'group') throw new Error('expected group') + const source = h.store.groupSource(group.key) + expect(source.getSnapshot()?.data.summary).toEqual({ + counts: [{ kind, count: 1 }], running: kind, preparing: true, runningDetail: kind === 'tools' ? name : '', + }) + h.builder.apply({ upserts: [started], timeline }) + h.commit() + expect(source.getSnapshot()?.data.summary).toEqual({ counts: [{ kind, count: 1 }], running: kind, runningDetail: 'pwd' }) + const settled: ChatNode<'tool-call'> = { + ...started, + data: { root: { + kind: 'tool-result', callId: 'call', seq: 3, time: 3, callTime: 2, + call: { name, argsRaw: '{"command":"pwd"}' }, content: [], isError: false, subCalls: [], + } }, + } + h.builder.apply({ upserts: [settled], timeline }) + h.commit() + expect(source.getSnapshot()?.data.summary).toEqual({ counts: [{ kind, count: 1 }], running: undefined, runningDetail: '' }) + }) + it('retains a group through repeated prepends and a later append', () => { const b = tool('b', 4) const c = tool('c', 6) diff --git a/packages/client/ui-chat/tests/scroll-follow.client.spec.ts b/packages/client/ui-chat/tests/scroll-follow.client.spec.ts new file mode 100644 index 0000000000..5c90959ee0 --- /dev/null +++ b/packages/client/ui-chat/tests/scroll-follow.client.spec.ts @@ -0,0 +1,177 @@ +// @vitest-environment jsdom +import { afterEach, describe, expect, it, vi } from 'vitest' +import { ScrollFollow, scrollMetrics } from '../src/client/chat/use-scroll-follow.ts' + +afterEach(() => { vi.unstubAllGlobals() }) + +function scrollport() { + const element = document.createElement('div') + let height = 600 + const scrollTo = vi.fn((options: ScrollToOptions) => { + if (options.behavior === 'instant') element.scrollTop = options.top ?? element.scrollTop + }) + Object.defineProperties(element, { + clientHeight: { value: 200 }, + scrollHeight: { get: () => height }, + scrollTo: { value: scrollTo }, + }) + return { element, scrollTo, metrics: () => scrollMetrics(element), grow: (value: number) => { height = value } } +} + +describe('ScrollFollow', () => { + it('does not await native events for a fractional floor that cannot move', () => { + const world = scrollport() + let top = 399.6 + Object.defineProperty(world.element, 'scrollTop', { + get: () => top, set: (value: number) => { top = Math.min(value, world.metrics().floor - 0.4) }, + }) + const follow = new ScrollFollow(true, 1) + follow.toBottom(world.element, world.metrics(), 'smooth') + expect(world.scrollTo).not.toHaveBeenCalled() + expect(follow.animating).toBe(false) + expect(follow.active).toBe(true) + world.grow(800) + follow.toBottom(world.element, world.metrics(), 'smooth') + expect(world.scrollTo).toHaveBeenCalledExactlyOnceWith({ top: 600, behavior: 'smooth' }) + top = 599.6 + follow.sample(world.metrics()) + follow.toBottom(world.element, world.metrics(), 'smooth') + expect(follow.animating).toBe(true) + expect(world.scrollTo).toHaveBeenCalledTimes(1) + follow.settle(world.metrics()) + follow.toBottom(world.element, world.metrics(), 'smooth') + expect(follow.animating).toBe(false) + expect(world.scrollTo).toHaveBeenCalledTimes(1) + }) + + it('releases only its own scrollport binding', () => { + const { element } = scrollport() + const first = new ScrollFollow(false, 1) + const second = new ScrollFollow(false, 1) + expect(ScrollFollow.forElement(element)).toBeUndefined() + const releaseFirst = first.bind(element) + const releaseSecond = second.bind(element) + releaseFirst() + expect(ScrollFollow.forElement(element)).toBe(second) + releaseSecond() + expect(ScrollFollow.forElement(element)).toBeUndefined() + }) + + it('keeps independent thresholds and adopts only attributed reader movement', () => { + const outer = new ScrollFollow(true, 25) + const inner = new ScrollFollow(true, 1) + const metrics = { top: 380, floor: 400, height: 200 } + expect(outer.sample(metrics, true)).toBe(true) + expect(inner.sample(metrics, true)).toBe(false) + expect(inner.sample({ ...metrics, top: 400 }, false)).toBe(false) + expect(inner.sample({ ...metrics, top: 400 }, true)).toBe(true) + inner.reset() + expect(inner.active).toBe(false) + expect(inner.sample({ ...metrics, top: 400 })).toBe(true) + expect(outer.active).toBe(true) + }) + + it('finishes native motion before following the latest grown floor', () => { + const world = scrollport() + const follow = new ScrollFollow(false, 1) + expect(follow.toBottom(world.element, world.metrics(), 'smooth').top).toBe(0) + expect(world.scrollTo).toHaveBeenLastCalledWith({ top: 400, behavior: 'smooth' }) + world.element.scrollTop = 100 + expect(follow.sample(world.metrics())).toBe(true) + follow.toBottom(world.element, world.metrics(), 'smooth') + expect(world.scrollTo).toHaveBeenCalledTimes(1) + world.grow(800) + follow.toBottom(world.element, world.metrics(), 'smooth') + world.grow(1_000) + follow.toBottom(world.element, world.metrics(), 'smooth') + expect(world.scrollTo).toHaveBeenCalledTimes(1) + world.element.scrollTop = 400 + expect(follow.sample(world.metrics())).toBe(true) + expect(follow.animating).toBe(true) + expect(follow.settle(world.metrics())).toBe(true) + expect(follow.animating).toBe(false) + follow.toBottom(world.element, world.metrics(), 'smooth') + expect(world.scrollTo).toHaveBeenCalledTimes(2) + expect(world.scrollTo).toHaveBeenLastCalledWith({ top: 800, behavior: 'smooth' }) + world.element.scrollTop = 800 + expect(follow.sample(world.metrics())).toBe(true) + expect(follow.settle(world.metrics())).toBe(true) + expect(follow.animating).toBe(false) + world.element.scrollTop = 300 + expect(follow.sample(world.metrics())).toBe(false) + }) + + it('finishes native motion when shrinking content clamps its target', () => { + const world = scrollport() + const follow = new ScrollFollow(true, 1) + follow.toBottom(world.element, world.metrics(), 'smooth') + world.grow(300) + world.element.scrollTop = 100 + expect(follow.sample(world.metrics())).toBe(true) + expect(follow.settle(world.metrics())).toBe(true) + expect(follow.animating).toBe(false) + }) + + it.each([150, 600])('releases following after a native stop at %i instead of its target', (top) => { + const world = scrollport() + const follow = new ScrollFollow(true, 1) + follow.toBottom(world.element, world.metrics(), 'smooth') + world.grow(1_000) + world.element.scrollTop = top + expect(follow.sample(world.metrics())).toBe(true) + expect(follow.settle(world.metrics())).toBe(false) + expect(follow.animating).toBe(false) + world.grow(1_200) + expect(follow.sample(world.metrics())).toBe(false) + }) + + it('cancels native motion before input without treating the cancellation itself as scrolling away', () => { + const world = scrollport() + const follow = new ScrollFollow(true, 1) + follow.interrupt(world.element, world.metrics()) + expect(world.scrollTo).not.toHaveBeenCalled() + follow.toBottom(world.element, world.metrics(), 'smooth') + world.element.scrollTop = 150 + follow.sample(world.metrics()) + follow.interrupt(world.element, world.metrics()) + expect(world.scrollTo).toHaveBeenLastCalledWith({ top: 150, behavior: 'instant' }) + expect(follow.animating).toBe(false) + expect(follow.sample(world.metrics())).toBe(true) + expect(follow.settle(world.metrics())).toBe(true) + world.element.scrollTop = 100 + expect(follow.sample(world.metrics())).toBe(false) + world.element.scrollTop = 400 + expect(follow.sample(world.metrics())).toBe(true) + }) + + it('positions opening and external landings immediately within the current range', () => { + const world = scrollport() + const follow = new ScrollFollow(false, 1) + expect(follow.toBottom(world.element, world.metrics(), 'instant').top).toBe(400) + expect(follow.toBottom(world.element, world.metrics(), 'smooth').top).toBe(400) + expect(world.scrollTo).not.toHaveBeenCalled() + expect(follow.jump(world.element, world.metrics(), -100).top).toBe(0) + expect(follow.active).toBe(false) + expect(follow.jump(world.element, world.metrics(), 1_000).top).toBe(400) + expect(follow.active).toBe(true) + }) + + it('uses immediate following under reduced motion', () => { + vi.stubGlobal('matchMedia', vi.fn(() => ({ matches: true }))) + const world = scrollport() + const follow = new ScrollFollow(false, 1) + expect(follow.toBottom(world.element, world.metrics(), 'smooth').top).toBe(400) + expect(follow.animating).toBe(false) + expect(world.scrollTo).not.toHaveBeenCalled() + }) + + it('stops a native animation when an explicit jump retains the current offset', () => { + const world = scrollport() + const follow = new ScrollFollow(false, 1) + follow.toBottom(world.element, world.metrics(), 'smooth') + follow.jump(world.element, world.metrics(), 0) + expect(world.scrollTo).toHaveBeenLastCalledWith({ top: 0, behavior: 'instant' }) + expect(follow.animating).toBe(false) + expect(follow.active).toBe(false) + }) +}) diff --git a/packages/client/ui-chat/tests/tool-call-tree.client.spec.ts b/packages/client/ui-chat/tests/tool-call-tree.client.spec.ts index fbfbcb1c47..51d4aa9501 100644 --- a/packages/client/ui-chat/tests/tool-call-tree.client.spec.ts +++ b/packages/client/ui-chat/tests/tool-call-tree.client.spec.ts @@ -1,6 +1,6 @@ import type { SessionEvent } from '@deepseek-ai/dsh-session/types' import { describe, expect, it } from 'vitest' -import type { RunningToolCall, ToolCallBlock } from '../src/client/contract/snapshot.ts' +import type { StartedToolCall, ToolCallBlock } from '../src/client/contract/snapshot.ts' import { MAX_TOOL_CALL_TREE_DEPTH, ToolCallTree, } from '../src/client/model/tool-call-tree.ts' @@ -19,8 +19,8 @@ const settle = (seq: number, parentCallId: string, subCallId: string): SessionEv isError: false, content: [], }) -const root = (callId: string): RunningToolCall => ({ - callId, name: 'run_code', argsRaw: '{}', turn: 1, step: 1, +const root = (callId: string): StartedToolCall => ({ + phase: 'start' as const, callId, name: 'run_code', argsRaw: '{}', turn: 1, step: 1, time: 1_700_000_000_000, subCalls: [], }) diff --git a/packages/client/ui-chat/tests/tool-preparation.client.spec.ts b/packages/client/ui-chat/tests/tool-preparation.client.spec.ts new file mode 100644 index 0000000000..c458a0869f --- /dev/null +++ b/packages/client/ui-chat/tests/tool-preparation.client.spec.ts @@ -0,0 +1,195 @@ +import { describe, expect, it, vi } from 'vitest' +import { createAssistantMessage, createToolResultMessage, LlmAttemptId, ToolCallId } from '@deepseek-ai/dsh-llm' +import { AssistantStreamAccumulator } from '@deepseek-ai/dsh-llm/assistant-stream' +import type { StreamChunk } from '@deepseek-ai/dsh-llm' +import { SessionSeq, type SessionEvent } from '@deepseek-ai/dsh-session/types' +import type { SessionAssistantSettlementEntry, SessionEventLikeEntry, SessionLiveEventEntry } from '@deepseek-ai/dsh-api-session-controller/client' +import { ConversationNodeAssembler } from '@deepseek-ai/dsh-client-ui-conversation/client' +import type { ChatNode, ChatSnapshot } from '@deepseek-ai/dsh-client-ui-chat/client' +import { assistantDefinition } from '../src/client/conversation-nodes/assistant.ts' +import { toolDefinition } from '../src/client/conversation-nodes/tool.ts' +import { chatViewDefinition } from '../src/client/conversation-nodes/chat-snapshot-builder.ts' +import { processGroupDefinition } from '../src/client/conversation-nodes/process-groups.ts' + +const attemptId = LlmAttemptId('preparing-test') +const first = ToolCallId('write-one') +const second = ToolCallId('write-two') +const args = '{"file_path":"hello.txt","content":"hello"}' + +function entry(event: SessionEvent): SessionLiveEventEntry { + return { type: 'event', event } +} + +const opening = [ + entry({ type: 'turn/start', seq: SessionSeq(1), time: 1, data: { turn: 1 } }), + entry({ type: 'step/start', seq: SessionSeq(2), time: 2, data: { turn: 1, step: 1 } }), +] + +function delta(seq: number, id = first, argumentsDelta = '', name: string | null = 'write'): SessionEventLikeEntry { + return { + type: 'transient', + event: { + type: 'assistant/live-chunk', seq, time: seq, + data: { turn: 1, step: 1, attemptId, chunk: { + type: 'tool-call-delta', index: id === first ? 0 : 1, id, argumentsDelta, + ...(name === null ? {} : { name }), + } }, + }, + } +} + +function call(seq: number, callId = first): SessionLiveEventEntry { + return entry({ type: 'tool/call', seq: SessionSeq(seq), time: seq, + data: { turn: 1, step: 1, callId, name: 'write', arguments: args } }) +} + +function result(seq: number, callId = first): SessionLiveEventEntry { + return entry({ type: 'tool/result', seq: SessionSeq(seq), time: seq, surfaceOp: 'append', data: { + turn: 1, step: 1, message: createToolResultMessage({ callId, content: [{ type: 'text', text: 'Written' }], isError: false }), + } }) +} + +function settlement(): SessionAssistantSettlementEntry { + const stream = new AssistantStreamAccumulator() + for (const [index, id] of [first, second].entries()) { + const chunk: StreamChunk = { type: 'tool-call-delta', index, id, name: 'write', argumentsDelta: args } + stream.push({ time: 3 + index, chunk }) + } + return { type: 'event', event: { + type: 'assistant/message', seq: SessionSeq(5), time: 5, surfaceOp: 'append', data: { + turn: 1, step: 1, stream: [...stream.snapshot()], + message: createAssistantMessage({ source: { provider: 'test', model: 'test' }, content: [first, second].map(id => ({ + type: 'tool-call', id, name: 'write', arguments: args, + })) }), + }, + } } +} + +function harness(entries: readonly SessionEventLikeEntry[] = opening, hasMore = false, tool = toolDefinition) { + const assembler = new ConversationNodeAssembler( + { entries: () => [assistantDefinition, tool], fallbackEntry: () => undefined }, + { entries: () => [chatViewDefinition] }, + { entries: () => [processGroupDefinition], forTarget: target => target === 'chat' ? processGroupDefinition : undefined }, + ) + assembler.replaceWindow(entries, hasMore) + assembler.activateTarget('chat') + const tools = () => { + assembler.flush() + const snapshot = assembler.snapshot('chat') as ChatSnapshot + return snapshot.nodes.values().filter((node): node is ChatNode<'tool-call'> => node.kind === 'tool-call' && node.visibility === 'visible') + .sort((left, right) => left.anchorSeq - right.anchorSeq) + } + const phases = () => tools().map(node => [node.id, 'kind' in node.data.root ? 'result' : node.data.root.phase]) + const material = () => tools().map(({ key, anchorSeq, data }) => ({ key, anchorSeq, data })) + return { assembler, tools, phases, material } +} + +describe('Tool preparation and durable replay', () => { + it('batches repeated named deltas and retains the unchanged Tool node', () => { + const start = vi.fn((...args) => toolDefinition.start(...args)) + const h = harness(opening, false, { ...toolDefinition, start }) + expect(h.assembler.append(delta(2.1, first, '{"file_path":"file.txt","content":"'))).toBe('animation-frame') + const original = h.tools()[0]! + for (let index = 0; index < 100; index++) { + expect(h.assembler.append(delta(2.2 + index / 1000, first, 'x'.repeat(128)))).toBe('animation-frame') + expect(h.tools()[0]).toBe(original) + } + expect(start).toHaveBeenCalledOnce() + expect(h.assembler.append(call(6))).toBe('immediate') + expect(h.phases()).toEqual([[first, 'start']]) + expect(h.tools()[0]).not.toBe(original) + expect(h.assembler.append(result(7))).toBe('immediate') + expect(h.phases()).toEqual([[first, 'result']]) + }) + + it('creates two independent live preparations and updates each call under the same key', () => { + const h = harness() + h.assembler.append(delta(2.1, first, '', null)) + expect(h.phases()).toEqual([]) + h.assembler.append(delta(2.2)) + const original = h.tools()[0]! + expect(h.phases()).toEqual([[first, 'preparing']]) + expect(original.data.root).not.toHaveProperty('argsRaw') + h.assembler.append(delta(2.3, first, '{"content":"hello', null)) + expect(h.tools()[0]?.data.root).toBe(original.data.root) + h.assembler.append(delta(2.4, second)) + expect(h.phases()).toEqual([[first, 'preparing'], [second, 'preparing']]) + const groups = h.assembler.grouped('chat')! + const group = groups.entries.find(entry => entry.kind === 'group')! + if (group.kind !== 'group') throw new Error('expected a process group') + expect(groups.groupSource(group.key).getSnapshot()?.data.summary).toMatchObject({ + counts: [{ kind: 'write', count: 2 }], preparing: true, runningDetail: '', + }) + h.assembler.append(call(6)) + expect(h.tools()[0]?.key).toBe(original.key) + expect(h.phases()).toEqual([[first, 'start'], [second, 'preparing']]) + expect(h.tools()[0]?.data.root).toMatchObject({ argsRaw: args }) + h.assembler.append(result(7)) + h.assembler.append(call(8, second)) + h.assembler.append(result(9, second)) + expect(h.phases()).toEqual([[first, 'result'], [second, 'result']]) + }) + + it('withdraws transient preparation on settlement and converges with a durable-only replacement', () => { + const h = harness() + h.assembler.append(delta(2.1)) + const key = h.tools()[0]!.key + const message = settlement() + h.assembler.settleAssistant(attemptId, message) + expect(h.phases()).toEqual([]) + const history = [...opening, message, call(6), result(7), call(8, second), result(9, second)] + for (const event of history.slice(3)) h.assembler.append(event) + expect(h.tools()[0]?.key).toBe(key) + const replay = harness(history) + expect(h.phases()).toEqual([[first, 'result'], [second, 'result']]) + expect(h.material()).toEqual(replay.material()) + }) + + it('does not create preparing rows from stored Assistant streams in history or older pages', () => { + const message = settlement() + const history = [...opening, message, call(6), result(7), call(8, second), result(9, second)] + const full = harness(history) + expect(full.phases()).toEqual([[first, 'result'], [second, 'result']]) + const paged = harness([result(9, second)], true) + expect(paged.phases()).toEqual([[second, 'result']]) + paged.assembler.prepend([call(8, second)], true) + expect(paged.phases()).toEqual([[second, 'result']]) + paged.assembler.prepend(history.slice(0, 5), false) + expect(paged.material()).toEqual(full.material()) + paged.assembler.replaceWindow([...opening, message], false) + expect(paged.phases()).toEqual([]) + paged.assembler.append(call(6)) + expect(paged.phases()).toEqual([[first, 'start']]) + }) + + it('retains a dispatched call when its earlier transient start is withdrawn', () => { + const h = harness() + h.assembler.append(delta(2.1)) + const key = h.tools()[0]!.key + h.assembler.append(call(6)) + expect(h.tools()[0]).toMatchObject({ key, anchorSeq: 2.1, data: { root: { phase: 'start', argsRaw: args } } }) + const message = settlement() + h.assembler.settleAssistant(attemptId, message) + expect(h.tools()).toHaveLength(1) + expect(h.tools()[0]).toMatchObject({ key, anchorSeq: 6, data: { root: { phase: 'start', argsRaw: args } } }) + h.assembler.append(result(7)) + expect(h.phases()).toEqual([[first, 'result']]) + const replay = harness([...opening, message, call(6), result(7)]) + expect(h.material()).toEqual(replay.material()) + }) + + it('abandons one attempt and lets a subsequent live attempt create its own preparation', () => { + const h = harness() + h.assembler.append(delta(2.1)) + expect(h.phases()).toEqual([[first, 'preparing']]) + h.assembler.settleAssistant(attemptId) + expect(h.phases()).toEqual([]) + h.assembler.append(delta(2.2, second)) + expect(h.phases()).toEqual([[second, 'preparing']]) + h.assembler.replaceWindow([...opening, delta(2.2, second)], false) + expect(h.phases()).toEqual([[second, 'preparing']]) + h.assembler.settleAssistant(attemptId) + h.assembler.append(call(8, second)) + expect(h.phases()).toEqual([[second, 'start']]) + }) +}) diff --git a/packages/client/ui-commands/package.json b/packages/client/ui-commands/package.json index 3b0b2cc6fb..b2eb3b8b28 100644 --- a/packages/client/ui-commands/package.json +++ b/packages/client/ui-commands/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-commands", "description": "Client command surface: global directory cache, '/' source, three command UI kinds, popupSelect registry", - "version": "0.1.7-alpha.1", + "version": "0.1.7-alpha.2", "publishConfig": { "access": "public" }, @@ -42,27 +42,27 @@ }, "license": "MIT", "peerDependencies": { - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:~" }, "devDependencies": { - "@deepseek-ai/dsh-api-remotes": "workspace:^", - "@deepseek-ai/dsh-client-connection": "workspace:^", - "@deepseek-ai/dsh-client-locale": "workspace:^", - "@deepseek-ai/dsh-client-test-runtime": "workspace:^", - "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", - "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", - "@deepseek-ai/dsh-client-ui-input-trigger": "workspace:^", - "@deepseek-ai/dsh-client-ui-slots": "workspace:^", - "@deepseek-ai/dsh-commands": "workspace:^", + "@deepseek-ai/dsh-api-remotes": "workspace:*", + "@deepseek-ai/dsh-client-connection": "workspace:*", + "@deepseek-ai/dsh-client-locale": "workspace:*", + "@deepseek-ai/dsh-client-test-runtime": "workspace:*", + "@deepseek-ai/dsh-client-ui-conversation": "workspace:*", + "@deepseek-ai/dsh-client-ui-primitives": "workspace:*", + "@deepseek-ai/dsh-client-ui-input-trigger": "workspace:*", + "@deepseek-ai/dsh-client-ui-slots": "workspace:*", + "@deepseek-ai/dsh-commands": "workspace:*", "@types/react": "~18.3.1", - "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/cordis": "workspace:~", "react": "^18.2.0", - "@deepseek-ai/dsh-api-session-controller": "workspace:^", - "@deepseek-ai/dsh-client-store": "workspace:^", - "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/dsh-util-values": "workspace:^", - "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", - "@deepseek-ai/dsh-client-ui-session": "workspace:^", + "@deepseek-ai/dsh-api-session-controller": "workspace:*", + "@deepseek-ai/dsh-client-store": "workspace:*", + "@deepseek-ai/dsh-session": "workspace:*", + "@deepseek-ai/dsh-util-values": "workspace:*", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:*", + "@deepseek-ai/dsh-client-ui-session": "workspace:*", "clsx": "^2.0.0" }, "files": [ diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 99eba4e710..015f305a4b 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md -README.md: 48df40483632a7c63ec59d389f920d4dd663155b -README.zh.md: f5c34657264f97d8f0178859dcba5e3fc981f17d +README.md: 153c505250f5654f0aeade23c221e19554294641 +README.zh.md: c849b1bdfd033e07cac382c4b62cd0df35333c8e diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 48df404836..153c505250 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -27,7 +27,7 @@ English | [中文](README.zh.md) `UiConversation.events` is the single registry for event Definitions, and `UiConversation.views` is the single registry for target snapshot builders. Both registries reject duplicate keys, preserve registration order, return idempotent disposers, and rebuild existing bindings when their contribution roster changes. `UiConversation.binding(bindingOrSessionId)` returns one identity-stable Conversation binding for the current Session Controller binding. It does not open another event source. A View Definition may declare `toolCallFocus` to translate a tool-call id into its focus identity. Conversation supplies an Inspect callback only while such a target has a visible View entry; Chat consumes this callback without selecting a target. -The adapter passes each `SessionEventLikeEntry` directly to the assembler. Its outer `type` distinguishes durable events from Client-only transient events, while its inner `event` always exposes `type`, `seq`, `time`, and `data`; Definitions receive that inner `SessionEventLike`. Replacement windows may include both entry variants, while historical prepends carry durable entries and live appends may carry either. Every Definition uses the same `match` and `update` methods for both event forms, while `start` receives only a durable event and the assembler rejects a transient start. Definitions that do not consume Assistant deltas return `null` for `assistant/live-chunk`. Replacement windows and revision gaps rebuild from the complete loaded window; contiguous append, prepend, and Assistant-settlement revisions use incremental assembly. Settlement removes only the named attempt's transient matches, applies its optional durable entry, and replays the affected Contexts and dependents without replacing unrelated target nodes. The assembler owns Context matching, Turn/Step locations, target node materialization, target activity, and stable target sources. `ConversationSnapshot` contains only target-neutral views and active-target facts; Session lifecycle state remains in `SessionSnapshot`. +The adapter passes each `SessionEventLikeEntry` directly to the assembler. Its outer `type` distinguishes durable events from Client-only transient events, while its inner `event` always exposes `type`, `seq`, `time`, and `data`; Definitions receive that inner `SessionEventLike`. Replacement windows may include both entry variants, while historical prepends carry durable entries and live appends may carry either. Definitions use the same match/start/update interface for durable and transient events. The earliest loaded start initializes State; every later Match, including another start for that identity, updates it. Definitions that do not consume Assistant deltas return `null` for `assistant/live-chunk`. Replacement windows and revision gaps rebuild from the complete loaded window; contiguous append, prepend, and Assistant-settlement revisions use incremental assembly. Settlement removes only the named attempt's transient matches, applies its optional durable entry, and recomputes the affected Contexts from their remaining earliest start, refreshing predecessor indexes and dependents without replacing unrelated target nodes. A Context with no remaining start has no State. Its key and previously published nodes remain available to later evidence until a full window rebuild; the owning Definition may hide those nodes. The assembler owns Context matching, Turn/Step locations, target node materialization, target activity, and stable target sources. `ConversationSnapshot` contains only target-neutral views and active-target facts; Session lifecycle state remains in `SessionSnapshot`. A target becomes active when shell selection resolves it or when its source receives a first subscriber. The assembler replaces that target from current Contexts once and keeps it active for later incremental flushes; creating a source does not activate it and unsubscription does not deactivate it. @@ -68,9 +68,9 @@ Active transcripts in the main occurrence expose content-width drag handles in t The width-handle indicator follows the pointer only during a captured drag; ordinary hover leaves its position unchanged. -The resident composer survives no-Session and Session transitions. Whitespace hides its placeholder; a whitespace-only draft without attachments cannot be sent. The no-Session state keeps the same composer surface mounted but inert while the Workspace picker connects a blank Session. The surface is a shell-owned Lexical editor: reference chips are atomic decorator nodes carrying the owner's serialization identity (submission expands them through the owner codec), claimed slash commands stay styled leading text, folder text references carry the folder glyph as an icon prefix, and the draft's clipboard projection is mirrored into the per-Session Conversation store. QueueDock reads `next-turn` directly from the Session `inbox` projection, including cold recovered messages. Queue operations address exact queue occurrences through the scoped `ctx.conversation` service; queue previews render sent text through the shared inline reference projection from `ui-primitives` (wire session forms fold to their label) and show local or durable images and files in original attachment order. Images use thumbnails; files use compact name-and-size cards. An edit exposes the literal sent text in a textarea that grows with its content, so line breaks survive re-editing; Enter saves, Shift+Enter breaks the line, and Escape cancels. Durable thumbnails resolve through the session image URL cache. Busy Enter behavior is stored in the Host-backed `ui-conversation` settings namespace. The composer keymap arbitrates the trigger menu's keys through the slash pipeline — Tab settles the highlighted completion (or drills a drillable one), Escape and Shift+Tab leave the menu without settling — and leaves every other key to the editor. An overlay that takes the keyboard hands it back through `SessionInput.focus()`, which rides Lexical's own focus so the caret returns where the draft left it rather than at the start. +The resident composer survives no-Session and Session transitions. Whitespace hides its placeholder; a whitespace-only draft without attachments cannot be sent. The no-Session state keeps the same composer surface mounted but inert while the Workspace picker connects a blank Session. The surface is a shell-owned Lexical editor: reference chips are atomic decorator nodes carrying the owner's serialization identity (submission expands them through the owner codec), claimed slash commands stay styled leading text, folder text references carry the folder glyph as an icon prefix, and the draft's clipboard projection is mirrored into the per-Session Conversation store. QueueDock reads `next-turn` from the Session `inbox` projection, including cold recovered messages, and excludes only requestIds still owned by local transcript submissions. Other queued rows retain their normal display and actions. Queue operations address exact queue occurrences through the scoped `ctx.conversation` service; queue previews render sent text through the shared inline reference projection from `ui-primitives` (wire session forms fold to their label) and show local or durable images and files in original attachment order. Images use thumbnails; files use compact name-and-size cards. An edit exposes the literal sent text in a textarea that grows with its content, so line breaks survive re-editing; Enter saves, Shift+Enter breaks the line, and Escape cancels. Durable thumbnails resolve through the session image URL cache. Busy Enter behavior is stored in the Host-backed `ui-conversation` settings namespace. The composer keymap arbitrates the trigger menu's keys through the slash pipeline — Tab settles the highlighted completion (or drills a drillable one), Escape and Shift+Tab leave the menu without settling — and leaves every other key to the editor. An overlay that takes the keyboard hands it back through `SessionInput.focus()`, which rides Lexical's own focus so the caret returns where the draft left it rather than at the start. -Default sends commit optimistically: Enter clears the draft, occurrence table, and undo history in the same transaction, keeps the composer in `plain`, and runs the send as a detached attempt, so typing and further sends continue during the flight. `sendSession` registers a Session submission echo (`session.beginSubmission`) with the delivery mode before serializing, preserving selected image and file order in `pendingSubmissions`; Session derives the placement from that mode and its current running state, so idle sends use the transcript, busy Queue sends use QueueDock, and busy Steer sends use the pending-steering surface. It then yields one paint, encodes images through the browser's native `FileReader` data-URL path, and cites staged file receipts. Command submissions use the same receipts for generic files, so sending `/goal` or `/plan` never reads those browser files again. The prompt reuses the submission `requestId`; queue and history observation by that `rpcId` retires the echo once. Concurrent failures are restored together in submission order until the user edits the restored content; command submissions keep the frozen `submitting` phase. Detached attempts retain their attachment ids through admission and Session scope disposal. An observed retirement immediately exposes each image preview through the durable cache, replaces it with the canonical URL after fetching the admitted attachment, revokes each URL after its use ends, and releases file cards. Selected generic files enter one FIFO background-upload queue; `maxConcurrentFileUploads` defaults to two active Worker transports, the Conversation service retains queued and active operations plus byte progress across Session navigation, and removing a draft skips its queued transfer or aborts its active transport. Where the browser shell exposes `__DSH_HOST_PATHS__` (the Desktop application), dropped or pasted folders and dropped, picked, or pasted non-image files with a real path become `@path` chips; images keep uploading. Drag-and-drop and paste identify directories through the browser entry API; when that API is absent or returns no entry, pasted items retain ordinary file handling. The file picker cannot select directories. References require the `ui-reference` plugin and the original paths to remain readable by the model's file tools. Paths inside the workspace are relative; others remain absolute. The whole batch is validated before insertion, retains source order and selected text, and uses whitespace-separated, closed-quote mentions. A browser without the bridge refuses dropped or pasted folders; a Desktop folder without a reported path is rejected separately. Continuable subagents disable attachment intake and skip local echoes because their transport does not preserve the browser request id. +Default sends commit optimistically: Enter clears the draft, occurrence table, and undo history in the same transaction, keeps the composer in `plain`, and runs the send as a detached attempt, so typing and further sends continue during the flight. `sendSession` registers a Session submission echo (`session.beginSubmission`) with the delivery mode before serializing, preserving selected image and file order in `pendingSubmissions`; Session derives the placement from that mode and its current running state, so idle sends use the transcript, busy Queue sends use QueueDock, and busy Steer sends use the pending-steering surface. It then yields one paint, encodes images through the browser's native `FileReader` data-URL path, and cites staged file receipts. Command submissions use the same receipts for generic files, so sending `/goal` or `/plan` never reads those browser files again. The prompt reuses the submission `requestId`; Session retires the echo once at its display handoff, correlated by that `rpcId`. Concurrent failures are restored together in submission order until the user edits the restored content; command submissions keep the frozen `submitting` phase. Detached attempts retain their attachment ids through admission and Session scope disposal. An observed retirement immediately exposes each image preview through the durable cache, replaces it with the canonical URL after fetching the admitted attachment, revokes each URL after its use ends, and releases file cards. Selected generic files enter one FIFO background-upload queue; `maxConcurrentFileUploads` defaults to two active Worker transports, the Conversation service retains queued and active operations plus byte progress across Session navigation, and removing a draft skips its queued transfer or aborts its active transport. Where the browser shell exposes `__DSH_HOST_PATHS__` (the Desktop application), dropped or pasted folders and dropped, picked, or pasted non-image files with a real path become `@path` chips; images keep uploading. Drag-and-drop and paste identify directories through the browser entry API; when that API is absent or returns no entry, pasted items retain ordinary file handling. The file picker cannot select directories. References require the `ui-reference` plugin and the original paths to remain readable by the model's file tools. Paths inside the workspace are relative; others remain absolute. The whole batch is validated before insertion, retains source order and selected text, and uses whitespace-separated, closed-quote mentions. A browser without the bridge refuses dropped or pasted folders; a Desktop folder without a reported path is rejected separately. Continuable subagents disable attachment intake and skip local echoes because their transport does not preserve the browser request id. Queued submission echoes show “Sending…” beside disabled edit, remove, and steer buttons; a collapsed dock keeps the sending status in its header. A matching Host queue row replaces the echo and enables each action according to its normal text-content and running-state requirements. Prompt acknowledgement alone does not enable queue actions. A failed submission removes its echo and displays an error; the composer restores the failed draft when it is empty or still contains the previous automatic restoration, preserving subsequently typed text. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index f5c3465726..c849b1bdfd 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -27,7 +27,7 @@ kind: "package-reference" `UiConversation.events` 是 event Definition 的唯一 registry,`UiConversation.views` 是 target snapshot builder 的唯一 registry。两者都拒绝重复 key、保持注册顺序、返回幂等 disposer,并在 contribution roster 变化时重建现有 binding。`UiConversation.binding(bindingOrSessionId)` 为当前 Session Controller binding 返回 identity 稳定的 Conversation binding,不会另开事件源。 View Definition 可以声明 `toolCallFocus`,将工具调用 id 转换为自身的焦点标识。仅当此目标拥有可见的 View 条目时,Conversation 才提供 Inspect 回调;Chat 直接使用回调,不选择目标。 -适配器把每个 `SessionEventLikeEntry` 直接交给 assembler。外层 `type` 区分持久事件与 Client-only transient event,内部 `event` 则统一公开 `type`、`seq`、`time` 与 `data`;Definition 接收这个内部 `SessionEventLike`。replacement window 可以包含两种 entry,历史 prepend 携带持久 entry,实时 append 则可以携带任一种。两种事件都使用 Definition 的同一组 `match` 与 `update` 方法,`start` 只接收持久 event,assembler 会拒绝 transient start。不消费 Assistant delta 的 Definition 对 `assistant/live-chunk` 返回 `null`。replace window 或 revision 断档从完整已加载窗口重建;连续 revision 的 append、prepend 与 Assistant settlement 使用增量组装。settlement 只删除具名 attempt 的 transient match,应用可选持久 entry,并重放受影响的 Context 及其 dependent,不替换无关 target node。assembler 拥有 Context 匹配、Turn/Step location、target node 物化、target activity 和稳定 target source。`ConversationSnapshot` 只包含与 target 无关的 View 与 active-target 事实;Session lifecycle 状态仍属于 `SessionSnapshot`。 +适配器把每个 `SessionEventLikeEntry` 直接交给 assembler。外层 `type` 区分持久事件与 Client-only transient event,内部 `event` 则统一公开 `type`、`seq`、`time` 与 `data`;Definition 接收这个内部 `SessionEventLike`。replacement window 可以包含两种 entry,历史 prepend 携带持久 entry,实时 append 则可以携带任一种。持久与瞬态事件使用同一组 match/start/update 接口。当前最早的 start 初始化 State;后续所有 Match,包括同一身份的其他 start,都用于更新。不消费 Assistant delta 的 Definition 对 `assistant/live-chunk` 返回 `null`。replace window 或 revision 断档从完整已加载窗口重建;连续 revision 的 append、prepend 与 Assistant settlement 使用增量组装。settlement 只删除具名 attempt 的 transient match,应用可选持久 entry,并从剩余最早的 start 重算受影响的 Context,刷新前序索引及 dependent,不替换无关 target node。没有剩余 start 的 Context 不保留 State,但其 key 和已发布节点仍保留,供后续证据复用,直到完整窗口重建;所属 Definition 可以隐藏这些节点。assembler 拥有 Context 匹配、Turn/Step location、target node 物化、target activity 和稳定 target source。`ConversationSnapshot` 只包含与 target 无关的 View 与 active-target 事实;Session lifecycle 状态仍属于 `SessionSnapshot`。 shell 选择解析出 target 或 target source 收到首个 subscriber 时,该 target 进入 active 状态。assembler 从当前 Context 对它执行一次 replace,并使它参与后续增量 flush;创建 source 不会激活 target,取消订阅也不会停用 target。 @@ -68,9 +68,9 @@ Session 首次绑定或缓存的 Session 成为 current 时,shell 会在渲染 宽度拖拽条的指示线只在已捕获指针的拖拽期间跟随指针,普通悬停不改变其位置。 -常驻 composer 在无 Session 与有 Session 之间保持挂载。输入空白字符会隐藏占位提示;没有附件的纯空白草稿无法发送。无 Session 时,同一个编辑器表面保持 inert,Workspace picker 连接 blank Session。该表面是 shell 所有的 Lexical 编辑器:引用 chip 是携带 owner 序列化身份的原子 decorator 节点(提交时经 owner codec 展开),已认领的 slash command 保持为带样式的行首文本,文件夹文本引用以图标前缀携带文件夹图形,草稿的剪贴板投影镜像到逐 Session Conversation store。QueueDock 直接从 Session 的 `inbox` 投影读取 `next-turn`,包含从冷状态恢复的消息。Queue 操作通过 scoped `ctx.conversation` service 寻址准确的 queue occurrence;queue 预览经 `ui-primitives` 的共享行内引用投影渲染已发送文本(wire 会话形式折叠为其标签),并按原始附件顺序展示本地或持久化的图片和文件。图片使用缩略图,文件使用紧凑的名称与大小卡片。编辑态在可随内容增高的 textarea 中展示字面发送文本,因此重新编辑不会丢失换行;Enter 保存,Shift+Enter 换行,Escape 取消。持久化缩略图通过会话图片 URL 缓存解析。繁忙时 Enter 行为保存在 Host-backed `ui-conversation` settings namespace。 composer 键盘映射经斜杠流水线裁决触发菜单的按键——Tab 确认高亮补全项(可下钻项则下钻),Escape 与 Shift+Tab 离开菜单且不选定——其余按键交给编辑器自身。 接管键盘的浮层通过 `SessionInput.focus()` 把键盘还回来,该路径走 Lexical 自己的 focus,因此光标回到草稿原来的位置而不是开头。 +常驻 composer 在无 Session 与有 Session 之间保持挂载。输入空白字符会隐藏占位提示;没有附件的纯空白草稿无法发送。无 Session 时,同一个编辑器表面保持 inert,Workspace picker 连接 blank Session。该表面是 shell 所有的 Lexical 编辑器:引用 chip 是携带 owner 序列化身份的原子 decorator 节点(提交时经 owner codec 展开),已认领的 slash command 保持为带样式的行首文本,文件夹文本引用以图标前缀携带文件夹图形,草稿的剪贴板投影镜像到逐 Session Conversation store。QueueDock 从 Session 的 `inbox` 投影读取 `next-turn`,包含从冷状态恢复的消息,仅排除仍由本地 transcript 提交承接的 requestId。其他排队行保留正常展示和操作。Queue 操作通过 scoped `ctx.conversation` service 寻址准确的 queue occurrence;queue 预览经 `ui-primitives` 的共享行内引用投影渲染已发送文本(wire 会话形式折叠为其标签),并按原始附件顺序展示本地或持久化的图片和文件。图片使用缩略图,文件使用紧凑的名称与大小卡片。编辑态在可随内容增高的 textarea 中展示字面发送文本,因此重新编辑不会丢失换行;Enter 保存,Shift+Enter 换行,Escape 取消。持久化缩略图通过会话图片 URL 缓存解析。繁忙时 Enter 行为保存在 Host-backed `ui-conversation` settings namespace。 composer 键盘映射经斜杠流水线裁决触发菜单的按键——Tab 确认高亮补全项(可下钻项则下钻),Escape 与 Shift+Tab 离开菜单且不选定——其余按键交给编辑器自身。 接管键盘的浮层通过 `SessionInput.focus()` 把键盘还回来,该路径走 Lexical 自己的 focus,因此光标回到草稿原来的位置而不是开头。 -默认发送采用乐观提交:Enter 在同一事务里清空草稿、occurrence 表和撤销历史,composer 保持 `plain`,发送作为 detached attempt 运行,发送期间可以继续输入和提交。`sendSession` 在序列化之前用投递模式注册 Session 提交回显(`session.beginSubmission`),并在 `pendingSubmissions` 中保留图片与文件的选择顺序;Session 根据该模式与当前运行状态推导位置,因此空闲发送进入 transcript(文本记录),繁忙时 Queue 进入 QueueDock,繁忙时 Steer 进入 pending-steering 区域。随后让出一帧,图片经浏览器原生 `FileReader` data-URL 路径编码,文件则引用已暂存凭证。命令提交也用同一凭证表示通用文件,因此发送 `/goal` 或 `/plan` 时不会再次读取这些浏览器文件。提示词复用提交 `requestId`;queue 或历史以同一 `rpcId` 被观察后,回显只退休一次。多个并发发送失败时,在用户编辑还原内容之前按提交顺序合并还原;命令提交保持冻结的 `submitting` 阶段。Detached attempt 持有附件 id,直到 admission 完成或 Session scope 销毁。回显以 observed 退休时,durable 图片缓存立即公开每个预览 URL,读取 admitted 附件后用规范化 URL 替换预览,并在各 URL 停止使用后撤销,同时释放文件卡。选中的通用文件进入同一个先进先出的后台上传队列;`maxConcurrentFileUploads` 默认允许两个 Worker transport 同时运行,Conversation 服务在切换 Session 时继续持有排队和运行中的传输操作及字节进度,移除草稿会跳过排队中的传输或中止正在运行的传输。浏览器 shell 暴露 `__DSH_HOST_PATHS__` 时(桌面应用),拖入或粘贴的文件夹以及拖入、选择或粘贴的带真实路径的非图片文件会成为 `@路径` chip;图片仍然上传。拖放和粘贴通过浏览器 entry API 识别目录;该 API 不可用或没有返回 entry 时,粘贴项沿用普通文件处理。文件选择器不能选择目录。引用需要启用 `ui-reference` 插件,原路径也必须仍可由模型的文件工具读取。工作区内的路径使用相对形式,其他路径保留绝对形式。整批文件先校验再插入,保留来源顺序和已选中的文字,引用之间有空白分隔,含空格的路径使用闭合引号。没有该桥的浏览器会拒绝拖入或粘贴的文件夹,桌面端无法获取文件夹路径时单独报错。continuable 子代理禁用附件入口,也不创建本地回显,因为其 transport 不保留浏览器 request id。 +默认发送采用乐观提交:Enter 在同一事务里清空草稿、occurrence 表和撤销历史,composer 保持 `plain`,发送作为 detached attempt 运行,发送期间可以继续输入和提交。`sendSession` 在序列化之前用投递模式注册 Session 提交回显(`session.beginSubmission`),并在 `pendingSubmissions` 中保留图片与文件的选择顺序;Session 根据该模式与当前运行状态推导位置,因此空闲发送进入 transcript(文本记录),繁忙时 Queue 进入 QueueDock,繁忙时 Steer 进入 pending-steering 区域。随后让出一帧,图片经浏览器原生 `FileReader` data-URL 路径编码,文件则引用已暂存凭证。命令提交也用同一凭证表示通用文件,因此发送 `/goal` 或 `/plan` 时不会再次读取这些浏览器文件。提示词复用提交 `requestId`;Session 按同一 `rpcId` 关联展示接管,并仅退休回显一次。多个并发发送失败时,在用户编辑还原内容之前按提交顺序合并还原;命令提交保持冻结的 `submitting` 阶段。Detached attempt 持有附件 id,直到 admission 完成或 Session scope 销毁。回显以 observed 退休时,durable 图片缓存立即公开每个预览 URL,读取 admitted 附件后用规范化 URL 替换预览,并在各 URL 停止使用后撤销,同时释放文件卡。选中的通用文件进入同一个先进先出的后台上传队列;`maxConcurrentFileUploads` 默认允许两个 Worker transport 同时运行,Conversation 服务在切换 Session 时继续持有排队和运行中的传输操作及字节进度,移除草稿会跳过排队中的传输或中止正在运行的传输。浏览器 shell 暴露 `__DSH_HOST_PATHS__` 时(桌面应用),拖入或粘贴的文件夹以及拖入、选择或粘贴的带真实路径的非图片文件会成为 `@路径` chip;图片仍然上传。拖放和粘贴通过浏览器 entry API 识别目录;该 API 不可用或没有返回 entry 时,粘贴项沿用普通文件处理。文件选择器不能选择目录。引用需要启用 `ui-reference` 插件,原路径也必须仍可由模型的文件工具读取。工作区内的路径使用相对形式,其他路径保留绝对形式。整批文件先校验再插入,保留来源顺序和已选中的文字,引用之间有空白分隔,含空格的路径使用闭合引号。没有该桥的浏览器会拒绝拖入或粘贴的文件夹,桌面端无法获取文件夹路径时单独报错。continuable 子代理禁用附件入口,也不创建本地回显,因为其 transport 不保留浏览器 request id。 排队提交的本地回显在禁用的编辑、删除、插话按钮旁显示“发送中…”;折叠后的队列在标题栏保留发送状态。匹配的 Host 队列行替换回显后,各操作按原有的纯文本内容和运行状态要求启用。仅收到提示词确认不会启用队列操作。提交失败会移除回显并显示错误;输入框为空或仍保留上一次自动恢复的内容时,composer 恢复失败草稿,保留用户随后输入的文字。 diff --git a/packages/client/ui-conversation/package.json b/packages/client/ui-conversation/package.json index 37093c371d..7641d94c74 100644 --- a/packages/client/ui-conversation/package.json +++ b/packages/client/ui-conversation/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-conversation", "description": "Target-neutral Conversation assembly, shell, composer, queue, and view navigation", - "version": "0.1.7-alpha.1", + "version": "0.1.7-alpha.2", "publishConfig": { "access": "public" }, @@ -46,42 +46,42 @@ }, "license": "MIT", "dependencies": { - "@deepseek-ai/schemastery": "workspace:^" + "@deepseek-ai/schemastery": "workspace:~" }, "peerDependencies": { - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:~" }, "devDependencies": { - "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-api-remotes": "workspace:^", - "@deepseek-ai/dsh-api-session-controller": "workspace:^", - "@deepseek-ai/dsh-api-workspace-controller": "workspace:^", - "@deepseek-ai/dsh-attachment": "workspace:^", - "@deepseek-ai/dsh-brand": "workspace:^", - "@deepseek-ai/dsh-client-locale": "workspace:^", - "@deepseek-ai/dsh-client-file-upload": "workspace:^", - "@deepseek-ai/dsh-client-store": "workspace:^", - "@deepseek-ai/dsh-client-test-runtime": "workspace:^", - "@deepseek-ai/dsh-client-ui-layout": "workspace:^", - "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", - "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", - "@deepseek-ai/dsh-client-ui-session": "workspace:^", - "@deepseek-ai/dsh-client-ui-settings": "workspace:^", - "@deepseek-ai/dsh-client-ui-workspace": "workspace:^", - "@deepseek-ai/dsh-client-ui-slots": "workspace:^", - "@deepseek-ai/dsh-commands": "workspace:^", - "@deepseek-ai/dsh-goal": "workspace:^", - "@deepseek-ai/dsh-llm": "workspace:^", - "@deepseek-ai/dsh-llm-retry": "workspace:^", - "@deepseek-ai/dsh-plan-mode": "workspace:^", - "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/dsh-util-values": "workspace:^", - "@deepseek-ai/dsh-token-meter": "workspace:^", - "@deepseek-ai/dsh-tool-todo": "workspace:^", - "@deepseek-ai/dsh-util-crypto": "workspace:^", - "@deepseek-ai/dsh-workspace": "workspace:^", - "@deepseek-ai/dsh-file-reference": "workspace:^", - "@deepseek-ai/dsh-util-workspace-path": "workspace:^", + "@deepseek-ai/cordis": "workspace:~", + "@deepseek-ai/dsh-api-remotes": "workspace:*", + "@deepseek-ai/dsh-api-session-controller": "workspace:*", + "@deepseek-ai/dsh-api-workspace-controller": "workspace:*", + "@deepseek-ai/dsh-attachment": "workspace:*", + "@deepseek-ai/dsh-brand": "workspace:*", + "@deepseek-ai/dsh-client-locale": "workspace:*", + "@deepseek-ai/dsh-client-file-upload": "workspace:*", + "@deepseek-ai/dsh-client-store": "workspace:*", + "@deepseek-ai/dsh-client-test-runtime": "workspace:*", + "@deepseek-ai/dsh-client-ui-layout": "workspace:*", + "@deepseek-ai/dsh-client-ui-primitives": "workspace:*", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:*", + "@deepseek-ai/dsh-client-ui-session": "workspace:*", + "@deepseek-ai/dsh-client-ui-settings": "workspace:*", + "@deepseek-ai/dsh-client-ui-workspace": "workspace:*", + "@deepseek-ai/dsh-client-ui-slots": "workspace:*", + "@deepseek-ai/dsh-commands": "workspace:*", + "@deepseek-ai/dsh-goal": "workspace:*", + "@deepseek-ai/dsh-llm": "workspace:*", + "@deepseek-ai/dsh-llm-retry": "workspace:*", + "@deepseek-ai/dsh-plan-mode": "workspace:*", + "@deepseek-ai/dsh-session": "workspace:*", + "@deepseek-ai/dsh-util-values": "workspace:*", + "@deepseek-ai/dsh-token-meter": "workspace:*", + "@deepseek-ai/dsh-tool-todo": "workspace:*", + "@deepseek-ai/dsh-util-crypto": "workspace:*", + "@deepseek-ai/dsh-workspace": "workspace:*", + "@deepseek-ai/dsh-file-reference": "workspace:*", + "@deepseek-ai/dsh-util-workspace-path": "workspace:*", "@types/react": "~18.3.1", "react": "^18.2.0", "@lexical/headless": "^0.49.0", @@ -94,8 +94,8 @@ "@lexical/text": "^0.49.0", "@lexical/plain-text": "^0.49.0", "@lexical/history": "^0.49.0", - "@deepseek-ai/dsh-agent": "workspace:^", - "@deepseek-ai/dsh-settings": "workspace:^" + "@deepseek-ai/dsh-agent": "workspace:*", + "@deepseek-ai/dsh-settings": "workspace:*" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-conversation/src/client/contract/conversation.ts b/packages/client/ui-conversation/src/client/contract/conversation.ts index 333dd1e5ae..64fa04ecee 100644 --- a/packages/client/ui-conversation/src/client/contract/conversation.ts +++ b/packages/client/ui-conversation/src/client/contract/conversation.ts @@ -4,7 +4,7 @@ import type { ConversationGroupData, ConversationGroupedView, ConversationGroupInput, } from './groups.ts' -/** Definition-local identity and lifecycle role extracted from one event. */ +/** Definition-local identity; start permits initialization when no earlier start is present. */ export interface ConversationMatchResult { readonly id: string readonly role: 'start' | 'update' @@ -118,8 +118,8 @@ interface ConversationMatchOf< readonly location: ConversationLocation } -/** One scalar event accepted as a Context's unique start. */ -export type ConversationStartMatch = ConversationMatchOf +/** A durable or transient event that can initialize its Context. */ +export type ConversationStartMatch = ConversationMatchOf /** One event accepted by a Definition, with its lifecycle role and resolved Location. */ export type ConversationMatch = @@ -204,7 +204,7 @@ export interface ConversationNodeDefinition { */ match(event: SessionEventLike): ConversationMatchResult | null /** - * Create State from the unique start Match. + * Create State from the earliest currently loaded start Match. * @param context - complete evidence currently collected for the Context. * @param match - the start Match. * @param reader - strictly-backward read-only Context lookup. @@ -216,9 +216,9 @@ export interface ConversationNodeDefinition { reader: ConversationContextReader, ): State /** - * Apply one post-start update Match. + * Apply a later Match, including another event marked start. * @param context - Context with its current State. - * @param match - update Match in ascending log order. + * @param match - subsequent Match in ascending event order. * @returns the State adopted by the engine. */ update( diff --git a/packages/client/ui-conversation/src/client/contract/records.ts b/packages/client/ui-conversation/src/client/contract/records.ts index c4bd56c026..f9e0c002c6 100644 --- a/packages/client/ui-conversation/src/client/contract/records.ts +++ b/packages/client/ui-conversation/src/client/contract/records.ts @@ -257,22 +257,35 @@ export type ConversationNode = | CompactionSummaryNode | UnknownSurfaceNode -/** In-flight tool card material: tool/call seen, tool/result not yet. */ -export interface RunningToolCall { +/** Identity and placement shared by tool preparation and dispatch. */ +interface ToolCallHead { callId: string /** Parent Tool call for a PTC dispatch start; absent on a root Session call. */ parentCallId?: string name: string - argsRaw: string turn: number step: number - /** Unix epoch ms when the tool/call event was logged. */ + /** Unix epoch ms when this stage began. */ time: number /** Child calls owned by this call, in dispatch order. */ subCalls: readonly ToolCallBlock[] } -/** One running or settled call, recursively owning its child calls. */ +/** A named model call whose arguments are not yet available to tool views. */ +export interface PreparingToolCall extends ToolCallHead { + readonly phase: 'preparing' +} + +/** A dispatched tool call with complete arguments and no result yet. */ +export interface StartedToolCall extends ToolCallHead { + readonly phase: 'start' + readonly argsRaw: string +} + +/** A tool still preparing or awaiting its result. */ +export type RunningToolCall = PreparingToolCall | StartedToolCall + +/** One preparing, dispatched, or settled call, recursively owning its child calls. */ export type ToolCallBlock = RunningToolCall | ToolResultNode /** In-progress assistant output (chunk accumulator product). */ diff --git a/packages/client/ui-conversation/src/client/conversation/assembler.ts b/packages/client/ui-conversation/src/client/conversation/assembler.ts index d51216de32..0ab42da1c2 100644 --- a/packages/client/ui-conversation/src/client/conversation/assembler.ts +++ b/packages/client/ui-conversation/src/client/conversation/assembler.ts @@ -135,17 +135,10 @@ function mergeMatches( } function conversationMatch( - key: string, input: SessionEventLikeEntry, role: ConversationMatch['role'], location: ConversationMatch['location'], ): ConversationMatch { - if (role === 'start') { - if (input.type !== 'event') { - throw new Error(`conversation Context ${key} received a transient start Match`) - } - return { event: input.event, role, location } - } return { event: input.event, role, location } } @@ -269,6 +262,8 @@ export class ConversationNodeAssembler implements ConversationViewSnapshotStore /** * Retire one Assistant attempt's transient matches and apply its optional durable settlement. + * Empty Contexts retain their keys and published nodes until the loaded window is rebuilt; + * Definitions may hide those nodes when no start remains instead of withdrawing their identities. * @param attemptId - process-local attempt whose transient presentation ended. * @param entry - durable message or attempt event committed for the stream. * @returns highest requested publication cadence. @@ -461,7 +456,6 @@ export class ConversationNodeAssembler implements ConversationViewSnapshotStore return this.dispatchInput(input, (definition, id, role) => { const key = conversationContextKey(definition.kind, id) const match = conversationMatch( - key, input, role, this.locationIndex.locationOf(input.event), @@ -533,12 +527,9 @@ export class ConversationNodeAssembler implements ConversationViewSnapshotStore ): ConversationPublication { const key = conversationContextKey(definition.kind, id) let context = this.contexts.get(key) - if (role === 'start' && context?.start !== undefined) { - throw new Error(`conversation Context ${key} received more than one start Match`) - } context ??= this.createContext(definition, id, key) + const starting = role === 'start' && context.start === undefined const match = conversationMatch( - key, input, role, this.locationIndex.locationOf(input.event), @@ -547,11 +538,11 @@ export class ConversationNodeAssembler implements ConversationViewSnapshotStore if (previous !== undefined && previous.event.seq >= input.event.seq) { throw new Error(`conversation Context ${key} received non-appended Match ${input.event.seq}`) } - if (role === 'start' && context.matches.length > 0) { + if (starting && context.matches.length > 0) { throw new Error(`conversation Context ${key} received an update before its start Match`) } context.matches.push(match) - if (match.role === 'start') { + if (starting && match.role === 'start') { context.startSeq = input.event.seq context.start = match this.indexStartedContext(context) @@ -560,7 +551,7 @@ export class ConversationNodeAssembler implements ConversationViewSnapshotStore owners.add(context) this.contextsBySeq.set(input.event.seq, owners) - if (match.role === 'start') { + if (starting) { this.replayContext(context) } else if (context.state !== undefined) { const typed = contextSnapshot(context) as ConversationNodeContext & { readonly state: unknown } @@ -576,24 +567,16 @@ export class ConversationNodeAssembler implements ConversationViewSnapshotStore pending: ReadonlyMap, affected: Set, ): void { - const startsByKind = new Map() for (const [key, entries] of pending) { const first = entries[0] if (first === undefined) continue let context = this.contexts.get(key) context ??= this.createContext(first.definition, first.id, key) - let discoveredStart: ConversationStartMatch | undefined const additions = entries .map((entry) => { if (entry.definition !== context.definition || entry.id !== context.id) { throw new Error(`conversation Context ${key} received inconsistent Definition identity`) } - if (entry.match.role === 'start') { - if (discoveredStart !== undefined || context.start !== undefined) { - throw new Error(`conversation Context ${key} received more than one start Match`) - } - discoveredStart = entry.match - } const owners = this.contextsBySeq.get(entry.match.event.seq) ?? new Set() owners.add(context) this.contextsBySeq.set(entry.match.event.seq, owners) @@ -601,28 +584,21 @@ export class ConversationNodeAssembler implements ConversationViewSnapshotStore }) .sort((left, right) => left.event.seq - right.event.seq) context.matches = mergeMatches(context.key, additions, context.matches) - if (discoveredStart !== undefined) { - context.start = discoveredStart - context.startSeq = discoveredStart.event.seq - const starts = startsByKind.get(context.kind) ?? [] - starts.push(context) - startsByKind.set(context.kind, starts) - } - if (context.start !== undefined && context.matches[0] !== context.start) { - throw new Error(`conversation Context ${context.key} received an update before its start Match`) - } affected.add(context) this.markDirty(context) } - for (const [kind, contexts] of startsByKind) this.indexStartedContexts(kind, contexts) } private replayContexts(contexts: ReadonlySet): void { + this.refreshStarts(contexts) const ordered = [...contexts].sort((left, right) => (left.startSeq ?? Number.POSITIVE_INFINITY) - (right.startSeq ?? Number.POSITIVE_INFINITY)) for (const context of ordered) { if (context.start === undefined) { context.state = undefined + this.replaceDependencies(context, new Map()) + context.revision++ + this.revised.add(context) this.markDirty(context) continue } @@ -630,6 +606,26 @@ export class ConversationNodeAssembler implements ConversationViewSnapshotStore } } + private refreshStarts(contexts: ReadonlySet): void { + const changed = new Set() + const startsByKind = new Map() + for (const context of contexts) { + const start = context.matches.find(match => match.role === 'start') + if (start === context.start) continue + context.start = start + context.startSeq = start?.event.seq + changed.add(context) + const starts = startsByKind.get(context.kind) ?? [] + if (start !== undefined) starts.push(context) + startsByKind.set(context.kind, starts) + } + for (const [kind, starts] of startsByKind) { + const existing = this.contextsByKind.get(kind) ?? [] + this.contextsByKind.set(kind, existing.filter(context => !changed.has(context))) + this.indexStartedContexts(kind, starts) + } + } + private replayContext(context: InternalContext): void { const start = context.start if (start === undefined) { @@ -650,7 +646,7 @@ export class ConversationNodeAssembler implements ConversationViewSnapshotStore this.replaceDependencies(context, dependencies) for (let index = 1; index < context.matches.length; index++) { const match = context.matches[index] - if (match === undefined || match.role !== 'update') continue + if (match === undefined) continue const typed = contextSnapshot(context) as ConversationNodeContext & { readonly state: unknown } context.state = requireState( context.definition, diff --git a/packages/client/ui-conversation/src/client/index.ts b/packages/client/ui-conversation/src/client/index.ts index e776311040..e5825f8c70 100644 --- a/packages/client/ui-conversation/src/client/index.ts +++ b/packages/client/ui-conversation/src/client/index.ts @@ -30,7 +30,7 @@ export type { export type { AssistantBlock, AssistantMessageNode, AssistantProviderMetadataView, AssistantRequestConfig, AssistantTiming, CommandNode, CompactionSummaryNode, ContextMessageNode, ConversationNode, - ModelRetryNode, PartialAssistant, RunningToolCall, SteeringMessageNode, TodoItem, + ModelRetryNode, PartialAssistant, PreparingToolCall, RunningToolCall, StartedToolCall, SteeringMessageNode, TodoItem, ToolCallBlock, ToolResultNode, TurnErrorNode, TurnMaxTokensNode, UnknownSurfaceNode, UserMessageNode, } from './contract/records.ts' diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts index aab4735316..d170ddce32 100644 --- a/packages/client/ui-conversation/src/client/locales.ts +++ b/packages/client/ui-conversation/src/client/locales.ts @@ -130,14 +130,14 @@ export const zh = { 'tool.title.queryRuntime': '查询运行时', 'tool.title.inspectPlugins': '检查动态插件', 'tool.title.workflow': '运行工作流', - 'tool.title.ralph': '运行 Ralph', + 'tool.title.ralph': '运行循环工作流', 'tool.title.readEvent': '读取事件', 'tool.title.searchEvents': '搜索事件', 'tool.title.traceEvent': '追踪事件', 'tool.title.searchSessions': '搜索会话', 'tool.title.traceSession': '追踪会话', 'tool.title.listModels': '查看可用模型', - 'tool.title.subagent': '委派任务', + 'tool.title.subagent': '创建代理', 'tool.title.listAgents': '查看代理', 'tool.title.sendMessage': '发送消息', 'tool.title.interruptAgent': '中断代理', @@ -159,7 +159,7 @@ export const zh = { 'tool.title.getTeamTask': '读取团队任务', 'tool.title.updateTeamTask': '更新团队任务', 'tool.title.listTeamTasks': '查看团队任务', - 'tool.title.waitAgent': '等待队友', + 'tool.title.waitAgent': '等待子智能体', 'detail.recordedResult': '调用结果', 'detail.empty': '暂无结果', 'detail.none': '无', @@ -243,8 +243,8 @@ export const zh = { 'detail.receipt.signal': '信号已发送', 'detail.receipt.closed': '已关闭', 'detail.receipt.closing': '关闭中', - 'detail.wait.noProgress': '没有正在运行的队友', - 'detail.wait.title': '队友状态', + 'detail.wait.noProgress': '没有正在运行的子智能体', + 'detail.wait.title': '子智能体状态', 'detail.wait.timeout': '等待超时', 'detail.wait.changed': '检测到变化', 'detail.agent.reply': '代理回复', @@ -282,6 +282,7 @@ export const zh = { 'bash.failed': '失败', 'bash.stopped': '已停止', 'row.running': '运行中', + 'row.preparing': '正在准备调用', 'row.failed': '失败', 'row.stopped': '已停止', 'row.input': '输入', @@ -294,7 +295,7 @@ export const zh = { 'tool.title.edit': '编辑', 'tool.title.code': '代码', 'tool.title.generic': '工具调用', - 'tool.title.inspect': '查看', + 'tool.title.inspect': '查询 Cordis 环境', 'tool.title.runCordis': '运行 Cordis 插件', 'tool.title.stopCordis': '停止 Cordis 插件', 'tool.title.removeCordis': '移除 Cordis 插件', @@ -482,15 +483,15 @@ export const en = { 'tool.title.queryRuntime': 'Query runtime', 'tool.title.inspectPlugins': 'Inspect plugins', 'tool.title.workflow': 'Run workflow', - 'tool.title.ralph': 'Run Ralph', + 'tool.title.ralph': 'Run ralph loop', 'tool.title.readEvent': 'Read event', 'tool.title.searchEvents': 'Search events', 'tool.title.traceEvent': 'Trace event', 'tool.title.searchSessions': 'Search sessions', 'tool.title.traceSession': 'Trace session', 'tool.title.listModels': 'List models', - 'tool.title.subagent': 'Delegate task', - 'tool.title.listAgents': 'List agents', + 'tool.title.subagent': 'Create subagent', + 'tool.title.listAgents': 'List subagents', 'tool.title.sendMessage': 'Send message', 'tool.title.interruptAgent': 'Interrupt agent', 'tool.title.listJobs': 'List background jobs', @@ -511,7 +512,7 @@ export const en = { 'tool.title.getTeamTask': 'Read team task', 'tool.title.updateTeamTask': 'Update team task', 'tool.title.listTeamTasks': 'List team tasks', - 'tool.title.waitAgent': 'Wait for teammates', + 'tool.title.waitAgent': 'Wait for subagent', 'detail.recordedResult': 'Recorded result', 'detail.empty': 'No results', 'detail.none': 'None', @@ -595,8 +596,8 @@ export const en = { 'detail.receipt.signal': 'Signal delivered', 'detail.receipt.closed': 'Closed', 'detail.receipt.closing': 'Closing', - 'detail.wait.noProgress': 'No active teammates', - 'detail.wait.title': 'Teammate activity', + 'detail.wait.noProgress': 'No active subagents', + 'detail.wait.title': 'Subagent activity', 'detail.wait.timeout': 'Wait timed out', 'detail.wait.changed': 'Change detected', 'detail.agent.reply': 'Agent response', @@ -634,6 +635,7 @@ export const en = { 'bash.failed': 'Failed', 'bash.stopped': 'Stopped', 'row.running': 'Running', + 'row.preparing': 'Preparing tool call', 'row.failed': 'Failed', 'row.stopped': 'Stopped', 'row.input': 'IN', @@ -646,7 +648,7 @@ export const en = { 'tool.title.edit': 'Edit', 'tool.title.code': 'Code', 'tool.title.generic': 'Tool call', - 'tool.title.inspect': 'Inspect', + 'tool.title.inspect': 'Query Cordis environment', 'tool.title.runCordis': 'Run Cordis Plugin', 'tool.title.stopCordis': 'Stop Cordis Plugin', 'tool.title.removeCordis': 'Remove Cordis Plugin', diff --git a/packages/client/ui-conversation/src/client/queue/QueueDock.tsx b/packages/client/ui-conversation/src/client/queue/QueueDock.tsx index 03146f3e4c..2aa9056341 100644 --- a/packages/client/ui-conversation/src/client/queue/QueueDock.tsx +++ b/packages/client/ui-conversation/src/client/queue/QueueDock.tsx @@ -150,13 +150,19 @@ export type QueueDockProps = PropsRuntime<'conversation.input.dock'> & QueueDock /** * Queue strip: one item renders directly; multiple items default to a - * collapsible count header; an empty queue renders nothing. Local submissions + * collapsible count header; an empty queue renders nothing. Local queued submissions * show sending status and disabled actions until their Host queue rows arrive. */ export function QueueDock({ useSession, useProjection, updateQueue, notify, loadImage, t }: QueueDockProps) { const inbox = useProjection('inbox') as unknown as InboxState | undefined - const queue = inbox?.['next-turn'] ?? EMPTY_QUEUE const pendingSubmissions = useSession(s => s.pendingSubmissions) + const queue = useMemo(() => { + const rows = inbox?.['next-turn'] ?? EMPTY_QUEUE + const inChat = new Set(pendingSubmissions.filter(item => item.placement === 'transcript').map(item => item.requestId)) + return inChat.size === 0 ? rows : rows.filter(({ source }) => ( + source.kind !== 'user' || !('rpcId' in source) || !inChat.has(source.rpcId) + )) + }, [inbox, pendingSubmissions]) const pendingQueue = useMemo(() => { const admitted = new Set(queue.flatMap(({ source }) => ( source.kind === 'user' && 'rpcId' in source ? [source.rpcId] : [] @@ -279,7 +285,7 @@ export function QueueDock({ useSession, useProjection, updateQueue, notify, load {editing?.id === row.id ? ( <> - + - + - + - + - + )} {showResume && ( - + )} - + - + diff --git a/packages/client/ui-goal/tests/goalbar.client.spec.tsx b/packages/client/ui-goal/tests/goalbar.client.spec.tsx index c321ccf5ff..d73443d67c 100644 --- a/packages/client/ui-goal/tests/goalbar.client.spec.tsx +++ b/packages/client/ui-goal/tests/goalbar.client.spec.tsx @@ -151,6 +151,20 @@ describe('GoalBar', () => { expect(actions.onResume).toHaveBeenCalledTimes(1) }) + it('portals an action tooltip out of the strip, where the input card cannot cover it', () => { + vi.useFakeTimers() + try { + render() + fireEvent.mouseEnter(screen.getByRole('button', { name: '暂停目标' })) + act(() => { vi.advanceTimersByTime(500) }) + const tooltip = screen.getByRole('tooltip') + expect(tooltip.textContent).toBe('暂停目标') + expect(tooltip.parentElement).toBe(document.body) + } finally { + vi.useRealTimers() + } + }) + it('a new goal identity drops the edit form (no stale draft over the new goal)', () => { const actions = makeActions() const { rerender } = render() diff --git a/packages/client/ui-input-trigger/package.json b/packages/client/ui-input-trigger/package.json index 2e008aa00d..6267aff57c 100644 --- a/packages/client/ui-input-trigger/package.json +++ b/packages/client/ui-input-trigger/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-input-trigger", "description": "Input trigger pipeline: '/' and '@' detection, candidate menu, pick routing to registered sources", - "version": "0.1.7-alpha.1", + "version": "0.1.7-alpha.2", "publishConfig": { "access": "public" }, @@ -42,24 +42,24 @@ }, "license": "MIT", "peerDependencies": { - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:~" }, "devDependencies": { - "@deepseek-ai/dsh-client-locale": "workspace:^", - "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", - "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", - "@deepseek-ai/dsh-client-test-runtime": "workspace:^", - "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", - "@deepseek-ai/dsh-client-ui-slots": "workspace:^", + "@deepseek-ai/dsh-client-locale": "workspace:*", + "@deepseek-ai/dsh-client-ui-conversation": "workspace:*", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:*", + "@deepseek-ai/dsh-client-test-runtime": "workspace:*", + "@deepseek-ai/dsh-client-ui-primitives": "workspace:*", + "@deepseek-ai/dsh-client-ui-slots": "workspace:*", "@types/react": "~18.3.1", - "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/cordis": "workspace:~", "react": "^18.2.0", - "@deepseek-ai/dsh-file-reference": "workspace:^", - "@deepseek-ai/dsh-api-session-controller": "workspace:^", - "@deepseek-ai/dsh-client-store": "workspace:^", - "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/dsh-util-values": "workspace:^", - "@deepseek-ai/dsh-client-ui-session": "workspace:^", + "@deepseek-ai/dsh-file-reference": "workspace:*", + "@deepseek-ai/dsh-api-session-controller": "workspace:*", + "@deepseek-ai/dsh-client-store": "workspace:*", + "@deepseek-ai/dsh-session": "workspace:*", + "@deepseek-ai/dsh-util-values": "workspace:*", + "@deepseek-ai/dsh-client-ui-session": "workspace:*", "clsx": "^2.0.0" }, "files": [ diff --git a/packages/client/ui-jobs/README.i18n.yaml b/packages/client/ui-jobs/README.i18n.yaml index b91ee92562..03e39bf92e 100644 --- a/packages/client/ui-jobs/README.i18n.yaml +++ b/packages/client/ui-jobs/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-jobs/README.md -README.md: 6f2b856a970cc869676bbda57d749cf4fb7ceba0 -README.zh.md: b99723e841cfb3d6c52ba0ead7e1ab4dd1952e51 +README.md: 0c530b802b502ea9db3135a711ed5b643584a79e +README.zh.md: 86ac2890111c098d01d5a7bc2997f99e1aa4065f diff --git a/packages/client/ui-jobs/README.md b/packages/client/ui-jobs/README.md index 6f2b856a97..0c530b802b 100644 --- a/packages/client/ui-jobs/README.md +++ b/packages/client/ui-jobs/README.md @@ -45,7 +45,7 @@ Expanding an observable row opens that job's output observation stream from `ctx
Implementation internals — click to expand -One slot entry in the header actions band (after the preset label, before the subagent catalog) renders the trigger and popover; the popover fits itself to the viewport by measuring its anchor. All data arrives through `ctx.jobs` — the component holds no transport state. The roster follows the mount: one `useEffect` keeps the session's `job.list` stream open while the control lives. Observation follows visibility: another `useEffect` opens the stream for the expanded row's job and closes it on collapse, unmount, or popover close. +One slot entry in the header actions band (after the preset label) renders the trigger and popover; the popover fits itself to the viewport by measuring its anchor. All data arrives through `ctx.jobs` — the component holds no transport state. The roster follows the mount: one `useEffect` keeps the session's `job.list` stream open while the control lives. Observation follows visibility: another `useEffect` opens the stream for the expanded row's job and closes it on collapse, unmount, or popover close. | File | Role | |---|---| diff --git a/packages/client/ui-jobs/README.zh.md b/packages/client/ui-jobs/README.zh.md index b99723e841..86ac289011 100644 --- a/packages/client/ui-jobs/README.zh.md +++ b/packages/client/ui-jobs/README.zh.md @@ -45,7 +45,7 @@ kind: "package-reference"
实现内幕——点击展开 -头部操作带里的一个 slot 条目(preset 标签之后、subagent 目录之前)渲染触发器与弹出层;弹出层通过测量锚点把自己收进视口。所有数据经 `ctx.jobs` 到达——组件不持有任何传输状态。名册跟随挂载:一个 `useEffect` 在控件存活期间保持会话的 `job.list` 流打开。观测跟随可见性:另一个 `useEffect` 为展开行的 job 打开流,并在收起、卸载或弹出层关闭时关闭它。 +头部操作带里的一个 slot 条目(preset 标签之后)渲染触发器与弹出层;弹出层通过测量锚点把自己收进视口。所有数据经 `ctx.jobs` 到达——组件不持有任何传输状态。名册跟随挂载:一个 `useEffect` 在控件存活期间保持会话的 `job.list` 流打开。观测跟随可见性:另一个 `useEffect` 为展开行的 job 打开流,并在收起、卸载或弹出层关闭时关闭它。 | 文件 | 角色 | |---|---| diff --git a/packages/client/ui-jobs/package.json b/packages/client/ui-jobs/package.json index 533c729dcd..021b4d2ac6 100644 --- a/packages/client/ui-jobs/package.json +++ b/packages/client/ui-jobs/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-jobs", "description": "Session-header background-job list with on-demand streaming record panels", - "version": "0.1.7-alpha.1", + "version": "0.1.7-alpha.2", "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -31,20 +31,20 @@ "access": "public" }, "peerDependencies": { - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:~" }, "devDependencies": { - "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-api-job-controller": "workspace:^", - "@deepseek-ai/dsh-client-locale": "workspace:^", - "@deepseek-ai/dsh-client-test-runtime": "workspace:^", - "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", - "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", - "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", - "@deepseek-ai/dsh-client-ui-session": "workspace:^", - "@deepseek-ai/dsh-client-ui-slots": "workspace:^", - "@deepseek-ai/dsh-jobs": "workspace:^", - "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/cordis": "workspace:~", + "@deepseek-ai/dsh-api-job-controller": "workspace:*", + "@deepseek-ai/dsh-client-locale": "workspace:*", + "@deepseek-ai/dsh-client-test-runtime": "workspace:*", + "@deepseek-ai/dsh-client-ui-conversation": "workspace:*", + "@deepseek-ai/dsh-client-ui-primitives": "workspace:*", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:*", + "@deepseek-ai/dsh-client-ui-session": "workspace:*", + "@deepseek-ai/dsh-client-ui-slots": "workspace:*", + "@deepseek-ai/dsh-jobs": "workspace:*", + "@deepseek-ai/dsh-session": "workspace:*", "@types/react": "~18.3.1", "react": "^18.2.0" }, diff --git a/packages/client/ui-jobs/src/client/index.ts b/packages/client/ui-jobs/src/client/index.ts index 780b423555..91f93a2442 100644 --- a/packages/client/ui-jobs/src/client/index.ts +++ b/packages/client/ui-jobs/src/client/index.ts @@ -37,8 +37,7 @@ export function apply(ctx: ClientContext): void { () => ctx.slots.register({ name: 'conversation.session.header.actions', id: 'job-list', - // Between the preset label and the subagent catalog (order 30): running - // work reads before the session lineage. + // Background work follows the preset label in the header actions band. order: 20, locale: NS, inject: (): JobListInjected => ({ diff --git a/packages/client/ui-layout/package.json b/packages/client/ui-layout/package.json index 3ae2159164..cf4b95f168 100644 --- a/packages/client/ui-layout/package.json +++ b/packages/client/ui-layout/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-layout", "description": "Shell plugin: three-column AppFrame with drag handles, ctx.layout viewing-state service (navigation + panels)", - "version": "0.1.7-alpha.1", + "version": "0.1.7-alpha.2", "publishConfig": { "access": "public" }, @@ -42,18 +42,18 @@ }, "license": "MIT", "peerDependencies": { - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:~" }, "devDependencies": { - "@deepseek-ai/dsh-brand": "workspace:^", - "@deepseek-ai/dsh-client-locale": "workspace:^", - "@deepseek-ai/dsh-client-store": "workspace:^", - "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", - "@deepseek-ai/dsh-client-ui-session": "workspace:^", - "@deepseek-ai/dsh-client-ui-slots": "workspace:^", - "@deepseek-ai/dsh-client-ui-theme": "workspace:^", + "@deepseek-ai/dsh-brand": "workspace:*", + "@deepseek-ai/dsh-client-locale": "workspace:*", + "@deepseek-ai/dsh-client-store": "workspace:*", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:*", + "@deepseek-ai/dsh-client-ui-session": "workspace:*", + "@deepseek-ai/dsh-client-ui-slots": "workspace:*", + "@deepseek-ai/dsh-client-ui-theme": "workspace:*", "@types/react": "~18.3.1", - "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/cordis": "workspace:~", "react": "^18.2.0" }, "files": [ diff --git a/packages/client/ui-message-feedback/package.json b/packages/client/ui-message-feedback/package.json index b27cce2458..b60cdadcfd 100644 --- a/packages/client/ui-message-feedback/package.json +++ b/packages/client/ui-message-feedback/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-message-feedback", "description": "The Web feedback surface: per-message Like/Dislike in the assistant-message action strip and the feedback dialog behind both ratings and /feedback, backed by the messageFeedback and sessionFeedback Host Remotes", - "version": "0.1.7-alpha.1", + "version": "0.1.7-alpha.2", "publishConfig": { "access": "public" }, @@ -43,32 +43,32 @@ }, "license": "MIT", "peerDependencies": { - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:~" }, "devDependencies": { - "@deepseek-ai/dsh-api-remotes": "workspace:^", - "@deepseek-ai/dsh-client-connection": "workspace:^", - "@deepseek-ai/dsh-client-locale": "workspace:^", - "@deepseek-ai/dsh-client-test-runtime": "workspace:^", - "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", - "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", - "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", - "@deepseek-ai/dsh-client-ui-slots": "workspace:^", - "@deepseek-ai/dsh-message-feedback": "workspace:^", - "@deepseek-ai/dsh-remote-mock": "workspace:^", - "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/dsh-typert-protocol": "workspace:^", - "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-api-remotes": "workspace:*", + "@deepseek-ai/dsh-client-connection": "workspace:*", + "@deepseek-ai/dsh-client-locale": "workspace:*", + "@deepseek-ai/dsh-client-test-runtime": "workspace:*", + "@deepseek-ai/dsh-client-ui-conversation": "workspace:*", + "@deepseek-ai/dsh-client-ui-primitives": "workspace:*", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:*", + "@deepseek-ai/dsh-client-ui-slots": "workspace:*", + "@deepseek-ai/dsh-message-feedback": "workspace:*", + "@deepseek-ai/dsh-remote-mock": "workspace:*", + "@deepseek-ai/dsh-session": "workspace:*", + "@deepseek-ai/dsh-typert-protocol": "workspace:*", + "@deepseek-ai/cordis": "workspace:~", "@testing-library/react": "^16.1.0", "@types/react": "~18.3.1", "@types/react-dom": "~18.3.0", "react": "^18.2.0", "react-dom": "^18.2.0", - "@deepseek-ai/dsh-client-ui-chat": "workspace:^", - "@deepseek-ai/dsh-client-ui-session": "workspace:^", - "@deepseek-ai/dsh-client-ui-commands": "workspace:^", - "@deepseek-ai/dsh-command-feedback": "workspace:^", - "@deepseek-ai/dsh-client-store": "workspace:^" + "@deepseek-ai/dsh-client-ui-chat": "workspace:*", + "@deepseek-ai/dsh-client-ui-session": "workspace:*", + "@deepseek-ai/dsh-client-ui-commands": "workspace:*", + "@deepseek-ai/dsh-command-feedback": "workspace:*", + "@deepseek-ai/dsh-client-store": "workspace:*" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-model-selection/README.i18n.yaml b/packages/client/ui-model-selection/README.i18n.yaml index 10481b46f8..9dccc32faa 100644 --- a/packages/client/ui-model-selection/README.i18n.yaml +++ b/packages/client/ui-model-selection/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-model-selection/README.md -README.md: 37253cf04242275f22d646da0e65cfd44f4fefbf -README.zh.md: 4e339e6a6c6db8c3125e6ccc19c0fc3af9947f88 +README.md: 60a1491779cda03daac53cecf3fe1597e6930414 +README.zh.md: b1d297877bfe4f112e07e357f836b4cb06608077 diff --git a/packages/client/ui-model-selection/README.md b/packages/client/ui-model-selection/README.md index 37253cf042..60a1491779 100644 --- a/packages/client/ui-model-selection/README.md +++ b/packages/client/ui-model-selection/README.md @@ -27,6 +27,8 @@ The Web GUI lets users switch the model and reasoning effort for an existing ses Mount this plugin alongside `ui-conversation` and the commands package; the composer then shows the model seat next to the pending indicator, and `/model` opens the same directory as a popup. While the seat's menu is open, `↑`/`↓` move focus across the rows of the shown pane, Tab settles the focused row, and Escape and `Shift+Tab` leave a drilled pane first and otherwise close back to the trigger. Drilling lands on the row of the value in use, and going back lands on the cell that opened the pane left. Both surfaces show the host-reported current selection when the exact provider/model pair remains in the advertised groups; a missing catalog row leaves the routable selection intact while the trigger prompts `Select model`. +Mouse selection uses native browser clicks, including their cancellation behavior; a press alone never selects. Opening the menu focuses its trigger, and clicking the trigger again closes the menu and returns focus there. While a selection is pending, focus stays on the trigger; a rejected selection leaves the menu open, and Tab returns to the current row. + ### Model and effort Models stay grouped by provider. The composer menu shows model and effort names only. The `/model` popup shows provider names and catalog descriptions; it localizes the two built-in DeepSeek descriptions and leaves external provider descriptions verbatim. The popup applies the selected model's default effort; the composer can then choose any advertised effort. An adapter without reasoning metadata leaves the Effort row absent; there is no arbitrary effort input. diff --git a/packages/client/ui-model-selection/README.zh.md b/packages/client/ui-model-selection/README.zh.md index 4e339e6a6c..b1d297877b 100644 --- a/packages/client/ui-model-selection/README.zh.md +++ b/packages/client/ui-model-selection/README.zh.md @@ -27,6 +27,8 @@ Web GUI 允许用户通过 `/model` 弹窗或 composer 模型控件切换既有 与 `ui-conversation` 及命令包一起挂载本插件;composer 随即在待处理指示器旁显示模型位,`/model` 则以弹窗打开同一份目录。模型位菜单打开期间,`↑`/`↓` 在所显示面板的行间移动焦点,`Tab` 选定聚焦行,Escape 与 `Shift+Tab` 先退出已下钻的面板,否则关闭并回到触发器。下钻落在正在使用的那一行,返回则落在打开该面板的格子上。当确切提供方/模型对仍在已公布分组中时,两个界面都显示 Host 报告的当前选择;目录行缺席时,可路由的选择保持不变,触发器提示 `Select model`。 +鼠标选择沿用浏览器原生点击及其取消行为;仅按下按钮不会选定。打开菜单时聚焦触发按钮,再次点击触发按钮会关闭菜单并把焦点还给它。等待选择结果时,焦点停在触发按钮;选择被拒绝后菜单保持打开,Tab 可回到当前选中的行。 + ### 模型与推理强度 模型按提供方分组。composer 菜单只显示模型与推理强度名称。`/model` 弹窗显示提供方名称与目录说明;其中两个内置 DeepSeek 模型的说明使用当前语言,外部提供方说明保持原文。弹窗应用所选模型的默认推理强度;composer 随后可以选择任一已公布的推理强度。适配器没有推理元数据时不显示 Effort 行;不存在任意推理强度输入。 diff --git a/packages/client/ui-model-selection/package.json b/packages/client/ui-model-selection/package.json index eaaf7a5071..15c9158526 100644 --- a/packages/client/ui-model-selection/package.json +++ b/packages/client/ui-model-selection/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-model-selection", "description": "Model selection over the shared model catalog, Session projection, and session.selectModel", - "version": "0.1.7-alpha.1", + "version": "0.1.7-alpha.2", "publishConfig": { "access": "public" }, @@ -42,29 +42,29 @@ }, "license": "MIT", "peerDependencies": { - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:~" }, "devDependencies": { - "@deepseek-ai/dsh-api-remotes": "workspace:^", - "@deepseek-ai/dsh-api-session-controller": "workspace:^", - "@deepseek-ai/dsh-client-locale": "workspace:^", - "@deepseek-ai/dsh-client-test-runtime": "workspace:^", - "@deepseek-ai/dsh-client-ui-commands": "workspace:^", - "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", - "@deepseek-ai/dsh-client-ui-input-trigger": "workspace:^", - "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", - "@deepseek-ai/dsh-client-ui-slots": "workspace:^", - "@deepseek-ai/dsh-typert-protocol": "workspace:^", + "@deepseek-ai/dsh-api-remotes": "workspace:*", + "@deepseek-ai/dsh-api-session-controller": "workspace:*", + "@deepseek-ai/dsh-client-locale": "workspace:*", + "@deepseek-ai/dsh-client-test-runtime": "workspace:*", + "@deepseek-ai/dsh-client-ui-commands": "workspace:*", + "@deepseek-ai/dsh-client-ui-conversation": "workspace:*", + "@deepseek-ai/dsh-client-ui-input-trigger": "workspace:*", + "@deepseek-ai/dsh-client-ui-primitives": "workspace:*", + "@deepseek-ai/dsh-client-ui-slots": "workspace:*", + "@deepseek-ai/dsh-typert-protocol": "workspace:*", "@types/react": "~18.3.1", - "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/cordis": "workspace:~", "react": "^18.2.0", "react-dom": "^18.2.0", "@types/react-dom": "~18.3.0", - "@deepseek-ai/dsh-client-store": "workspace:^", - "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/dsh-util-values": "workspace:^", - "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", - "@deepseek-ai/dsh-client-ui-session": "workspace:^", + "@deepseek-ai/dsh-client-store": "workspace:*", + "@deepseek-ai/dsh-session": "workspace:*", + "@deepseek-ai/dsh-util-values": "workspace:*", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:*", + "@deepseek-ai/dsh-client-ui-session": "workspace:*", "clsx": "^2.1.1" }, "files": [ diff --git a/packages/client/ui-model-selection/src/client/ModelSelect.tsx b/packages/client/ui-model-selection/src/client/ModelSelect.tsx index b9c8993563..c7db6f1c0b 100644 --- a/packages/client/ui-model-selection/src/client/ModelSelect.tsx +++ b/packages/client/ui-model-selection/src/client/ModelSelect.tsx @@ -190,6 +190,7 @@ export function ModelSelect( if (!available) return null const show = (): void => { + triggerRef.current?.focus() setPane('root') setOpen(true) reload() @@ -291,13 +292,19 @@ export function ModelSelect( }) } + const submit = (selection: ModelSelection): void => { + lastActionRef.current = 'select' + // Disabled option rows cannot retain focus while a selection is pending. + triggerRef.current?.focus() + void select(selection).then(settleSelection) + } + const choose = (selection: ModelSelection): void => { if (state.current?.provider === selection.provider && state.current.model === selection.model) { close(true) return } - lastActionRef.current = 'select' - void select(selection).then(settleSelection) + submit(selection) } const chooseEffort = (effort: string | undefined): void => { @@ -311,8 +318,7 @@ export function ModelSelect( model: state.current.model, ...effort === undefined ? {} : { reasoningEffort: effort }, } - lastActionRef.current = 'select' - void select(selection).then(settleSelection) + submit(selection) } const waiting = state.current === null && state.status === 'loading' @@ -336,7 +342,16 @@ export function ModelSelect( } return ( -
+
{ + // WebKit blurs a focused row before click unless the button's mousedown keeps focus. + if (event.target instanceof Element && event.target.closest('button') !== null) event.preventDefault() + }} + > + + + )} + /> + ) + } if (phase === 'idle' || phase === 'checking') { const checking = phase === 'checking' const empty = install.spec.trim() === '' @@ -799,7 +817,7 @@ function InstallDialog({ onClose={onClose} title={t('installTitle')} closeLabel={t('close')} - description={t('installDescription')} + {...install.mirrorRecovery ? {} : { description: t('installDescription') }} className={css.installDialog as string} contentClassName={css.installContent as string} footer={( @@ -813,10 +831,11 @@ function InstallDialog({
{ onEditSpec(event.currentTarget.value) }} @@ -1321,6 +1340,7 @@ export function PluginManagerPage(props: PluginManagerPageProps): ReactNode { onToggleRegistry={props.toggleRegistryOptions} onChooseRegistry={props.chooseRegistry} onChangeRegistry={props.changeRegistry} + onUseGithubMirror={props.useGithubMirror} /> {state.confirm === null ? null diff --git a/packages/client/ui-plugin-manager/src/client/locales.ts b/packages/client/ui-plugin-manager/src/client/locales.ts index 30116aa2bf..bd27fb5041 100644 --- a/packages/client/ui-plugin-manager/src/client/locales.ts +++ b/packages/client/ui-plugin-manager/src/client/locales.ts @@ -17,7 +17,7 @@ export const zh = { bundlesTitle: '已安装', officialTitle: '官方', statusProblem: '异常', - statusBeta: 'Beta', + statusBeta: '实验性', reasonLabel: '原因', metadataError: '包元信息错误:{error}', versionTag: 'v{version}', @@ -87,6 +87,11 @@ export const zh = { installingTitle: '插件安装中…', installedTitle: '已安装', installFailedTitle: '插件安装失败', + installGithubFailedTitle: '无法访问 GitHub', + installGithubTimeoutTitle: '连接 GitHub 超时', + installGithubFailedDescription: '请尝试其他安装来源。', + installUseGithubMirror: '改用国内镜像', + installPackageLabel: '插件包名', installEdit: '编辑', installEditAria: '返回编辑', installCancelAndEdit: '取消安装并返回编辑', @@ -201,7 +206,7 @@ export const en = { bundlesTitle: 'Installed', officialTitle: 'Official', statusProblem: 'Problem', - statusBeta: 'Beta', + statusBeta: 'Experimental', reasonLabel: 'Reason', metadataError: 'Package metadata error: {error}', versionTag: 'v{version}', @@ -271,6 +276,11 @@ export const en = { installingTitle: 'Installing the plugin…', installedTitle: 'Installed', installFailedTitle: 'The plugin could not be installed', + installGithubFailedTitle: 'Cannot access GitHub', + installGithubTimeoutTitle: 'GitHub connection timed out', + installGithubFailedDescription: 'Try another installation source.', + installUseGithubMirror: 'Use mainland China mirror', + installPackageLabel: 'Plugin package name', installEdit: 'Edit', installEditAria: 'Back to editing', installCancelAndEdit: 'Cancel installation and return to editing', diff --git a/packages/client/ui-plugin-manager/src/client/manager-store.ts b/packages/client/ui-plugin-manager/src/client/manager-store.ts index 6b55628588..4fe666b5b6 100644 --- a/packages/client/ui-plugin-manager/src/client/manager-store.ts +++ b/packages/client/ui-plugin-manager/src/client/manager-store.ts @@ -147,6 +147,8 @@ export interface InstallState { readonly open: boolean /** The package spec as typed. */ readonly spec: string + /** The spec form was reopened after switching away from a failed GitHub address. */ + readonly mirrorRecovery?: boolean /** The registries the Host configured, read when the dialog opens; null until the Host answered. */ readonly registries: PluginRegistries | null /** The registry this install asks first: the one last used, else the Host's first. */ @@ -204,6 +206,20 @@ export function isInstallPending(phase: InstallState['phase']): boolean { return phase === 'starting' || phase === 'running' || phase === 'cancelling' || phase === 'applying' || phase === 'unconfirmed' } +/** + * Offer the configured mainland mirror after a confirmed GitHub connection failure. + * @param install - the installation and the Host's failure attribution. + * @returns the offered mirror URL, or undefined when this recovery does not apply. + */ +export function githubRecoveryRegistry(install: InstallState): string | undefined { + if (install.phase !== 'failed' || install.failure?.failedAt !== 'spec-host' + || (install.failure.kind !== 'network' && install.failure.kind !== 'timeout')) return undefined + const host = install.subject?.host?.toLowerCase().split(':')[0] + if (host !== 'github.com' && !host?.endsWith('.github.com')) return undefined + return offeredRegistries(install.registries).find((registry): registry is string => + registry !== null && new URL(registry).hostname === 'registry.npmmirror.com') +} + /** A destructive action waiting for the user's confirmation: a package's uninstall. */ export interface ConfirmState { readonly action: 'uninstall' @@ -254,6 +270,8 @@ export interface PluginManagerFace { chooseRegistry: (choice: RegistryChoice) => void /** From the failed screen: back to the spec with the registry options unfolded. */ changeRegistry: () => void + /** Return from a GitHub connection failure to an empty spec with the offered mainland mirror selected. */ + useGithubMirror: () => void /** Allow the install scripts the failed run left pending, saved for this profile, and run the same spec again. */ approveBuildsAndRetry: () => void /** Leave the check or the failed screen for the spec, or ask the Host to stop the run and wait for its cleanup. */ @@ -374,8 +392,8 @@ const IDLE_INSTALL: InstallState = { /** The dialog back at its spec: the run and its outcome forgotten, the spec and the registries kept. */ function specAgain(install: InstallState): InstallState { - const { open, spec, registries, registry } = install - return { ...IDLE_INSTALL, open, spec, registries, registry } + const { open, spec, registries, registry, mirrorRecovery } = install + return { ...IDLE_INSTALL, open, spec, registries, registry, ...mirrorRecovery === undefined ? {} : { mirrorRecovery } } } /** @@ -515,6 +533,14 @@ export class PluginManagerController { const install = this.getSnapshot().install if (install.phase === 'failed') this.patch({ install: { ...specAgain(install), registryOpen: true } }) }, + useGithubMirror: () => { + const install = this.getSnapshot().install + const mirror = githubRecoveryRegistry(install) + if (mirror === undefined) return + const registry: RegistryChoice = { kind: 'offered', registry: mirror } + this.registryMemory.set(registry) + this.patch({ install: { ...specAgain(install), spec: '', mirrorRecovery: true, registry } }) + }, approveBuildsAndRetry: () => { void this.approveBuildsAndRetry() }, cancelInstall: () => { void this.cancelInstall() }, reconcileInstall: () => { void this.reconcileInstall() }, diff --git a/packages/client/ui-plugin-manager/tests/components.client.spec.tsx b/packages/client/ui-plugin-manager/tests/components.client.spec.tsx index 4e13674dd1..906578bc85 100644 --- a/packages/client/ui-plugin-manager/tests/components.client.spec.tsx +++ b/packages/client/ui-plugin-manager/tests/components.client.spec.tsx @@ -106,6 +106,7 @@ function renderTab( toggleRegistryOptions: vi.fn(), chooseRegistry: vi.fn(), changeRegistry: vi.fn(), + useGithubMirror: vi.fn(), approveBuildsAndRetry: vi.fn(), enableInstalled: vi.fn(), clearHighlight: vi.fn(), @@ -1056,6 +1057,63 @@ describe('PluginManagerPage', () => { expect(screen.getByText(en.installSubjectTarball)).toBeTruthy() }) + it.each(['network', 'timeout'] as const)('shows GitHub recovery only after a %s failure and returns to package input', (kind) => { + const subject = { spec: 'github:a/b', status: 'accepted', kind: 'git', bundle: null, registry: null, host: 'github.com' } as const + const failed: InstallState = { + ...IDLE_INSTALL, open: true, spec: subject.spec, registries: REGISTRIES, phase: 'failed', subject, + failure: { reason: 'GitHub connection failed', kind, failedAt: 'spec-host' }, + } + const { actions, set, setLanguage } = renderTab({ install: { ...failed, phase: 'idle', failure: null } }) + expect(screen.queryByText(en.installGithubFailedTitle)).toBeNull() + set({ install: failed }) + const dialog = screen.getByRole('dialog', { name: kind === 'timeout' ? en.installGithubTimeoutTitle : en.installGithubFailedTitle }) + expect(within(dialog).getByText(en.installGithubFailedDescription)).toBeTruthy() + expect(screen.queryByRole('button', { name: en.installRetry })).toBeNull() + fireEvent.click(within(dialog).getByRole('button', { name: en.installUseGithubMirror })) + expect(actions.useGithubMirror).toHaveBeenCalledOnce() + expect(actions.runInstall).not.toHaveBeenCalled() + fireEvent.click(within(dialog).getByRole('button', { name: en.cancel })) + expect(actions.closeInstall).toHaveBeenCalledOnce() + setLanguage(zh) + expect(screen.getByRole('dialog', { name: kind === 'timeout' ? '连接 GitHub 超时' : '无法访问 GitHub' })).toBeTruthy() + expect(screen.getByText('请尝试其他安装来源。')).toBeTruthy() + set({ install: { ...IDLE_INSTALL, open: true, mirrorRecovery: true, registries: REGISTRIES, registry: { kind: 'offered', registry: MIRROR } } }) + const form = within(screen.getByRole('dialog', { name: zh.installTitle })) + expect(form.queryByText(zh.installDescription)).toBeNull() + expect(form.queryByRole('textbox', { name: zh.installSpecLabel })).toBeNull() + const input = screen.getByRole('textbox', { name: zh.installPackageLabel }) + expect(input).toHaveProperty('value', '') + expect(document.activeElement).toBe(input) + expect(screen.getByRole('button', { name: zh.installRun })).toHaveProperty('disabled', true) + expect(form.getAllByRole('button')).toEqual([ + form.getByRole('button', { name: zh.close }), + form.getByRole('button', { name: zh.installGuideToggle }), + form.getByRole('button', { name: '安装源 中国大陆镜像源' }), + form.getByRole('button', { name: zh.installRun }), + ]) + }) + + it('keeps the ordinary failure view for registry errors, other hosts, and unavailable mirrors', () => { + const failed: InstallState = { + ...IDLE_INSTALL, open: true, registries: REGISTRIES, phase: 'failed', + subject: { spec: 'github:a/b', status: 'accepted', kind: 'git', bundle: null, registry: null, host: 'github.com' }, + failure: { reason: 'failed', kind: 'network', failedAt: 'registry' }, + } + const { set } = renderTab({ install: failed }) + expect(screen.getByRole('button', { name: en.installChangeRegistry })).toBeTruthy() + for (const patch of [ + { subject: { ...failed.subject!, host: 'gitlab.com' } }, + { subject: null }, + { registries: { ...REGISTRIES, fallbackRegistries: [] } }, + { registries: null }, + { failure: { reason: 'not found', kind: 'not-found' as const, failedAt: 'spec-host' as const } }, + ]) { + set({ install: { ...failed, failure: { reason: 'failed', kind: 'network', failedAt: 'spec-host' }, ...patch } }) + expect(screen.queryByRole('dialog', { name: en.installGithubFailedTitle })).toBeNull() + expect(screen.getByRole('button', { name: en.installRetry })).toBeTruthy() + } + }) + it('offers the registries under the spec, folded by default, and picks or types one', () => { const open = { ...IDLE_INSTALL, open: true, registries: REGISTRIES } const { actions, set } = renderTab({ install: open }) diff --git a/packages/client/ui-plugin-manager/tests/manager-store.client.spec.ts b/packages/client/ui-plugin-manager/tests/manager-store.client.spec.ts index ac022135ef..829d8b4fd4 100644 --- a/packages/client/ui-plugin-manager/tests/manager-store.client.spec.ts +++ b/packages/client/ui-plugin-manager/tests/manager-store.client.spec.ts @@ -1155,6 +1155,48 @@ describe('PluginManagerController', () => { face.changeRegistry() expect(state().install).toMatchObject({ phase: 'idle', spec: 'dsh-new', registryOpen: true, runs: [], failure: null }) }) + + it.each(['network', 'timeout'] as const)('recovers a GitHub %s failure without retrying its URL through a mirror', async (kind) => { + const spec = 'https://github.com/example/dsh-plugin.git' + const inspect = vi.fn().mockResolvedValueOnce(ok({ status: 'accepted', kind: 'git', bundle: null, registry: null, host: 'github.com' })) + .mockResolvedValue(ok({ ...INSPECTED, registry: MIRROR })) + const { face, state, plugins } = bench({ + inspect, + installBundle: vi.fn().mockResolvedValueOnce(ok({ + ...failed(undefined, { exitCode: 1, output: 'Could not resolve host: github.com', truncated: false, logPath: '/l', kind }), + failedAt: 'spec-host', + })).mockResolvedValueOnce(ok({ + ...failed(undefined, { exitCode: 1, output: 'Registry connection failed', truncated: false, logPath: '/l', kind: 'network' }), + failedAt: 'registry', + })).mockResolvedValue(ok({ ...APPLIED, bundle: 'dsh-new' })), + }) + face.openInstall() + await vi.waitFor(() => { expect(state().install.registries).toEqual(REGISTRIES) }) + face.editInstallSpec(spec) + face.useGithubMirror() + expect(state().install.spec).toBe(spec) + face.runInstall() + await vi.waitFor(() => { expect(state().install.phase).toBe('failed') }) + face.useGithubMirror() + expect(state().install).toMatchObject({ + phase: 'idle', open: true, spec: '', mirrorRecovery: true, registry: { kind: 'offered', registry: MIRROR }, + registryOpen: false, failure: null, runs: [], + }) + expect(plugins.installBundle).toHaveBeenCalledTimes(1) + face.editInstallSpec('dsh-new') + face.runInstall() + await vi.waitFor(() => { expect(state().install.phase).toBe('failed') }) + face.changeRegistry() + expect(state().install).toMatchObject({ phase: 'idle', spec: 'dsh-new', mirrorRecovery: true, registryOpen: true }) + face.runInstall() + await vi.waitFor(() => { expect(state().install.phase).toBe('done') }) + expect(plugins.inspect).toHaveBeenLastCalledWith('dsh-new', { registry: MIRROR }, expect.any(AbortSignal)) + expect(plugins.installBundle).toHaveBeenLastCalledWith('dsh-new', expect.objectContaining({ registry: MIRROR })) + face.closeInstall() + face.openInstall() + await vi.waitFor(() => { expect(state().install.registries).toEqual(REGISTRIES) }) + expect(state().install.registry).toEqual({ kind: 'offered', registry: MIRROR }) + }) }) describe('Host registry response recommendation', () => { diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index 98d7fde4c3..ffec7ff98d 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-primitives/README.md -README.md: ffacf9aca9e3234f87b34ce3e016bc95e15d5e4d -README.zh.md: 76dbc91062ec5024c7fad0353eb7cdf0903d26c6 +README.md: 4dcc89968e7d0b53b6dfe3b9e6cc5752f4f87efd +README.zh.md: e86972925d6b2d46aeadba84434be65f8f0ba55c diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index ffacf9aca9..4dcc89968e 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -49,10 +49,10 @@ Check this table before writing a control in a feature package. A plugin cannot | `StateDot` | Solid green `done`, amber `warning`, red `error`, and neutral-grey `idle` marks in a 10px slot, plus a tertiary-grey 14px rotating `ongoing` loader whose animations pin to document time zero so every visible loader rotates in phase. `aria-hidden`, so the render site owns the name. `appearance="step"` shows a filled check for completion and a hollow pending circle. | | `ConnectionIndicator` | Inline connection-recovery control across outage, retry, and recovered states. | | `DisclosureRow` | 24px compact disclosure that lays title and content side by side. Memoized with shallow prop comparison; keep callbacks and React-node props stable when their content is unchanged. | -| `Modal` | Centered dialog over a page mask. | +| `Modal` | Centered dialog over a page mask. A nested dialog can intercept keys with `onKeyDownCapture` before document Escape handlers. | | `RiskConfirmation` | Sensitive action gated behind an explicit checkbox. | | `OnboardingSurface` | First-run stage that holds the application root inert. | -| `Tooltip` | Hover text anchored to a cloned child; optional `portal` rendering escapes clipping containers. | +| `Tooltip` | Hover text anchored to a cloned child; optional `portal` rendering escapes clipping containers and ancestor stacking contexts that cap the bubble's z-index. | | `HoverCard` | Hover preview the pointer can rest on and select from; optional copy button. | | `Toast` | Transient top-center banner held for the owner's `holdMs`. | | `SettingsForm`, `SettingsValueField`, `SettingsSecretField` | The frame and the controls of a plugin's settings page: the frame takes its copy as `labels`, saves only on its button, and discards on unmount; a value field shows staged text with the overridden badge and reset; a secret field starts blank and reports only whether a value is configured. | @@ -79,6 +79,8 @@ Writing your own component in your own package is fine when the need is genuinel The catalog above lists what each export is for; this section covers the behavior that props alone do not show. Product icon names omit artboard sizes and end in `Regular` for the supplied one-pixel artwork or `Medium` for the same geometry at a 1.3px stroke; the `size` prop controls rendered dimensions ([decision](../../../.agents/notes/implemented/architecture/2026-09-16-size-neutral-product-icon-weights.md)). Each product, reference, link, and permission glyph intentionally keeps both weight exports even when the current product uses only one, so callers can choose emphasis without adding another API later; fill-only pairs render identically. `IconWarningOutlineRegular`/`Medium` use a circle; `IconWarningTriangleOutlineRegular`/`Medium` use a rounded triangle. `FishLogo` and `BrandWordmark` fill brand slots. `FileTypeIcon` renders the traditional 28px spreadsheet, folder, HTML, image, Markdown, generic, PDF, PPT, video, and Word glyphs, and uses the imported square technology artwork for the established 48 code and configuration categories. The import replaces artwork only: archive-only categories do not extend `CodeFileType`. `classifyFileType` applies exact filename, prefix, suffix, optional project-context, and extension rules in that order; React names win over TypeScript/JavaScript, Angular suffixes win over their base extension, and a Dart file becomes Flutter only when the supplied project files contain a `pubspec.yaml` with `flutter:`. Markdown and SVG remain traditional Markdown and image files. Spreadsheet mappings include CSV, TSV, Excel workbooks and templates, OpenDocument spreadsheets, and Numbers; KEY maps to slides, while RTF/ODT/Pages map to documents. `fileExtension` exposes the same basename and final-dot parsing for adjacent metadata labels. Traditional glyphs use a solid category-colored sheet with a white mark and translucent white corner; the generic file uses a grey sheet and darker grey corner. Callers may override the sheet color through `--dsh-file-type-icon-color`. The full-color technology artwork is the deliberate exception and retains its embedded palette. All glyphs are decorative and carry no label. `LinkIconMedium` is the leading glyph for clickable artifact links — globe, folder, code, image, document, or plain paper, and for a `url` link the mark of a well-known site named by its `href` — the developer sites a transcript usually cites (GitHub, GitLab, npm, PyPI, Stack Overflow, MDN, Wikipedia, Hacker News, YouTube, X, Bilibili, Zhihu, Juejin, CSDN) and mainstream search, video, social, shopping, and reference sites (Google, Baidu, DuckDuckGo, TikTok, Netflix, Spotify, Facebook, Instagram, Reddit, Telegram, WhatsApp, WeChat, QQ, Weibo, Taobao, AliExpress, eBay, Quora, V2EX, Apple) — while `classifyLinkPath` folds the shared file types into that existing six-category vocabulary. `ConnectionIndicator` renders a warning-colored disconnected action whose permanent retry glyph marks the retry action beside the owner-supplied outage label, the shared ongoing loader beside a label whose one-to-three dots advance every 500ms independently of retry timing, or a success-colored recovered status. Clicking either warning state requests an immediate reconnect; no hover interaction changes the copy. The pill fades in on appearance, fades out for 150ms before unmounting, and sizes to its current label. Its owner supplies visibility, the recovery hold, localized labels, and the immediate-reconnect callback; the primitive uses no native title tooltip. `useAnchoredPosition` and `useAnchoredMaxHeight` keep floating panels and bottom-anchored overlays clamped to the viewport and following their anchor; `useAnchoredMaxHeight` and portalled `Menu` widen their 12px top viewport margin to the frame's published `--dsh-frame-top-clearance`. `HoverCard` keeps its portaled preview reachable across the anchor gap and can expose a copy button through the `copyText` prop. Its `preview` variant uses the anchor or `widthAnchorRef` element’s width minus 48px, centers the panel with 24px side insets, and places it above or below the row within the viewport. The panel keeps the frame’s top clearance, caps its height at 420px, and follows content and anchor resizing; Escape or pointer and keyboard activation of the anchor dismisses it. The whole preview fades in and out over 100ms; a closing preview stops receiving pointer input and unmounts after the fade. Re-entering the anchor during the fade restores it. Reduced-motion preferences suppress the transition. Copy labels are required only when copying is enabled. `Toast` uses the owner's `holdMs` for both the fade delay and its hold-and-fade lifetime. Rerenders with unchanged `holdMs` do not restart that lifetime; completion uses the latest callback, and fully faded actions cannot receive input. A new component key restarts the banner. `rankByName` is the `/` menu's shared candidate ranker for the command and skill sources: the query must be a case-insensitive ordered subsequence of the name; prefix hits rank first, then alignment score, then source order. Portalled `Menu` lists follow their anchor during dragging and CSS transforms, and stop tracking when closed. `Menu.autoFocus` focuses its first enabled item, supports Arrow Up/Down and Home/End navigation, and focuses the first button in the anchor on Escape; it is opt-in for action menus. +`Tooltip` reads the anchor on hover or keyboard focus and fits its bubble from `ResizeObserver` border-box sizes. The bubble stays hidden until its first fit, slides inside the horizontal viewport margin, and flips above or below only when the opposite side fits. Label-size and viewport changes reuse the anchor coordinates; fitting does not synchronously measure the bubble or trigger a React render. + ### Rendering agent output The nearest `MarkdownDelegateProvider` supplies optional `openExternalLink` and `openFile` navigation callbacks. Nested providers replace the enclosing capabilities, and callback changes reach already-rendered links without rebuilding Markdown. Its `openFile` makes local Markdown links clickable after settlement. Absolute and workspace-relative paths support percent escapes and `#L24` / `#L24-L30` fragments; ranges open at their first line. Literal `?` and `#` in filenames must be percent-encoded. The tooltip uses the decoded path and supplies the accessible name when the label is empty. The callback receives the decoded path and optional line, while the renderer preserves the label and displays a file icon. Without a callback, local links remain text. URL schemes, queries, unsupported fragments, and malformed destinations never reach the file opener. diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index 76dbc91062..e86972925d 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -49,10 +49,10 @@ kind: "package-library" | `StateDot` | 10px 槽内的绿色 `done`、琥珀色 `warning`、红色 `error`、中性灰色 `idle` 圆点,以及 tertiary 灰色 14px 旋转 `ongoing` loading,其动画固定到文档时间零点,所以所有可见 loading 同相旋转。它是 `aria-hidden` 的,名称由渲染点提供。 `appearance="step"` 以实心勾表示完成、空心圆表示等待。 | | `ConnectionIndicator` | 行内连接恢复控件,覆盖断线、重试与已恢复三种状态。 | | `DisclosureRow` | 24px 紧凑折叠行,标题与内容左右排列。使用浅层 prop 比较进行 memo;内容未变时,保持回调与 React 节点 prop 的引用稳定。 | -| `Modal` | 页面遮罩之上的居中对话框。 | +| `Modal` | 页面遮罩之上的居中对话框。嵌套对话框可通过 `onKeyDownCapture` 在文档级 Escape 处理器之前拦截按键。 | | `RiskConfirmation` | 以显式复选框把关的敏感操作确认。 | | `OnboardingSurface` | 首次运行的引导舞台,期间保持应用根节点 inert。 | -| `Tooltip` | 锚定在克隆子元素上的悬停文本;可通过 `portal` 渲染到外层,避免被容器裁剪。 | +| `Tooltip` | 锚定在克隆子元素上的悬停文本;可通过 `portal` 渲染到外层,避免被容器裁剪,或受祖先层叠上下文限制其 z-index。 | | `HoverCard` | 指针可停留、可选中的悬停预览;可选带复制按钮。 | | `Toast` | 顶部居中的瞬时横幅,保持时长由所有者的 `holdMs` 决定。 | | `SettingsForm`、`SettingsValueField`、`SettingsSecretField` | 插件设置页的框架与控件:框架以 `labels` 接收文案,只在按钮点击时保存,卸载即丢弃;值字段显示暂存文本以及已覆盖标签和重置;密文字段每次为空,只报告是否已配置。 | @@ -79,6 +79,8 @@ kind: "package-library" 上面的目录说明每个导出的用途;本节讲 props 本身看不出来的行为。产品图标名称不含画板尺寸,以 `Regular` 表示原始 1px 图形,以 `Medium` 表示同一几何的 1.3px 描边;`size` prop 控制渲染尺寸([决定](../../../.agents/notes/implemented/architecture/2026-09-16-size-neutral-product-icon-weights.zh.md))。每个产品、引用、链接与权限图形都会有意保留两种线重导出,即使当前产品只使用其中一种,也让调用方无需再次扩展 API 就能选择强调程度;仅填充的成对图形外观相同。`IconWarningOutlineRegular`/`Medium` 使用圆形;`IconWarningTriangleOutlineRegular`/`Medium` 使用圆角三角形。`FishLogo` 与 `BrandWordmark` 填充品牌 slot。`FileTypeIcon` 渲染传统的 28px spreadsheet、folder、HTML、image、Markdown、generic、PDF、PPT、video 与 Word 图形,并为现有 48 个代码和配置类别使用导入的方形技术图形。该导入只替换图形:资源包中额外的类别不会扩展 `CodeFileType`。`classifyFileType` 按完整文件名、前缀、后缀、可选项目上下文、扩展名的顺序匹配;React 文件名优先于 TypeScript/JavaScript,Angular 后缀优先于基础扩展名,只有传入的项目文件包含带 `flutter:` 的 `pubspec.yaml` 时 Dart 文件才使用 Flutter。Markdown 与 SVG 仍分别使用传统 Markdown 与图片图形。表格映射包括 CSV、TSV、Excel 工作簿与模板、OpenDocument 表格和 Numbers;KEY 映射为幻灯片,RTF/ODT/Pages 映射为文档。`fileExtension` 为相邻元数据 label 暴露同一套 basename 与最终点号解析。传统图形使用实色分类底板、白色标记和半透明白色折角;通用文件使用灰色底板与较深灰色折角。调用方可通过 `--dsh-file-type-icon-color` 覆盖底板颜色。全彩技术图形是明确例外,会保留其内嵌调色板。所有图形都是装饰性的,不自带 label。`LinkIconMedium` 是可点击产物链接的前置图形——地球、文件夹、代码、图片、文档或纸张,`url` 链接的 `href` 指向已知站点时则改用该站点自己的标记——转写内容常引用的开发者站点(GitHub、GitLab、npm、PyPI、Stack Overflow、MDN、Wikipedia、Hacker News、YouTube、X、Bilibili、知乎、掘金、CSDN),以及主流搜索、视频、社交、购物与参考资料站点(Google、百度、DuckDuckGo、TikTok、Netflix、Spotify、Facebook、Instagram、Reddit、Telegram、WhatsApp、微信、QQ、微博、淘宝、速卖通、eBay、Quora、V2EX、Apple)——`classifyLinkPath` 把共享文件类型折叠进原有六类词汇。`ConnectionIndicator` 可渲染警告色的断联操作(常驻重试图形指明重试动作,断联文案由持有方提供)、共享 ongoing loading 加一至三个点以独立于 retry 时序的 500ms 节奏推进的连接中状态,或成功色的恢复状态。点击任一警告状态都会请求立即重连;没有任何悬停交互会改变文案。药丸出现时淡入、卸载前淡出 150ms,宽度随当前 label 自适应。它的持有方提供可见性、恢复驻留时间、本地化 label 与立即重连回调;该原语不使用原生 title tooltip。`useAnchoredPosition` 与 `useAnchoredMaxHeight` 让浮动面板与底部锚定浮层始终钳制在视口内并跟随锚点;`useAnchoredMaxHeight` 与 portal 模式的 `Menu` 会把 12px 的视口顶部边距加宽到框架发布的 `--dsh-frame-top-clearance`。`HoverCard` 通过指针离开宽限期让采用 portal 的预览在跨过锚点间隙时仍可触及,并可通过 `copyText` prop 提供复制按钮。其 `preview` 变体使用 anchor 或 `widthAnchorRef` 元素的宽度减去 48px,左右各内缩 24px,并在视口内放置于行的上方或下方。浮层避开框架顶部保留区,高度最多 420px,会跟随内容及 anchor 尺寸变化;Escape 或通过鼠标和键盘激活锚点可将其关闭。整个浮层的淡入和淡出各持续 100ms;关闭中的浮层停止接收指针输入,淡出后卸载。在淡出期间移回锚点可恢复显示。减少动态效果偏好会禁用过渡动画。只有启用复制时才必须提供复制标签。`Toast` 使用调用方的 `holdMs` 同时控制淡出延迟与停留加淡出的总时长。`holdMs` 未变时,父组件重渲染不会重启该生命周期;完成时调用最新回调,完全淡出的操作不能接收输入。新的组件 key 会重新开始横幅周期。`rankByName` 是 `/` 菜单命令源与 skill(技能)源共享的候选排序器:查询必须是名字的不区分大小写的有序子序列;前缀命中排最前,其次按对齐分数,再按来源顺序。 portal 模式的 `Menu` 列表会在拖动和 CSS 变换期间跟随锚点,并在关闭时停止跟踪。`Menu.autoFocus` 聚焦首个启用项,支持上下方向键与 Home/End 导航,并在 Escape 时聚焦 anchor 内的第一个按钮;操作菜单可显式启用。 +`Tooltip` 在悬停或键盘聚焦时读取锚点位置,再根据 `ResizeObserver` 提供的边框盒尺寸调整气泡。首次定位前气泡保持隐藏;横向移入视口留白,仅在另一侧容得下时上下翻转。标签尺寸与视口变化复用锚点坐标;定位不会同步测量气泡,也不会触发 React 渲染。 + ### 渲染 agent 输出 最近的 `MarkdownDelegateProvider` 提供可选的 `openExternalLink` 和 `openFile` 导航回调。嵌套 Provider 替换外层能力,回调变化无需重新构建 Markdown 即可到达已渲染链接。其 `openFile` 使本地 Markdown 链接在落定后可点击。绝对路径和工作区相对路径支持百分号转义以及 `#L24` / `#L24-L30` 片段;范围定位到起始行。文件名中的字面 `?` 和 `#` 必须百分号编码。悬停提示使用解码后的路径,并在标签为空时提供可访问名称。回调接收解码后的路径和可选行号,渲染器保留标签并显示文件图标。不传回调时,本地链接仍为文本。URL 协议、查询串、不支持的片段及格式错误的目标不会传给文件打开器。 diff --git a/packages/client/ui-primitives/package.json b/packages/client/ui-primitives/package.json index a3002de65d..52e29beb42 100644 --- a/packages/client/ui-primitives/package.json +++ b/packages/client/ui-primitives/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-primitives", "description": "Pure React atoms for the dsh web UI: controls, icons, markdown, and JSON inspectors (zero cordis)", - "version": "0.1.7-alpha.1", + "version": "0.1.7-alpha.2", "publishConfig": { "access": "public" }, @@ -23,10 +23,10 @@ }, "license": "MIT", "devDependencies": { - "@deepseek-ai/dsh-client-store": "workspace:^", + "@deepseek-ai/dsh-client-store": "workspace:*", "@types/react": "~18.3.1", "@types/react-dom": "~18.3.0", - "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/cordis": "workspace:~", "react": "^18.2.0", "clsx": "^2.0.0", "diff": "^9.0.0", @@ -39,7 +39,7 @@ "micromark-util-symbol": "^2.0.1", "micromark-util-types": "^2.0.2", "shiki": "^4.3.1", - "@deepseek-ai/dsh-util-workspace-path": "workspace:^", + "@deepseek-ai/dsh-util-workspace-path": "workspace:*", "@shikijs/langs": "^4.3.1", "@types/mdast": "^4.0.4", "micromark-factory-space": "^2.0.1", @@ -57,6 +57,6 @@ "lib/types/**/*.d.ts" ], "peerDependencies": { - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:~" } } diff --git a/packages/client/ui-primitives/src/Modal.tsx b/packages/client/ui-primitives/src/Modal.tsx index 58438873a8..dcb5da4535 100644 --- a/packages/client/ui-primitives/src/Modal.tsx +++ b/packages/client/ui-primitives/src/Modal.tsx @@ -1,5 +1,5 @@ import { useEffect } from 'react' -import type { ReactNode } from 'react' +import type { KeyboardEventHandler, ReactNode } from 'react' import { createPortal } from 'react-dom' import clsx from 'clsx' import { IconCloseOutlineRegular } from './icons/index.tsx' @@ -14,6 +14,7 @@ interface ModalBaseProps { footer?: ReactNode className?: string contentClassName?: string + onKeyDownCapture?: KeyboardEventHandler } type ModalProps = ModalBaseProps & ( @@ -34,10 +35,11 @@ type ModalProps = ModalBaseProps & ( * @param props.contentClassName - optional class for a scrollable content region. * @param props.headless - render children directly in the card (no default * header/close/body chrome); mask, card, Escape, and aria-label remain. + * @param props.onKeyDownCapture - handle a nested dialog's keys before the document Escape listeners. * @returns null when closed; otherwise the overlay tree. */ export function Modal({ - open, onClose, title, closeLabel, description, children, footer, className, contentClassName, headless = false, + open, onClose, title, closeLabel, description, children, footer, className, contentClassName, onKeyDownCapture, headless = false, }: ModalProps) { useEffect(() => { if (!open) return @@ -51,7 +53,7 @@ export function Modal({ if (!open) return null return createPortal(( -
+
@@ -468,6 +469,8 @@ function CatalogDropdown({ const menuRef = useRef(null) const hoverOpenTimer = useRef | undefined>(undefined) const hoverCloseTimer = useRef | undefined>(undefined) + // A click-opened (pinned) menu ignores hover-out; only explicit dismissal closes it. + const pinnedRef = useRef(false) const currentEntry = currentSessionId === undefined ? undefined : catalog?.entries.find(entry => entry.id === currentSessionId) @@ -506,6 +509,7 @@ function CatalogDropdown({ setMenuPosition(catalogMenuPosition(trigger)) } else { + pinnedRef.current = false setOpen(false) setMenuPosition(undefined) setExpanded(new Set()) @@ -526,6 +530,7 @@ function CatalogDropdown({ const scheduleHoverClose = (): void => { cancelHoverOpen() cancelHoverClose() + if (pinnedRef.current) return hoverCloseTimer.current = setTimeout(() => { hoverCloseTimer.current = undefined changeOpen(false) @@ -602,6 +607,7 @@ function CatalogDropdown({ cancelHoverOpen() cancelHoverClose() if (!open) return + pinnedRef.current = false setOpen(false) setExpanded(new Set()) }, [visible, open]) @@ -640,11 +646,11 @@ function CatalogDropdown({ className={`${css.root} ${variant === 'switcher' ? css.switcherRoot : ''}`} ref={rootRef} onKeyDown={navigate} - onMouseEnter={scheduleHoverOpen} onMouseLeave={scheduleHoverClose} >
), document.body)}
@@ -724,7 +735,7 @@ export type SubagentCatalogActionProps = /** * Session-header catalog action for root sessions: the descendant count and - * its dropdown, ordered after the task list. Child sessions render nothing + * its dropdown at the start of the header actions band. Child sessions render nothing * here — their breadcrumb switcher in the lineage slot owns the same * navigation. * @param props - Session standard props plus the catalog actions and translator. @@ -769,7 +780,7 @@ export function SubagentHeaderLineage({ }) const shared = { useSessions, useSessionStatus, openChild, openChildAside, refreshProjection, t } // Root sessions carry no breadcrumb; their descendant count lives in the - // header actions band (SubagentCatalogAction), after the task list. + // header actions band (SubagentCatalogAction). if (parentId === undefined) return null return ( <> diff --git a/packages/client/ui-subagent/src/client/index.ts b/packages/client/ui-subagent/src/client/index.ts index fc70c93abf..e24490966d 100644 --- a/packages/client/ui-subagent/src/client/index.ts +++ b/packages/client/ui-subagent/src/client/index.ts @@ -84,9 +84,10 @@ export function apply(ctx: ClientContext): void { () => ctx.slots.register({ name: 'conversation.session.header.actions', id: 'subagent-catalog', - // After the task list: the preset label leads, running work follows, - // and delegation navigation closes the band. - order: 30, + // Leads the band, directly after the title crumbs: subagent lineage is + // the title's own continuation, ahead of Team navigation (-20) and the + // preset label (-10). + order: -30, locale: NS, inject: catalogActions, }, SubagentCatalogAction), diff --git a/packages/client/ui-subagent/src/client/locales.ts b/packages/client/ui-subagent/src/client/locales.ts index c17a2f3cb1..305e74e248 100644 --- a/packages/client/ui-subagent/src/client/locales.ts +++ b/packages/client/ui-subagent/src/client/locales.ts @@ -19,8 +19,8 @@ export const zh = { 'tokens.thousand': '{value}K', 'tokens.million': '{value}M', 'tokens.total': '{value} tok', - 'loading.label': '正在加载子代理…', - 'load.error': '无法加载子代理', + 'loading.label': '正在加载子智能体…', + 'load.error': '无法加载子智能体', 'retry': '重试', 'mode.oneShot': '一次性', 'mode.continuable': '可继续', @@ -29,18 +29,19 @@ export const zh = { 'activity.running': '正在运行', 'activity.completed': '已完成', 'activity.inactive': '当前未运行', - 'branch.collapse': '收起 {label} 的下级子代理', - 'branch.expand': '展开 {label} 的下级子代理', - 'count.total.one': '{count} 个子代理', - 'count.total.other': '{count} 个子代理', - 'count.running.one': '{count} 个子代理,正在运行', - 'count.running.other': '{count} 个子代理,正在运行', - 'switcher.aria': '切换子代理:{title}', - 'tree.aria': '子代理会话', - 'open.sidebar': '在侧边栏打开 {label}', + 'branch.collapse': '收起 {label} 的下级子智能体', + 'branch.expand': '展开 {label} 的下级子智能体', + 'count.total.one': '{count} 个子智能体', + 'count.total.other': '{count} 个子智能体', + 'count.running.one': '{count} 个子智能体,正在运行', + 'count.running.other': '{count} 个子智能体,正在运行', + 'switcher.aria': '切换子智能体:{title}', + 'tree.aria': '子智能体会话', + 'open.sidebar': '在侧边栏打开', + 'open.sidebar.aria': '在侧边栏打开 {label}', 'sidebar.chat': '聊天', - 'readonly.oneShot.title': '一次性子代理记录', - 'readonly.title': '此子代理暂时只读', + 'readonly.oneShot.title': '一次性子智能体记录', + 'readonly.title': '此子智能体暂时只读', 'readonly.oneShot.body': '一次性任务不支持后续消息,可在这里查看完整执行记录。', 'readonly.body': '父会话当前不在线,重新打开父会话后即可继续发送消息。', } as const @@ -79,7 +80,8 @@ export const en: Record = { 'count.running.other': '{count} subagents running', 'switcher.aria': 'Switch subagent: {title}', 'tree.aria': 'Subagent sessions', - 'open.sidebar': 'Open {label} in sidebar', + 'open.sidebar': 'Open in sidebar', + 'open.sidebar.aria': 'Open {label} in sidebar', 'sidebar.chat': 'Chat', 'readonly.oneShot.title': 'One-shot subagent record', 'readonly.title': 'This subagent is read-only for now', diff --git a/packages/client/ui-subagent/tests/browser-plugin.client.spec.ts b/packages/client/ui-subagent/tests/browser-plugin.client.spec.ts index 161ba8ec62..23aa61b015 100644 --- a/packages/client/ui-subagent/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-subagent/tests/browser-plugin.client.spec.ts @@ -134,10 +134,10 @@ describe('apply', () => { ]) // The root-session catalog seat registers in the actions band with the - // same business face, ordered after the task list. + // same business face, leading the band directly after the title crumbs. const actionEntry = ctx.slots.entries('conversation.session.header.actions') .find(entry => entry.options.id === 'subagent-catalog')! - expect(actionEntry.options.order).toBe(30) + expect(actionEntry.options.order).toBe(-30) expect(actionEntry.inject).toBe(catalogEntry.inject) const composerEntry = ctx.slots.entries('conversation.composer') diff --git a/packages/client/ui-subagent/tests/conversation-ui.client.spec.tsx b/packages/client/ui-subagent/tests/conversation-ui.client.spec.tsx index 381d7523fb..5e0401e33e 100644 --- a/packages/client/ui-subagent/tests/conversation-ui.client.spec.tsx +++ b/packages/client/ui-subagent/tests/conversation-ui.client.spec.tsx @@ -132,10 +132,8 @@ function summary(id: SessionId, updatedAt: number): SessionSummary { } function hoverCatalog(trigger: HTMLElement): void { - const root = trigger.parentElement - if (root === null) throw new Error('catalog trigger has no root') vi.useFakeTimers() - fireEvent.mouseEnter(root) + fireEvent.mouseEnter(trigger) act(() => { vi.advanceTimersByTime(150) }) } @@ -166,7 +164,7 @@ describe('SubagentHeaderLineage', () => { useSessions={select => select({ ...initial, ids: [] })} useSessionStatus={select => select(statuses)} />) - expect(screen.getByRole('button', { name: '1 个子代理,正在运行' })).toBeTruthy() + expect(screen.getByRole('button', { name: '1 个子智能体,正在运行' })).toBeTruthy() }) it('shows retained children while an idle projection awaits refresh', () => { @@ -212,13 +210,13 @@ describe('SubagentHeaderLineage', () => { } const view = render() - const trigger = screen.getByRole('button', { name: '1 个子代理,正在运行' }) + const trigger = screen.getByRole('button', { name: '1 个子智能体,正在运行' }) expect(trigger.querySelector('[data-state="ongoing"]')).not.toBeNull() view.rerender() - const inactiveTrigger = screen.getByRole('button', { name: '2 个子代理' }) + const inactiveTrigger = screen.getByRole('button', { name: '2 个子智能体' }) expect(inactiveTrigger.querySelector('[data-state="ongoing"]')).toBeNull() }) @@ -238,24 +236,25 @@ describe('SubagentHeaderLineage', () => { [forkChild]: { ...summary(forkChild, 1), parentId: fork, origin: 'subagent', running: true }, })} />) - const trigger = screen.getByRole('button', { name: '1 个子代理' }) + const trigger = screen.getByRole('button', { name: '1 个子智能体' }) expect(trigger.querySelector('[data-state="ongoing"]')).toBeNull() }) it('renders stable rows and catalog-addressed navigation', () => { const input = props(catalog()) render() - const trigger = screen.getByRole('button', { name: /1 个子代理,正在运行/ }) + const trigger = screen.getByRole('button', { name: /1 个子智能体,正在运行/ }) hoverCatalog(trigger) expect(input.refreshProjection).not.toHaveBeenCalled() expect(screen.getAllByRole('treeitem')).toHaveLength(2) expect(screen.getByText('正在扫描项目文件 · 可继续 · 正在运行')).toBeTruthy() expect(screen.getByText('一次性 · 当前未运行')).toBeTruthy() - expect(screen.getByRole('button', { name: '展开 worker 的下级子代理' })).toBeTruthy() - expect(screen.getByRole('button', { name: '展开 reviewer 的下级子代理' })).toBeTruthy() + expect(screen.getByRole('button', { name: '展开 worker 的下级子智能体' })).toBeTruthy() + expect(screen.getByRole('button', { name: '展开 reviewer 的下级子智能体' })).toBeTruthy() - const sidebarButton = screen.getByRole('button', { name: '在侧边栏打开 worker' }) + const sidebarButton = within(screen.getByRole('treeitem', { name: /worker/ })) + .getByRole('button', { name: '在侧边栏打开 worker' }) fireEvent.keyDown(sidebarButton, { key: 'Enter' }) fireEvent.click(sidebarButton) expect(input.openChildAside).toHaveBeenCalledWith({ @@ -292,14 +291,14 @@ describe('SubagentHeaderLineage', () => { render() - hoverCatalog(screen.getByRole('button', { name: /1 个子代理/ })) + hoverCatalog(screen.getByRole('button', { name: /1 个子智能体/ })) expect(screen.getByRole('treeitem', { name: /worker/ }).children).toHaveLength(1) }) it('aligns a known leaf with siblings whose child catalog is still unknown', () => { render() - hoverCatalog(screen.getByRole('button', { name: /1 个子代理,正在运行/ })) + hoverCatalog(screen.getByRole('button', { name: /1 个子智能体,正在运行/ })) expect(screen.getByRole('treeitem', { name: /worker/ }).children).toHaveLength(2) expect(screen.getByRole('button', { name: /展开 reviewer/ })).toBeTruthy() @@ -308,7 +307,7 @@ describe('SubagentHeaderLineage', () => { it('supports trigger/menu keyboard traversal, Escape focus restore, and outside close', async () => { const input = props(catalog()) render() - const trigger = screen.getByRole('button', { name: /1 个子代理,正在运行/ }) + const trigger = screen.getByRole('button', { name: /1 个子智能体,正在运行/ }) fireEvent.keyDown(trigger, { key: 'ArrowDown' }) await Promise.resolve() expect(document.activeElement).toBe(screen.getByRole('treeitem', { name: /worker/ })) @@ -331,36 +330,33 @@ describe('SubagentHeaderLineage', () => { expect(screen.queryByRole('tree')).toBeNull() }) - it('opens only on hover and preserves the portaled-menu crossing grace', async () => { + it('opens on hover and preserves the portaled-menu crossing grace', async () => { vi.useFakeTimers() const advance = async (duration: number): Promise => { await act(async () => { await vi.advanceTimersByTimeAsync(duration) }) } const view = render() - const trigger = screen.getByRole('button', { name: /1 个子代理,正在运行/ }) + const trigger = screen.getByRole('button', { name: /1 个子智能体,正在运行/ }) const triggerRect = vi.spyOn(trigger, 'getBoundingClientRect') .mockReturnValue({ bottom: 40, left: 50 } as DOMRect) - fireEvent.click(trigger) - expect(screen.queryByRole('tree')).toBeNull() - - fireEvent.mouseEnter(trigger.parentElement!) + fireEvent.mouseEnter(trigger) await advance(149) expect(screen.queryByRole('tree')).toBeNull() await advance(1) - const tree = screen.getByRole('tree') - expect(tree.style.top).toBe('45px') - expect(tree.style.left).toBe('50px') + const menu = screen.getByRole('tree').parentElement! + expect(menu.style.top).toBe('45px') + expect(menu.style.left).toBe('50px') triggerRect.mockReturnValue({ bottom: 60, left: 70 } as DOMRect) fireEvent.resize(window) - expect(tree.style.top).toBe('65px') - expect(tree.style.left).toBe('70px') + expect(menu.style.top).toBe('65px') + expect(menu.style.left).toBe('70px') fireEvent.mouseLeave(trigger.parentElement!) - fireEvent.mouseEnter(tree) + fireEvent.mouseEnter(menu) await advance(120) expect(screen.getByRole('tree')).toBeTruthy() - fireEvent.mouseLeave(tree) + fireEvent.mouseLeave(menu) await advance(119) expect(screen.getByRole('tree')).toBeTruthy() await advance(1) @@ -372,34 +368,98 @@ describe('SubagentHeaderLineage', () => { await advance(120) }) + it('pins a click-opened catalog through hover-out until outside pointer or Escape', async () => { + vi.useFakeTimers() + const advance = async (duration: number): Promise => { + await act(async () => { await vi.advanceTimersByTimeAsync(duration) }) + } + render() + const trigger = screen.getByRole('button', { name: /1 个子智能体,正在运行/ }) + const root = trigger.parentElement! + + fireEvent.click(trigger) + const menu = screen.getByRole('tree') + fireEvent.mouseEnter(trigger) + fireEvent.mouseLeave(root) + await advance(300) + expect(screen.getByRole('tree')).toBe(menu) + fireEvent.mouseEnter(menu.parentElement!) + fireEvent.mouseLeave(menu.parentElement!) + await advance(300) + expect(screen.getByRole('tree')).toBe(menu) + fireEvent.pointerDown(document.body) + expect(screen.queryByRole('tree')).toBeNull() + + fireEvent.mouseEnter(trigger) + await advance(150) + const hovered = screen.getByRole('tree') + fireEvent.click(trigger) + fireEvent.mouseLeave(root) + await advance(300) + expect(screen.getByRole('tree')).toBe(hovered) + fireEvent.keyDown(hovered, { key: 'Escape' }) + expect(screen.queryByRole('tree')).toBeNull() + fireEvent.mouseEnter(trigger) + fireEvent.mouseLeave(root) + await advance(300) + expect(screen.queryByRole('tree')).toBeNull() + }) + + it('keeps the hover catalog open when the pointer returns directly to its trigger', async () => { + vi.useFakeTimers() + render() + const trigger = screen.getByRole('button', { name: /1 个子智能体,正在运行/ }) + fireEvent.mouseOver(trigger, { relatedTarget: document.body }) + await act(async () => { await vi.advanceTimersByTimeAsync(150) }) + const menu = screen.getByRole('tree').parentElement! + fireEvent.mouseOut(trigger, { relatedTarget: menu }) + fireEvent.mouseOver(menu, { relatedTarget: trigger }) + fireEvent.mouseOut(menu, { relatedTarget: trigger }) + fireEvent.mouseOver(trigger, { relatedTarget: menu }) + await act(async () => { await vi.advanceTimersByTimeAsync(150) }) + expect(screen.getByRole('tree').parentElement).toBe(menu) + }) + + it('cancels pending hover dismissal when the catalog trigger is activated from the keyboard', async () => { + vi.useFakeTimers() + render() + const trigger = screen.getByRole('button', { name: /1 个子智能体,正在运行/ }) + fireEvent.mouseOver(trigger, { relatedTarget: document.body }) + await act(async () => { await vi.advanceTimersByTimeAsync(150) }) + fireEvent.mouseOut(trigger, { relatedTarget: document.body }) + fireEvent.click(trigger, { detail: 0 }) + await act(async () => { await vi.advanceTimersByTimeAsync(120) }) + expect(screen.getByRole('tree')).toBeTruthy() + }) + it('repositions an open catalog after viewport resize and document scroll', () => { const view = render() - const trigger = screen.getByRole('button', { name: /1 个子代理,正在运行/ }) + const trigger = screen.getByRole('button', { name: /1 个子智能体,正在运行/ }) const bounds = vi.spyOn(trigger, 'getBoundingClientRect') bounds.mockReturnValue({ bottom: 20, left: 30 } as DOMRect) hoverCatalog(trigger) - const tree = screen.getByRole('tree') - expect(tree.style.top).toBe('25px') - expect(tree.style.left).toBe('30px') + const menu = screen.getByRole('tree').parentElement! + expect(menu.style.top).toBe('25px') + expect(menu.style.left).toBe('30px') bounds.mockReturnValue({ bottom: 70, left: 80 } as DOMRect) act(() => { window.dispatchEvent(new Event('resize')) }) - expect(tree.style.top).toBe('75px') - expect(tree.style.left).toBe('80px') + expect(menu.style.top).toBe('75px') + expect(menu.style.left).toBe('80px') bounds.mockReturnValue({ bottom: 90, left: 100 } as DOMRect) act(() => { document.dispatchEvent(new Event('scroll')) }) - expect(tree.style.top).toBe('95px') - expect(tree.style.left).toBe('100px') + expect(menu.style.top).toBe('95px') + expect(menu.style.left).toBe('100px') view.unmount() }) it('cancels a pending hover when the trigger becomes hidden', async () => { vi.useFakeTimers() const view = render() - const trigger = screen.getByRole('button', { name: /1 个子代理,正在运行/ }) + const trigger = screen.getByRole('button', { name: /1 个子智能体,正在运行/ }) - fireEvent.mouseEnter(trigger.parentElement!) + fireEvent.mouseEnter(trigger) await vi.advanceTimersByTimeAsync(149) view.rerender() expect(screen.queryByRole('button')).toBeNull() @@ -424,7 +484,7 @@ describe('SubagentHeaderLineage', () => { ], })) render() - const trigger = screen.getByRole('button', { name: /1 个子代理,正在运行/ }) + const trigger = screen.getByRole('button', { name: /1 个子智能体,正在运行/ }) fireEvent.keyDown(trigger, { key: 'Tab' }) expect(screen.queryByRole('tree')).toBeNull() hoverCatalog(trigger) @@ -445,7 +505,7 @@ describe('SubagentHeaderLineage', () => { it('keeps unknown catalog children clickable by their durable parent address', () => { const input = props(catalog({ entries: [{ id: CHILD, mode: 'unknown', activity: 'inactive' }] })) render() - hoverCatalog(screen.getByRole('button', { name: /子代理/ })) + hoverCatalog(screen.getByRole('button', { name: /子智能体/ })) const row = screen.getByRole('treeitem', { name: new RegExp(CHILD) }) expect(row.textContent).toContain('模式未知') fireEvent.keyDown(row, { key: 'Enter' }) @@ -525,8 +585,8 @@ describe('SubagentHeaderLineage', () => { })) as Record const input = props(catalog({ entries }), {}, summaries) render() - const trigger = screen.getByRole('button', { name: '1 个子代理,正在运行' }) - expect(within(trigger).getByText('9 个子代理')).toBeTruthy() + const trigger = screen.getByRole('button', { name: '1 个子智能体,正在运行' }) + expect(within(trigger).getByText('9 个子智能体')).toBeTruthy() hoverCatalog(trigger) const runningRow = screen.getByRole('treeitem', { name: /running.*4\.6K tok · 1分10秒/ }) @@ -579,16 +639,16 @@ describe('SubagentHeaderLineage', () => { }, }) render() - hoverCatalog(screen.getByRole('button', { name: '1 个子代理' })) + hoverCatalog(screen.getByRole('button', { name: '1 个子智能体' })) const beforeExpand = vi.getTimerCount() - fireEvent.click(screen.getByRole('button', { name: '展开 worker 的下级子代理' })) + fireEvent.click(screen.getByRole('button', { name: '展开 worker 的下级子智能体' })) expect(screen.getByRole('treeitem', { name: /indexer.*5秒/ })).toBeTruthy() await act(async () => { await vi.advanceTimersByTimeAsync(1_000) }) expect(screen.getByRole('treeitem', { name: /indexer.*6秒/ })).toBeTruthy() - fireEvent.click(screen.getByRole('button', { name: '收起 worker 的下级子代理' })) + fireEvent.click(screen.getByRole('button', { name: '收起 worker 的下级子智能体' })) expect(vi.getTimerCount()).toBe(beforeExpand) await act(async () => { await vi.advanceTimersByTimeAsync(1_000) }) - fireEvent.click(screen.getByRole('button', { name: '展开 worker 的下级子代理' })) + fireEvent.click(screen.getByRole('button', { name: '展开 worker 的下级子智能体' })) expect(screen.getByRole('treeitem', { name: /indexer.*7秒/ })).toBeTruthy() }) @@ -607,9 +667,9 @@ describe('SubagentHeaderLineage', () => { [GRANDCHILD]: grandchildCatalog, }) render() - hoverCatalog(screen.getByRole('button', { name: /1 个子代理,正在运行/ })) + hoverCatalog(screen.getByRole('button', { name: /1 个子智能体,正在运行/ })) - fireEvent.click(screen.getByRole('button', { name: '展开 worker 的下级子代理' })) + fireEvent.click(screen.getByRole('button', { name: '展开 worker 的下级子智能体' })) expect(input.refreshProjection).toHaveBeenCalledWith(CHILD) const nested = screen.getByRole('treeitem', { name: /indexer/ }) expect(nested.getAttribute('aria-level')).toBe('2') @@ -635,19 +695,19 @@ describe('SubagentHeaderLineage', () => { } const deferred = props(catalog(), {}, summaries) const view = render() - hoverCatalog(screen.getByRole('button', { name: /1 个子代理,正在运行/ })) - fireEvent.click(screen.getByRole('button', { name: '展开 worker 的下级子代理' })) + hoverCatalog(screen.getByRole('button', { name: /1 个子智能体,正在运行/ })) + fireEvent.click(screen.getByRole('button', { name: '展开 worker 的下级子智能体' })) expect(deferred.refreshProjection).toHaveBeenCalledWith(CHILD) expect(screen.getByRole('group').getAttribute('aria-busy')).toBe('true') - expect(screen.getByText('正在加载子代理…')).toBeTruthy() - expect(screen.queryByRole('treeitem', { name: '正在加载子代理' })).toBeNull() + expect(screen.getByText('正在加载子智能体…')).toBeTruthy() + expect(screen.queryByRole('treeitem', { name: '正在加载子智能体' })).toBeNull() const loading = props(catalog(), { [CHILD]: catalog({ entries: [], state: 'loading' }), }, summaries) view.rerender() - expect(screen.getByText('正在加载子代理…')).toBeTruthy() + expect(screen.getByText('正在加载子智能体…')).toBeTruthy() const ready = props(catalog(), { [CHILD]: catalog({ @@ -667,7 +727,7 @@ describe('SubagentHeaderLineage', () => { expect(screen.getByRole('group').getAttribute('aria-busy')).toBeNull() expect(screen.getByRole('treeitem', { name: /indexer/ }).querySelector('[data-state="idle"]')).not.toBeNull() expect(screen.getByRole('treeitem', { name: /critic/ }).querySelector('[data-state="ongoing"]')).not.toBeNull() - expect(screen.queryByText('正在加载子代理…')).toBeNull() + expect(screen.queryByText('正在加载子智能体…')).toBeNull() }) it('uses ArrowRight and ArrowLeft for branch disclosure', async () => { @@ -680,7 +740,7 @@ describe('SubagentHeaderLineage', () => { }), }) render() - const trigger = screen.getByRole('button', { name: /1 个子代理,正在运行/ }) + const trigger = screen.getByRole('button', { name: /1 个子智能体,正在运行/ }) fireEvent.keyDown(trigger, { key: 'ArrowDown' }) await Promise.resolve() const worker = screen.getByRole('treeitem', { name: /worker/ }) @@ -700,10 +760,10 @@ describe('SubagentHeaderLineage', () => { }), }) render() - hoverCatalog(screen.getByRole('button', { name: /1 个子代理,正在运行/ })) - fireEvent.click(screen.getByRole('button', { name: '展开 worker 的下级子代理' })) - fireEvent.click(screen.getByRole('button', { name: '展开 indexer 的下级子代理' })) - fireEvent.click(screen.getByRole('button', { name: '收起 worker 的下级子代理' })) + hoverCatalog(screen.getByRole('button', { name: /1 个子智能体,正在运行/ })) + fireEvent.click(screen.getByRole('button', { name: '展开 worker 的下级子智能体' })) + fireEvent.click(screen.getByRole('button', { name: '展开 indexer 的下级子智能体' })) + fireEvent.click(screen.getByRole('button', { name: '收起 worker 的下级子智能体' })) expect(screen.queryByRole('treeitem', { name: /indexer/ })).toBeNull() }) @@ -724,7 +784,7 @@ describe('SubagentHeaderLineage', () => { error: new RemoteError('gateway/internal', 'index down', {}), })) render() - hoverCatalog(screen.getByRole('button', { name: /0 个子代理/ })) + hoverCatalog(screen.getByRole('button', { name: /0 个子智能体/ })) expect(screen.getByText('index down')).toBeTruthy() fireEvent.click(screen.getByRole('button', { name: /重试/ })) expect(failed.refreshProjection).toHaveBeenCalledWith(PARENT) @@ -758,9 +818,9 @@ describe('SubagentHeaderLineage', () => { const failed = props(catalog({ entries: [], state: 'error', error: null })) render() - const trigger = screen.getByRole('button', { name: /0 个子代理/ }) + const trigger = screen.getByRole('button', { name: /0 个子智能体/ }) hoverCatalog(trigger) - expect(screen.getByText('无法加载子代理')).toBeTruthy() + expect(screen.getByText('无法加载子智能体')).toBeTruthy() fireEvent.keyDown(trigger, { key: 'ArrowDown' }) await Promise.resolve() expect(screen.getByRole('tree')).toBeTruthy() @@ -770,7 +830,7 @@ describe('SubagentHeaderLineage', () => { it('navigates from outside the tree and tolerates a deferred focus after unmount', async () => { const input = props(catalog()) const view = render() - const trigger = screen.getByRole('button', { name: /1 个子代理,正在运行/ }) + const trigger = screen.getByRole('button', { name: /1 个子智能体,正在运行/ }) hoverCatalog(trigger) fireEvent.keyDown(screen.getByRole('tree'), { key: 'ArrowUp' }) expect(document.activeElement).toBe(screen.getByRole('treeitem', { name: /reviewer/ })) @@ -789,8 +849,8 @@ describe('SubagentHeaderLineage', () => { }), }) const view = render() - hoverCatalog(screen.getByRole('button', { name: /1 个子代理,正在运行/ })) - fireEvent.click(screen.getByRole('button', { name: '展开 worker 的下级子代理' })) + hoverCatalog(screen.getByRole('button', { name: /1 个子智能体,正在运行/ })) + fireEvent.click(screen.getByRole('button', { name: '展开 worker 的下级子智能体' })) const empty = props(catalog({ entries: [] })) view.rerender() @@ -801,7 +861,7 @@ describe('SubagentHeaderLineage', () => { const view = render() expect(screen.queryByText('/')).toBeNull() - expect(screen.getByRole('button', { name: /1 个子代理,正在运行/ })).toBeTruthy() + expect(screen.getByRole('button', { name: /1 个子智能体,正在运行/ })).toBeTruthy() view.rerender() expect(screen.queryByRole('button')).toBeNull() @@ -824,7 +884,7 @@ describe('SubagentHeaderLineage', () => { } render() - const switcher = screen.getByRole('button', { name: '切换子代理:worker' }) + const switcher = screen.getByRole('button', { name: '切换子智能体:worker' }) expect(switcher.className).toContain('switcherTrigger') expect(within(switcher).getByText('worker')).toBeTruthy() expect(switcher.querySelector('svg')).not.toBeNull() @@ -857,7 +917,7 @@ describe('SubagentHeaderLineage', () => { } render() - expect(screen.queryByRole('button', { name: '切换子代理:worker' })).toBeNull() + expect(screen.queryByRole('button', { name: '切换子智能体:worker' })).toBeNull() }) it('falls back to the catalog session id when the selected row has no label', () => { @@ -876,7 +936,7 @@ describe('SubagentHeaderLineage', () => { } render() - expect(screen.getByRole('button', { name: `切换子代理:${CHILD}` })).toBeTruthy() + expect(screen.getByRole('button', { name: `切换子智能体:${CHILD}` })).toBeTruthy() }) it('keeps an ancestor switcher muted and omits its descendant count', () => { @@ -900,9 +960,9 @@ describe('SubagentHeaderLineage', () => { } render() - const switcher = screen.getByRole('button', { name: '切换子代理:worker' }) + const switcher = screen.getByRole('button', { name: '切换子智能体:worker' }) expect(switcher.className).toContain('ancestorSwitcherTrigger') - expect(screen.queryByRole('button', { name: /1 个子代理/ })).toBeNull() + expect(screen.queryByRole('button', { name: /1 个子智能体/ })).toBeNull() vi.useFakeTimers() fireEvent.mouseEnter(switcher.parentElement!) fireEvent.click(switcher) @@ -934,7 +994,7 @@ describe('SubagentHeaderLineage', () => { } render() - expect(screen.getByRole('button', { name: '切换子代理:正在扫描项目文件' })).toBeTruthy() + expect(screen.getByRole('button', { name: '切换子智能体:正在扫描项目文件' })).toBeTruthy() expect(input.refreshProjection).not.toHaveBeenCalled() }) @@ -959,7 +1019,7 @@ describe('SubagentHeaderLineage', () => { } render() - hoverCatalog(screen.getByRole('button', { name: '切换子代理:indexer' })) + hoverCatalog(screen.getByRole('button', { name: '切换子智能体:indexer' })) expect(input.refreshProjection).not.toHaveBeenCalled() const current = screen.getByRole('treeitem', { name: /indexer/ }) diff --git a/packages/client/ui-theme/README.i18n.yaml b/packages/client/ui-theme/README.i18n.yaml index 4f43025e5b..02b00f7ee4 100644 --- a/packages/client/ui-theme/README.i18n.yaml +++ b/packages/client/ui-theme/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-theme/README.md -README.md: b0dbeacbac18972e17cf953ee99725c69db5b3e7 -README.zh.md: 830760c66c678cbabbcb5472c497cc9dfb475d70 +README.md: ad5cec001c3cac9f3235a86f12fcb0b08e0d56ec +README.zh.md: d71fa6231b227c486319b23d232d88c3faf2b449 diff --git a/packages/client/ui-theme/README.md b/packages/client/ui-theme/README.md index b0dbeacbac..ad5cec001c 100644 --- a/packages/client/ui-theme/README.md +++ b/packages/client/ui-theme/README.md @@ -51,7 +51,7 @@ The service owns theme and font-size state and publishes snapshots. The ui-layou ### Stylesheets -`src/styles/` holds seven sheets imported in order by ui-theme's dynamic client entry: `base.css`, `corner-shape.css`, `design-platform.css`, `focus.css`, `scrollbar.css`, `gradient-shadow-text.css`, and `shiki.css`. The client bundle compiles and injects them as plugin-owned global styles, so unload and HMR remove them with ui-theme. `scrollbar.css` consumes the `--dsw-alias-scrollbar-*` tokens and must follow `design-platform.css`, which declares them. Status marks use their own semantic state tokens. `design-platform.css` also owns the code-diff fill aliases and their static alpha palette entries; `shiki.css` owns syntax colors. +`src/styles/` holds seven sheets imported in order by ui-theme's dynamic client entry: `base.css`, `corner-shape.css`, `design-platform.css`, `focus.css`, `scrollbar.css`, `gradient-shadow-text.css`, and `shiki.css`. The client bundle compiles and injects them as plugin-owned global styles, so unload and HMR remove them with ui-theme. `scrollbar.css` consumes the `--dsw-alias-scrollbar-*` tokens and must follow `design-platform.css`, which declares them. Status marks use their own semantic state tokens. `design-platform.css` also owns the code-diff fill aliases and their static alpha palette entries, plus the `--dsw-alias-file-diff-*` code, gutter, and marker palette for file comparisons; `shiki.css` owns syntax colors. [`focus.css`](src/styles/focus.css) provides a `:focus-visible` outline-colour fallback through `var(--dsw-focus-ring-color, var(--dsw-alias-brand-primary))` without changing outline geometry. Component outlines and focus-ring shadows use the same colour expression, including rings on descendants and pseudo-elements. `--dsw-focus-ring-width` (2px) is the standard width; dense tables and toolbars may keep 1px, and offsets remain component-owned. diff --git a/packages/client/ui-theme/README.zh.md b/packages/client/ui-theme/README.zh.md index 830760c66c..d71fa6231b 100644 --- a/packages/client/ui-theme/README.zh.md +++ b/packages/client/ui-theme/README.zh.md @@ -51,7 +51,7 @@ kind: "package-reference" ### 样式表 -`src/styles/` 下有七张样式表,由 ui-theme 的动态客户端 entry 依次导入:`base.css`、`corner-shape.css`、`design-platform.css`、`focus.css`、`scrollbar.css`、`gradient-shadow-text.css` 与 `shiki.css`。客户端 bundle 将其编译并注入为插件持有的全局样式,因此卸载与 HMR(热模块替换)会随 ui-theme 一同移除。`scrollbar.css` 消费 `--dsw-alias-scrollbar-*` token,必须排在声明这些 token 的 `design-platform.css` 之后。状态标记使用各自的语义状态 token。`design-platform.css` 还负责代码差异底色的别名及其静态透明度色阶;`shiki.css` 负责语法颜色。 +`src/styles/` 下有七张样式表,由 ui-theme 的动态客户端 entry 依次导入:`base.css`、`corner-shape.css`、`design-platform.css`、`focus.css`、`scrollbar.css`、`gradient-shadow-text.css` 与 `shiki.css`。客户端 bundle 将其编译并注入为插件持有的全局样式,因此卸载与 HMR(热模块替换)会随 ui-theme 一同移除。`scrollbar.css` 消费 `--dsw-alias-scrollbar-*` token,必须排在声明这些 token 的 `design-platform.css` 之后。状态标记使用各自的语义状态 token。`design-platform.css` 还负责代码差异底色的别名及其静态透明度色阶,以及文件对比所用的 `--dsw-alias-file-diff-*` 代码区、行号区和标记配色;`shiki.css` 负责语法颜色。 [`focus.css`](src/styles/focus.css) 通过 `var(--dsw-focus-ring-color, var(--dsw-alias-brand-primary))` 提供 `:focus-visible` 轮廓颜色兜底,不改变轮廓几何。组件轮廓与焦点环阴影使用同一颜色表达式,包括后代和伪元素上的环。`--dsw-focus-ring-width`(2px)是标准宽度;密集表格与工具栏可以保留 1px,offset 仍由组件决定。 diff --git a/packages/client/ui-theme/package.json b/packages/client/ui-theme/package.json index dc0a7b10fe..8fdbec3862 100644 --- a/packages/client/ui-theme/package.json +++ b/packages/client/ui-theme/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-theme", "description": "Theme plugin: Host bootstrap for the pre-plugin palette; DOM-free ThemeRuntime for light/dark/system state; --dsw-* token styles and Appearance settings row", - "version": "0.1.7-alpha.1", + "version": "0.1.7-alpha.2", "publishConfig": { "access": "public" }, @@ -41,24 +41,24 @@ }, "license": "MIT", "peerDependencies": { - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:~" }, "devDependencies": { - "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-api-remotes": "workspace:^", - "@deepseek-ai/dsh-client-connection": "workspace:^", - "@deepseek-ai/dsh-client-locale": "workspace:^", - "@deepseek-ai/dsh-client-store": "workspace:^", - "@deepseek-ai/dsh-client-test-runtime": "workspace:^", - "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", - "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", - "@deepseek-ai/dsh-client-ui-settings": "workspace:^", - "@deepseek-ai/dsh-client-ui-slots": "workspace:^", - "@deepseek-ai/dsh-host-webserver": "workspace:^", + "@deepseek-ai/cordis": "workspace:~", + "@deepseek-ai/dsh-api-remotes": "workspace:*", + "@deepseek-ai/dsh-client-connection": "workspace:*", + "@deepseek-ai/dsh-client-locale": "workspace:*", + "@deepseek-ai/dsh-client-store": "workspace:*", + "@deepseek-ai/dsh-client-test-runtime": "workspace:*", + "@deepseek-ai/dsh-client-ui-primitives": "workspace:*", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:*", + "@deepseek-ai/dsh-client-ui-settings": "workspace:*", + "@deepseek-ai/dsh-client-ui-slots": "workspace:*", + "@deepseek-ai/dsh-host-webserver": "workspace:*", "@types/react": "~18.3.1", "react": "^18.2.0", "clsx": "^2.0.0", - "@deepseek-ai/dsh-settings": "workspace:^" + "@deepseek-ai/dsh-settings": "workspace:*" }, "files": [ "lib/index.js", @@ -71,6 +71,6 @@ "watch": "tsdown --watch" }, "dependencies": { - "@deepseek-ai/schemastery": "workspace:^" + "@deepseek-ai/schemastery": "workspace:~" } } diff --git a/packages/client/ui-theme/src/styles/design-platform.css b/packages/client/ui-theme/src/styles/design-platform.css index 929b24be6c..8f447398fc 100644 --- a/packages/client/ui-theme/src/styles/design-platform.css +++ b/packages/client/ui-theme/src/styles/design-platform.css @@ -236,6 +236,12 @@ body { --dsw-alias-state-business-tertiary: var(--dsw-static-deepseek-100); --dsw-alias-code-diff-added: var(--dsw-static-green-500-a08); --dsw-alias-code-diff-deleted: var(--dsw-static-red-600-a08); + --dsw-alias-file-diff-added-bg: rgb(230, 244, 231); + --dsw-alias-file-diff-added-gutter: rgb(237, 247, 237); + --dsw-alias-file-diff-added-marker: rgb(1, 162, 65); + --dsw-alias-file-diff-deleted-bg: rgb(252, 230, 226); + --dsw-alias-file-diff-deleted-gutter: rgb(253, 236, 233); + --dsw-alias-file-diff-deleted-marker: rgb(186, 39, 35); --dsw-alias-state-error-primary: var(--dsw-static-red-600); --dsw-alias-state-error-secondary: var(--dsw-static-red-400); --dsw-alias-state-idle-primary: var(--dsw-static-neutral-300); @@ -336,6 +342,12 @@ body[data-ds-dark-theme] { --dsw-alias-state-business-tertiary: var(--dsw-static-deepseek-800); --dsw-alias-code-diff-added: var(--dsw-static-green-500-a12); --dsw-alias-code-diff-deleted: var(--dsw-static-red-400-a12); + --dsw-alias-file-diff-added-bg: rgb(31, 49, 36); + --dsw-alias-file-diff-added-gutter: rgb(19, 32, 22); + --dsw-alias-file-diff-added-marker: rgb(65, 201, 119); + --dsw-alias-file-diff-deleted-bg: rgb(60, 31, 27); + --dsw-alias-file-diff-deleted-gutter: rgb(40, 19, 14); + --dsw-alias-file-diff-deleted-marker: rgb(250, 66, 62); --dsw-alias-state-error-primary: var(--dsw-static-red-400); --dsw-alias-state-error-secondary: var(--dsw-static-red-400); --dsw-alias-state-idle-primary: var(--dsw-static-neutral-600); diff --git a/packages/client/ui-tool/README.i18n.yaml b/packages/client/ui-tool/README.i18n.yaml index b5dd281e0e..af3b5ca741 100644 --- a/packages/client/ui-tool/README.i18n.yaml +++ b/packages/client/ui-tool/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-tool/README.md -README.md: 622dbd89c93f8a21bece72f2267613a4fa81b4d6 -README.zh.md: c3fec14cb7a5d2e2448743204b59dcac73380849 +README.md: a00ed8e8c78dce29144a3ff89d2eb671b5f209fb +README.zh.md: c4a6f6183be082d8c21877b0c1c7052652bc7e2e diff --git a/packages/client/ui-tool/README.md b/packages/client/ui-tool/README.md index 622dbd89c9..a00ed8e8c7 100644 --- a/packages/client/ui-tool/README.md +++ b/packages/client/ui-tool/README.md @@ -29,6 +29,8 @@ Tool calls appear in the conversation as cards: a root call tree with its nested Shared Tool rows and Bash rows retain error and warning colors for failed and stopped summaries, including on hover. Hover darkens only summaries without those states. +Before dispatch, a named model call appears as one non-expandable row with its tool-owned icon and title. A generic row shows `Tool call · `. Preparation provides no argument body, file link, result, or interaction requiring parameters. `tool/call` enables the existing call presentation; completing an argument block alone does not start execution. + ### Registering a business tool view An owning business package registers its wire Tool name into `tool.call.toolview`: @@ -41,10 +43,12 @@ ctx.slots.inject('tool.call.toolview', () => }, BusinessToolRow)) ``` -The owner payload is `ToolCallOwnerProps`: `callId`, `toolName`, the frozen `block`, optional `cwd` and `home`, the session-authorized `loadImage` loader (for a view whose result carries durable images), and plain `openFile`/`inspect` callbacks. A PTC dispatch block retains its event's `parentCallId`; a root Session call has no such field, so descendants route through the same keyed dispatch — a registered view such as `read_image` renders its card there, and unregistered descendants keep the generic flattened form. Path summaries relativize to the Session cwd first, then replace a leftover POSIX Host home with `~`; `filePath` and Host open keep the authored filesystem path. The registration receives the normal Session slot runtime share but no React node or Runtime service. +The owner payload is `ToolCallOwnerProps`: `callId`, `toolName`, the `phase` discriminant and its frozen stage-specific `block`, optional `cwd` and `home`, the session-authorized `loadImage` loader (for a view whose result carries durable images), and plain `openFile`/`inspect` callbacks. A PTC dispatch block retains its event's `parentCallId`; a root Session call has no such field, so descendants route through the same keyed dispatch — a registered view such as `read_image` renders its card there, and unregistered descendants keep the generic flattened form. Path summaries relativize to the Session cwd first, then replace a leftover POSIX Host home with `~`; `filePath` and Host open keep the authored filesystem path. The registration receives the normal Session slot runtime share but no React node or Runtime service. ### Built-in views +Every registered view receives the explicit `preparing`, `start`, and `result` props declared in [the Tool slot types](src/client/contract/slots.ts). Shared rows use the same `ToolRow` in all stages. Their row model selects the title and combines any generic tool-name prefix with the available argument summary independently of lifecycle state. Tool-owned titles omit the English name. Preparation has no argument summary, and the shared argument parser returns no call without parsing JSON. Custom renderers such as Bash, Skill, and Cordis handle preparation separately; their argument-dependent components accept `StartedToolCallViewProps`. + This package owns the generic fallback and the built-in shell/pwsh, read, read_image, write/edit, running `str_replace_editor` `create`/`str_replace`, grep/glob, web, todo, question, and PTC dispatch presentations. Structured cards derive directly from first-party raw event fields; Host `presentCall` and `presentResult` values never enter the Client. Running and settled foreground standard `bash`/`pwsh` and `terminal_send` calls use terminal cards at the root and in PTC dispatch children, subject to the same argument, result, and error checks. Persistent `bash`/`pwsh` calls use terminal cards only while running. Shell output ending in a recognized spill-policy notice uses expandable generic output in shell rows and generic output in Details; a displaced or omitted exit marker cannot establish success. Settled persistent-shell results stay generic because reset and partial-output diagnostics do not always describe one process exit status; root persistent results are expandable, while background acknowledgements remain collapsed. A native or PTC dispatch failure carrying `AUTO_REVIEW_DENIED` shows the Auto review verdict in its collapsed row and one normalized not-executed reason when expanded; a missing or whitespace-only reason uses localized fallback text. A successful question row pairs call questions with result answers by their stable ids and shows readable question/answer lines when expanded. A cancelled or interrupted row shows its verdict and original questions without inventing answers. Unsupported, malformed, or ambiguous inputs fall back to flattened Tool input/result text. `ui-skill` demonstrates a business-owned registration for `skill`. ----- @@ -59,7 +63,7 @@ The package realizes one dispatch rule: atomic Tool views are keyed by wire Tool ### Rendering contract -`ToolCallTree` receives one root `ToolCallBlock` that already contains recursive `subCalls`, the session `cwd`, and the owner's callbacks for opening files and inspecting calls. It recursively walks the standard call blocks and sends the root and children at every depth through the same atomic dispatch path, without subscribing to a separate parent-to-children map. Each root and child wrapper preserves the `data-chat-anchor-key="call:"` and `data-chat-call-id` DOM contract used for paging and selection. +`ToolCallTree` receives one Tool node, Session `cwd`, and navigation callbacks. Each branch receives a stable block and memoizes its explicit phase props, so changing one subcall leaves unchanged sibling branches unrendered. It dispatches each tool by name through `tool.call.toolview`. Dispatched roots retain their recursive `subCalls`; preparation has no children. Each root and child wrapper preserves the `data-chat-anchor-key="call:"` and `data-chat-call-id` DOM contract used for paging and selection. The Tool node keeps the same callId across all three stages. Tool owner props forward Chat's stable `useDisclosure` Hook through root and nested calls. Rows invoke it where they own their expanded bodies; intermediate renderers do not subscribe. Each invocation has independent open state that resets when the enclosing Turn collapses, without replacing React identity. Presentation-mode switches preserve it. @@ -98,7 +102,7 @@ These pages cover the conversation host, the view slots, and the card models. ## Model Experience -None, as the package is a browser-side tool presentation layer that renders logged calls without changing model context. +None, as the package renders streamed tool identities and logged calls without changing model context. #### KV Cache effect diff --git a/packages/client/ui-tool/README.zh.md b/packages/client/ui-tool/README.zh.md index c3fec14cb7..c4a6f6183b 100644 --- a/packages/client/ui-tool/README.zh.md +++ b/packages/client/ui-tool/README.zh.md @@ -29,6 +29,8 @@ kind: "package-reference" 共享工具行和 Bash 行的失败、停止摘要在悬停时仍保留错误色和警告色;只有不处于这两种状态的摘要会在悬停时加深。 +派发前,模型已给出名称的调用显示为不可展开的一行,使用工具自己的图标与标题。通用行显示为`工具调用 · <工具名>`。准备阶段不提供参数正文、文件链接、结果或依赖参数的交互。`tool/call` 才启用既有调用展示;参数块结束本身不代表开始执行。 + ### 注册业务工具视图 拥有该视图的业务包将其 wire 工具名称注册进 `tool.call.toolview`: @@ -41,10 +43,12 @@ ctx.slots.inject('tool.call.toolview', () => }, BusinessToolRow)) ``` -owner 载荷为 `ToolCallOwnerProps`:`callId`、`toolName`、冻结的 `block`、可选 `cwd` 与 `home`、会话授权的 `loadImage` loader(供结果携带持久图像的视图使用),以及普通的 `openFile`/`inspect` 回调。PTC dispatch 块保留事件的 `parentCallId`;根会话调用没有该字段,因此后代调用都走同一条按 key 分发路径:已注册视图的调用(如 `read_image`)也会在嵌套处渲染对应卡片,未注册的后代调用则保持通用压平形式。路径摘要先相对会话 cwd 缩短,再把剩余的 POSIX Host home 写成 `~`;`filePath` 与 Host 打开仍使用作者给出的文件系统路径。注册项会收到常规的会话 slot 运行时共享数据,但不会收到 React 节点或运行时服务。 +owner 载荷为 `ToolCallOwnerProps`:`callId`、`toolName`、`phase` 判别字段及对应阶段的冻结 `block`、可选 `cwd` 与 `home`、会话授权的 `loadImage` loader(供结果携带持久图像的视图使用),以及普通的 `openFile`/`inspect` 回调。PTC dispatch 块保留事件的 `parentCallId`;根会话调用没有该字段,因此后代调用都走同一条按 key 分发路径:已注册视图的调用(如 `read_image`)也会在嵌套处渲染对应卡片,未注册的后代调用则保持通用压平形式。路径摘要先相对会话 cwd 缩短,再把剩余的 POSIX Host home 写成 `~`;`filePath` 与 Host 打开仍使用作者给出的文件系统路径。注册项会收到常规的会话 slot 运行时共享数据,但不会收到 React 节点或运行时服务。 ### 内置视图 +每个注册视图都接收[工具 slot 类型](src/client/contract/slots.ts)声明的显式 `preparing`、`start` 和 `result` props。通用行在三个阶段使用同一个 `ToolRow`。行模型统一选择标题,并组合通用工具名前缀与已有参数摘要,不按生命周期阶段改变前缀;专用标题不附带英文名。准备阶段没有参数摘要,共享参数解析入口直接返回无调用,不解析 JSON。Bash、Skill、Cordis 等自定义 renderer 分别处理准备态,其依赖参数的组件接收 `StartedToolCallViewProps`。 + 本包拥有 generic fallback,以及 shell/pwsh、read、read_image、write/edit、运行中的 `str_replace_editor` `create`/`str_replace`、grep/glob、web、todo、question 与 PTC dispatch 的内置展示。结构化卡片直接从第一方原始 event 字段派生;Host `presentCall` 与 `presentResult` 值不会进入 Client。运行中与已完成的前台标准 `bash`/`pwsh` 和 `terminal_send` 调用,无论位于根还是 PTC dispatch 子调用中,都在通过相同的参数、结果和错误检查后使用 terminal 卡片。持久 `bash`/`pwsh` 调用仅在运行中使用 terminal 卡片。以已识别的 spill 策略提示结尾的 shell 输出,在 shell 行中使用可展开的 generic 输出,在 Details 中使用 generic 输出;位置被改变或被省略的退出标记无法证明成功。已完成的持久 shell 结果保持 generic 展示,因为 reset 与部分输出诊断不一定描述单个进程的退出状态;根调用的持久 shell 结果可展开,后台启动回执则保持折叠。带有 `AUTO_REVIEW_DENIED` 的原生或 PTC dispatch 失败会在折叠行显示 Auto review 裁决,展开时显示一行归一化后的“未执行”原因;原因缺失或只有空白时使用本地化 fallback 文案。成功的问题行按稳定 id 配对调用中的问题与结果中的回答,展开后显示可读的问答行。已取消或已中断的问题行显示其裁决与原始问题,不虚构回答。不受支持、格式错误或含糊的输入回退为压平的工具输入/结果文本。`ui-skill` 展示了业务包自行拥有的 `skill` 注册项。 ----- @@ -59,7 +63,7 @@ owner 载荷为 `ToolCallOwnerProps`:`callId`、`toolName`、冻结的 `block` ### 渲染约定 -`ToolCallTree` 接收一个已经包含递归 `subCalls` 的 root `ToolCallBlock`、会话 `cwd`,以及属主用于打开文件和检查调用的回调。它递归遍历标准调用块,让 root 与任意深度的 child 经过同一条原子分发路径,不订阅独立的 parent-to-children map。每个 root 和 child 包装层都保留 `data-chat-anchor-key="call:"` 与 `data-chat-call-id` DOM 约定,供分页和 selection 使用。 +`ToolCallTree` 接收一个 Tool 节点、会话 `cwd` 和导航回调。每个分支接收稳定的 block,并缓存显式阶段 props,因此一个子调用变化不会重渲染未变化的兄弟分支。它通过 `tool.call.toolview` 按工具名分发。已派发的根调用保留递归 `subCalls`,准备阶段没有子调用。每个根调用和子调用包装层都保留 `data-chat-anchor-key="call:"` 与 `data-chat-call-id` DOM 约定,供分页和选择使用。Tool 节点在三个阶段保持同一个 callId。 Tool 所有者属性将 Chat 注入的稳定 `useDisclosure` 钩子传给根调用及嵌套调用。工具行在拥有展开正文的位置调用它,中间 renderer 不订阅。每次调用拥有独立展开状态,外层轮次收起时重置该状态,不替换 React 身份;展示模式切换保留该状态。 @@ -98,7 +102,7 @@ terminal model 使用浏览器安全入口 `@deepseek-ai/dsh-spill-policy/notice ## 模型体验 -无。该包是浏览器端工具展示层,只渲染已记录的工具调用,不改变模型上下文。 +无。该包渲染流式工具身份与已记录调用,不改变模型上下文。 #### KV Cache 影响 diff --git a/packages/client/ui-tool/package.json b/packages/client/ui-tool/package.json index 87acc5a4e9..1f36739781 100644 --- a/packages/client/ui-tool/package.json +++ b/packages/client/ui-tool/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-tool", "description": "Client Tool call-tree renderer and keyed per-tool presentation slot", - "version": "0.1.7-alpha.1", + "version": "0.1.7-alpha.2", "publishConfig": { "access": "public" }, @@ -42,40 +42,40 @@ }, "license": "MIT", "peerDependencies": { - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:~" }, "devDependencies": { - "@deepseek-ai/dsh-spill-policy": "workspace:^", - "@deepseek-ai/dsh-agent": "workspace:^", - "@deepseek-ai/dsh-llm": "workspace:^", - "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/dsh-system-prompt": "workspace:^", - "@deepseek-ai/dsh-tools": "workspace:^", - "@deepseek-ai/dsh-tool-jobs": "workspace:^", - "@deepseek-ai/dsh-tool-session-query": "workspace:^", - "@deepseek-ai/dsh-tool-terminal": "workspace:^", - "@deepseek-ai/dsh-spill": "workspace:^", - "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-api-remotes": "workspace:^", - "@deepseek-ai/dsh-client-connection": "workspace:^", - "@deepseek-ai/dsh-client-locale": "workspace:^", - "@deepseek-ai/dsh-client-test-runtime": "workspace:^", - "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", - "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", - "@deepseek-ai/dsh-client-ui-slots": "workspace:^", + "@deepseek-ai/dsh-spill-policy": "workspace:*", + "@deepseek-ai/dsh-agent": "workspace:*", + "@deepseek-ai/dsh-llm": "workspace:*", + "@deepseek-ai/dsh-session": "workspace:*", + "@deepseek-ai/dsh-system-prompt": "workspace:*", + "@deepseek-ai/dsh-tools": "workspace:*", + "@deepseek-ai/dsh-tool-jobs": "workspace:*", + "@deepseek-ai/dsh-tool-session-query": "workspace:*", + "@deepseek-ai/dsh-tool-terminal": "workspace:*", + "@deepseek-ai/dsh-spill": "workspace:*", + "@deepseek-ai/cordis": "workspace:~", + "@deepseek-ai/dsh-api-remotes": "workspace:*", + "@deepseek-ai/dsh-client-connection": "workspace:*", + "@deepseek-ai/dsh-client-locale": "workspace:*", + "@deepseek-ai/dsh-client-test-runtime": "workspace:*", + "@deepseek-ai/dsh-client-ui-conversation": "workspace:*", + "@deepseek-ai/dsh-client-ui-primitives": "workspace:*", + "@deepseek-ai/dsh-client-ui-slots": "workspace:*", "@testing-library/react": "^16.1.0", "@types/react": "~18.3.1", "react": "^18.2.0", "react-dom": "^18.2.0", - "@deepseek-ai/dsh-api-workspace-controller": "workspace:^", - "@deepseek-ai/dsh-client-ui-chat": "workspace:^", - "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", - "@deepseek-ai/dsh-client-ui-session": "workspace:^", - "@deepseek-ai/dsh-util-workspace-path": "workspace:^", - "@deepseek-ai/dsh-attachment": "workspace:^", + "@deepseek-ai/dsh-api-workspace-controller": "workspace:*", + "@deepseek-ai/dsh-client-ui-chat": "workspace:*", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:*", + "@deepseek-ai/dsh-client-ui-session": "workspace:*", + "@deepseek-ai/dsh-util-workspace-path": "workspace:*", + "@deepseek-ai/dsh-attachment": "workspace:*", "clsx": "^2.0.0", - "@deepseek-ai/dsh-ptc-runtime": "workspace:^", - "@deepseek-ai/dsh-token-meter": "workspace:^" + "@deepseek-ai/dsh-ptc-runtime": "workspace:*", + "@deepseek-ai/dsh-token-meter": "workspace:*" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-tool/src/client/contract/slots.ts b/packages/client/ui-tool/src/client/contract/slots.ts index d87ccf3ade..3ebda6f301 100644 --- a/packages/client/ui-tool/src/client/contract/slots.ts +++ b/packages/client/ui-tool/src/client/contract/slots.ts @@ -3,7 +3,10 @@ import type { HostObservable, InjectFace, PropsLocale, PropsRenderSlots, PropsRuntime, } from '@deepseek-ai/dsh-client-ui-slots' import type { RemoteHostFacts } from '@deepseek-ai/dsh-api-remotes/client' -import type { OpenFileOptions, ToolCallBlock, UseDisclosure } from '@deepseek-ai/dsh-client-ui-chat/client' +import type { + OpenFileOptions, PreparingToolCall, StartedToolCall, + ToolResultNode, UseDisclosure, +} from '@deepseek-ai/dsh-client-ui-chat/client' import type { MessageImageLoader, MessageImageSource } from '@deepseek-ai/dsh-client-ui-conversation/client' import type {} from '@deepseek-ai/dsh-client-locale/client' @@ -16,7 +19,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { * * Registering an occupied key replaces its view; unclaimed keys use the * generic row. The owner supplies the call identity and frozen running - * or settled node through ToolCallOwnerProps. + * or settled node through explicit phase props; preparing inputs have no arguments. */ 'tool.call.toolview': { kind: 'keyed'; scope: 'session'; owner: ToolCallOwnerProps } /** @@ -47,39 +50,40 @@ export interface ToolImagesOwnerProps { } /** Standard owner currency supplied to every atomic Tool view. */ -export interface ToolCallOwnerProps { - /** Stable injected Hook; each invocation owns its open state and subscribes to enclosing-Turn resets. */ +export interface ToolCallCommonProps { + /** Stable Hook; each invocation owns its open state and subscribes to enclosing-Turn resets. */ useDisclosure: UseDisclosure - /** Tool call identity, stable across running and settled forms. */ + /** Call identity, stable across all stages. */ callId: string /** Wire Tool name and keyed dispatch value. */ toolName: string - /** Frozen running call or settled result node. */ - block: ToolCallBlock /** Session workspace root for relative summaries. */ cwd?: string | undefined /** Host account home; POSIX home-rooted summaries display as `~`. */ home?: string | undefined - /** - * Open a Tool argument path. A view that knows which line the call was about - * passes it, and the opened surface lands there. - */ + /** Open an argument path at its optional requested line. */ openFile: (path: string, options?: OpenFileOptions) => void - /** - * Session-authorized image loader for the `tool.call.images` slot, supplied - * by the chat node that owns this call. A composed chat node always - * supplies it (`ChatNodeOwnerProps.loadImage` is required), so the tool - * layer never imports an attachment implementation nor handles URL - * authorization. - */ + /** Chat-supplied, session-authorized loader for durable images; Tool views do not manage attachment URLs. */ loadImage: MessageImageLoader /** Inspect this call in the trajectory view when available. */ inspect?: (() => void) | undefined } +/** Stage-specific tool data; only start/result expose the dispatched call material. */ +export type ToolCallPhaseProps = + | { readonly phase: 'preparing'; readonly block: PreparingToolCall } + | { readonly phase: 'start'; readonly block: StartedToolCall } + | { readonly phase: 'result'; readonly block: ToolResultNode } + +/** Common owner callbacks and the data admitted at the current tool stage. */ +export type ToolCallOwnerProps = ToolCallCommonProps & ToolCallPhaseProps + /** Full props of a registered atomic Tool view. */ export type ToolCallViewProps = PropsRuntime<'tool.call.toolview'> +/** Existing argument/result business components exclude the preparation stage. */ +export type StartedToolCallViewProps = Exclude + /** Injected Host description for POSIX home-path display. */ export type ToolHostInfoInjected = { hooks: { @@ -93,7 +97,7 @@ export type ToolHostInfoInjected = { } } -/** Full props of the Tool call-tree renderer registered as a `tool-call` Chat Node. */ +/** Full props of the Tool call-tree renderer registered as a tool-call Chat Node. */ export type ToolTreeProps = PropsRuntime<'conversation.chat.node', 'tool-call'> & PropsRenderSlots<'tool.call.toolview'> & PropsLocale<'conversation'> diff --git a/packages/client/ui-tool/src/client/index.ts b/packages/client/ui-tool/src/client/index.ts index c2b2f61fa3..bf79129892 100644 --- a/packages/client/ui-tool/src/client/index.ts +++ b/packages/client/ui-tool/src/client/index.ts @@ -1,5 +1,6 @@ /** Browser Tool plugin: whole-call composition and keyed atomic Tool views. */ export { apply, inject } from './apply.ts' export type { - ToolCallOwnerProps, ToolCallViewProps, ToolHostInfoInjected, ToolTreeProps, + StartedToolCallViewProps, ToolCallCommonProps, ToolCallOwnerProps, ToolCallPhaseProps, ToolCallViewProps, + ToolHostInfoInjected, ToolTreeProps, } from './contract/slots.ts' diff --git a/packages/client/ui-tool/src/client/tool/ToolCallTree.tsx b/packages/client/ui-tool/src/client/tool/ToolCallTree.tsx index eb7309aeeb..d6419aa205 100644 --- a/packages/client/ui-tool/src/client/tool/ToolCallTree.tsx +++ b/packages/client/ui-tool/src/client/tool/ToolCallTree.tsx @@ -1,40 +1,45 @@ /** Root/subcall Tool composition with one keyed atomic dispatch path. */ import { memo, useMemo, type ReactNode } from 'react' import type { ToolCallBlock } from '@deepseek-ai/dsh-client-ui-chat/client' -import type { ToolCallOwnerProps, ToolTreeProps } from '../contract/slots.ts' +import type { ToolCallOwnerProps, ToolCallPhaseProps, ToolTreeProps } from '../contract/slots.ts' import { toolRowModel } from './models/tool-call-model.ts' import { GenericToolCard } from './toolviews/GenericToolCard.tsx' import css from './ToolCallTree.module.css' -/** Resolve a Tool call's wire name from either lifecycle form. */ -function callName(node: ToolCallBlock): string { - return 'kind' in node ? node.call?.name ?? '' : node.name +function toolCallPhase(block: ToolCallBlock): ToolCallPhaseProps { + if ('kind' in block) return { phase: 'result', block } + return block.phase === 'preparing' ? { phase: 'preparing', block } : { phase: 'start', block } +} + +/** Resolve a Tool call's wire name from its current stage. */ +function callName(call: ToolCallPhaseProps): string { + return call.phase === 'result' ? call.block.call?.name ?? '' : call.block.name } /** One atomic call dispatched through the Tool-owned keyed slot. */ const ToolCall = memo(function ToolCall({ - renderSlot, callId, toolName, block, openFile, cwd, home, inspectCall, loadImage, useDisclosure, t, children, + renderSlot, callId, toolName, call, openFile, cwd, home, inspectCall, loadImage, useDisclosure, t, children, }: Pick & { callId: string toolName: string - block: ToolCallBlock + call: ToolCallPhaseProps home?: string | undefined children?: ReactNode }) { const owner: ToolCallOwnerProps = useMemo(() => ({ callId, toolName, - block, + ...call, openFile, cwd, home, loadImage, useDisclosure, inspect: inspectCall === undefined ? undefined : () => { inspectCall(callId) }, - }), [callId, toolName, block, openFile, cwd, home, loadImage, inspectCall, useDisclosure]) + }), [callId, toolName, call, openFile, cwd, home, loadImage, inspectCall, useDisclosure]) const autoReviewDenied = useMemo( - () => toolRowModel(toolName, block).autoReviewDenial !== null, - [toolName, block], + () => call.phase === 'result' && toolRowModel(toolName, call.block).autoReviewDenial !== null, + [toolName, call], ) return (
toolCallPhase(block), [block]) return ( - {block.subCalls.length > 0 ? ( + {call.phase !== 'preparing' && call.block.subCalls.length > 0 ? (
- {block.subCalls.map(child => ( + {call.block.subCalls.map(child => ( info.home) - const block = node.data.root return ( & { + readonly icon: ReactNode + readonly title: string + readonly t: ToolRowProps['t'] +} + +/** + * Render an argument-free, non-expandable tool prefix. + * @param props - tool prefix and locale. + * @returns the preparation row. + */ +export function PreparingToolRow({ toolName, useDisclosure, icon, title, t }: PreparingToolRowProps) { + return +} diff --git a/packages/client/ui-tool/src/client/tool/components/ToolRow.tsx b/packages/client/ui-tool/src/client/tool/components/ToolRow.tsx index 228b6e9a94..618344b4c7 100644 --- a/packages/client/ui-tool/src/client/tool/components/ToolRow.tsx +++ b/packages/client/ui-tool/src/client/tool/components/ToolRow.tsx @@ -96,6 +96,7 @@ export interface ToolRowProps { /** Visually hidden run-state label for color-only running and settlement cues. */ function stateStatus(state: ToolRowState, t: TranslateNS<'conversation'>): string | null { switch (state) { + case 'preparing': return t('row.preparing') case 'running': return t('row.running') case 'error': return t('row.failed') case 'stopped': return t('row.stopped') @@ -105,6 +106,7 @@ function stateStatus(state: ToolRowState, t: TranslateNS<'conversation'>): strin /** * Render one localized tool summary and lazily mounted result card. + * Preparation retains the icon, title, and optional tool-name summary without disclosure. * @param props - tool state, summary, output, and navigation callbacks. * @returns the tool disclosure. */ @@ -157,14 +159,14 @@ export const ToolRow = memo(function ToolRow({ const inputRaw = bodyRaw ?? null const outputText = output ?? null const card = askQuestionBody ?? terminalBody ?? diffBody ?? readBody ?? imageBody ?? searchBody ?? webBody ?? detailsBody - const expandable = inputRaw !== null || outputText !== null || card !== null + const expandable = state !== 'preparing' && (inputRaw !== null || outputText !== null || card !== null) const open = expanded && expandable const bodyText = useMemo( () => open && card === null && inputRaw !== null ? formatToolBody(variant, inputRaw) : null, [card, inputRaw, open, variant], ) const status = stateStatus(state, t) - const running = state === 'running' + const running = state === 'running' || state === 'preparing' const normalSummary = terminalBody?.description ?? (open ? detailsBody?.expandedSummary ?? summary : summary) // A failure keeps its first result line when available and otherwise turns // the ordinary summary red. An interruption turns the tool-owned summary diff --git a/packages/client/ui-tool/src/client/tool/models/raw-tool-call.ts b/packages/client/ui-tool/src/client/tool/models/raw-tool-call.ts index 8d3dad773a..204c83c75f 100644 --- a/packages/client/ui-tool/src/client/tool/models/raw-tool-call.ts +++ b/packages/client/ui-tool/src/client/tool/models/raw-tool-call.ts @@ -1,5 +1,6 @@ /** Shared narrowing for raw Tool call and result fields consumed by card models. */ -import type { ToolCallBlock, ToolResultNode } from '@deepseek-ai/dsh-client-ui-chat/client' +import type { ToolResultNode } from '@deepseek-ai/dsh-client-ui-chat/client' +import type { ToolCallBlock } from './tool-call-model.ts' /** A parsed, in-window Tool call whose arguments are a JSON object. */ export interface ParsedToolCall { @@ -11,10 +12,11 @@ const parsedCalls = new WeakMap() /** * Parse the call head paired with one immutable Tool block. - * @param block - running or settled Tool block. - * @returns the Tool name and object arguments, or null when the call head or valid JSON object is unavailable. + * @param block - preparing, dispatched, or settled Tool block. + * @returns the Tool name and object arguments, or null during preparation or when valid arguments are unavailable. */ export function parsedToolCall(block: ToolCallBlock): ParsedToolCall | null { + if (!('kind' in block) && block.phase === 'preparing') return null const cached = parsedCalls.get(block) if (cached !== undefined || parsedCalls.has(block)) return cached ?? null const call = 'kind' in block ? block.call : block diff --git a/packages/client/ui-tool/src/client/tool/models/todo-diff-model.ts b/packages/client/ui-tool/src/client/tool/models/todo-diff-model.ts index ac4f9213fb..f8451b12d3 100644 --- a/packages/client/ui-tool/src/client/tool/models/todo-diff-model.ts +++ b/packages/client/ui-tool/src/client/tool/models/todo-diff-model.ts @@ -1,5 +1,5 @@ /** Todo changes relative to the preceding recorded write. */ -import type { ToolCallBlock } from '@deepseek-ai/dsh-client-ui-chat/client' +import type { ToolCallBlock } from './tool-call-model.ts' import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots' import type { ToolDetailsModel } from '../components/ToolDetails.tsx' import type { TodoBaseline } from './todo-history.ts' diff --git a/packages/client/ui-tool/src/client/tool/models/tool-call-model.ts b/packages/client/ui-tool/src/client/tool/models/tool-call-model.ts index 79e211a028..ebfea77232 100644 --- a/packages/client/ui-tool/src/client/tool/models/tool-call-model.ts +++ b/packages/client/ui-tool/src/client/tool/models/tool-call-model.ts @@ -5,9 +5,6 @@ * output and error material from the settled result node. A supported terminal * call gets its expanded body from `terminalCardModel` instead. */ -// The block union's defining home is runtime (fold-product types); this -// contract only forwards it (type-definition authority stays with the layer -// that produces the values). import type { ToolCallBlock, ToolResultNode } from '@deepseek-ai/dsh-client-ui-chat/client' import type { LocaleKeysOf } from '@deepseek-ai/dsh-client-ui-slots' import { abbreviateHomePath, relativizeToCwd } from '@deepseek-ai/dsh-util-workspace-path' @@ -18,7 +15,7 @@ export type { ToolCallBlock } from '@deepseek-ai/dsh-client-ui-chat/client' export type ToolRowVariant = 'search' | 'read' | 'bash' | 'write' | 'edit' | 'code' | 'others' /** Row lifecycle state used by summary styling and accessible status text. */ -export type ToolRowState = 'running' | 'ok' | 'error' | 'stopped' +export type ToolRowState = 'preparing' | 'running' | 'ok' | 'error' | 'stopped' /** Locale-neutral structured fact consumed only by the user-facing Tool row. */ export interface AutoReviewDenial { @@ -26,7 +23,7 @@ export interface AutoReviewDenial { reason: string | null } -type ToolTitleKey = Extract, `tool.title.${string}`> +type ToolTitleKey = Extract, `tool.title.${string}` | 'ask.rowTitle' | 'todo.rowTitle'> /** Locale key per generic row variant. */ export const VARIANT_TITLE_KEYS = { @@ -82,6 +79,44 @@ const TOOL_TITLE_KEYS: Record = { cordis_undefine: 'tool.title.removeCordis', pwsh: 'tool.title.pwsh', read_image: 'tool.title.readImage', + todo_write: 'todo.rowTitle', + ask_user_question: 'ask.rowTitle', + create_goal: 'tool.title.createGoal', + get_goal: 'tool.title.getGoal', + update_goal: 'tool.title.updateGoal', + schedule_create: 'tool.title.createSchedule', + schedule_list: 'tool.title.listSchedules', + schedule_delete: 'tool.title.deleteSchedule', + cordis_inspect_list: 'tool.title.inspectProviders', + cordis_inspect_query: 'tool.title.queryRuntime', + cordis_inspect_self: 'tool.title.inspectPlugins', + workflow: 'tool.title.workflow', + ralph: 'tool.title.ralph', + session_event_read: 'tool.title.readEvent', + session_event_search: 'tool.title.searchEvents', + session_event_trace: 'tool.title.traceEvent', + session_search: 'tool.title.searchSessions', + session_trace: 'tool.title.traceSession', + list_subagent_models: 'tool.title.listModels', + subagent: 'tool.title.subagent', + list_agents: 'tool.title.listAgents', + send_message: 'tool.title.sendMessage', + interrupt_agent: 'tool.title.interruptAgent', + job_list: 'tool.title.listJobs', + job_output: 'tool.title.readJob', + job_kill: 'tool.title.killJob', + terminal_open: 'tool.title.openTerminal', + terminal_read: 'tool.title.readTerminal', + terminal_list: 'tool.title.listTerminals', + terminal_signal: 'tool.title.signalTerminal', + terminal_close: 'tool.title.closeTerminal', + lsp: 'tool.title.lsp', + spawn_teammate: 'tool.title.spawnTeammate', + team_task_create: 'tool.title.createTeamTask', + team_task_get: 'tool.title.getTeamTask', + team_task_update: 'tool.title.updateTeamTask', + team_task_list: 'tool.title.listTeamTasks', + wait_agent: 'tool.title.waitAgent', } /** @@ -93,10 +128,20 @@ export function classifyTool(toolName: string): ToolRowVariant { return TOOL_VARIANTS[toolName] ?? 'others' } +/** + * Select a tool-owned or generic title without reading arguments. + * @param toolName - wire tool name. + * @returns the localized title key. + */ +export function toolTitleKey(toolName: string): ToolTitleKey { + return TOOL_TITLE_KEYS[toolName] ?? VARIANT_TITLE_KEYS[classifyTool(toolName)] +} + /** Everything ToolRow needs, derived once from the frozen slice. */ export interface ToolRowModel { variant: ToolRowVariant titleKey: ToolTitleKey + /** Generic rows retain the wire tool name; available arguments append their summary. */ summary: string /** * Filesystem path from args (`path` / `file_path`) when the row is a file @@ -229,27 +274,23 @@ export function formatToolBody(variant: ToolRowVariant, argsRaw: string): string /** * Derive the full row model from a frozen call slice. * @param toolName - wire tool name (dispatch-supplied; survives windowless results). - * @param block - RunningToolCall or ToolResultNode off the snapshot caches. + * @param block - preparing call, dispatched call, or result from the snapshot. * @param cwd - session workspace root; workspace-rooted path summaries display relative to it. * @param home - host account home; a leftover POSIX home path displays as `~`. * @returns the row model. */ export function toolRowModel(toolName: string, block: ToolCallBlock, cwd?: string, home?: string): ToolRowModel { const variant = classifyTool(toolName) + const titleKey = toolTitleKey(toolName) const done = 'kind' in block - const argsRaw = (done ? block.call?.argsRaw : block.argsRaw) ?? '' - const state: ToolRowState = !done ? 'running' + const argsRaw = done ? block.call?.argsRaw ?? '' : block.phase === 'start' ? block.argsRaw : null + const state: ToolRowState = !done ? block.phase === 'preparing' ? 'preparing' : 'running' : block.error?.code === 'interrupted' ? 'stopped' : block.isError ? 'error' : 'ok' - const base = argsRaw === '' - ? block.callId - : abbreviateHomePath(relativizeToCwd(deriveSummary(variant, argsRaw), cwd), home) - const toolTitleKey = TOOL_TITLE_KEYS[toolName] - // Others keeps the static "Tool call" title (figma literal); the real tool - // name rides the mutable summary slot unless the tool owns a specific title. - const summary = variant === 'others' && toolName !== '' && toolTitleKey === undefined - ? `${toolName} · ${base}` - : base + const base = argsRaw === null ? '' + : argsRaw === '' ? block.callId + : abbreviateHomePath(relativizeToCwd(deriveSummary(variant, argsRaw), cwd), home) + const summary = [titleKey === 'tool.title.generic' ? toolName : '', base].filter(Boolean).join(' · ') // The empty string is "no text" for both derived result fields: a settled // call with blank content has nothing to expand, and a blank first line // would erase the collapsed error row's summary slot. @@ -258,9 +299,9 @@ export function toolRowModel(toolName: string, block: ToolCallBlock, cwd?: strin const bodyRaw = argsRaw === '' ? null : argsRaw return { variant, - titleKey: toolTitleKey ?? VARIANT_TITLE_KEYS[variant], + titleKey, summary, - filePath: deriveFilePath(variant, argsRaw), + filePath: argsRaw === null ? undefined : deriveFilePath(variant, argsRaw), bodyRaw, output, errorSummary, diff --git a/packages/client/ui-tool/src/client/tool/toolviews/GenericToolCard.tsx b/packages/client/ui-tool/src/client/tool/toolviews/GenericToolCard.tsx index c58729723b..bc0faacb13 100644 --- a/packages/client/ui-tool/src/client/tool/toolviews/GenericToolCard.tsx +++ b/packages/client/ui-tool/src/client/tool/toolviews/GenericToolCard.tsx @@ -25,10 +25,11 @@ const VARIANT_ICONS: Record = { } /** Card props: the owner payload plus the render site's locale seat (plain prop). */ -export interface GenericToolCardProps extends ToolCallOwnerProps { +export type GenericToolCardProps = ToolCallOwnerProps & { t: ToolTreeProps['t'] } +/** @param props - current tool stage and locale. @returns its preparation or dispatched card. */ export function GenericToolCard({ toolName, block, cwd, home, openFile, inspect, useDisclosure, t }: GenericToolCardProps) { const model = toolRowModel(toolName, block, cwd, home) const autoReview = model.autoReviewDenial === null diff --git a/packages/client/ui-tool/src/client/tool/toolviews/ask-question-row.tsx b/packages/client/ui-tool/src/client/tool/toolviews/ask-question-row.tsx index c08a38312f..ff38e3f7a3 100644 --- a/packages/client/ui-tool/src/client/tool/toolviews/ask-question-row.tsx +++ b/packages/client/ui-tool/src/client/tool/toolviews/ask-question-row.tsx @@ -146,7 +146,7 @@ export function AskQuestionRow({ toolName, block, inspect, useDisclosure, t }: A // failed shape, and the abort keeps the shared stopped (amber) semantics of // any other interrupted tool call. const code = 'kind' in block ? block.error?.code : undefined - const argsRaw = ('kind' in block ? block.call?.argsRaw : block.argsRaw) ?? '' + const argsRaw = model.bodyRaw ?? '' let summary = model.summary let state = model.state let transcript: AskQuestionCardModel | null = null @@ -185,7 +185,7 @@ export function AskQuestionRow({ toolName, block, inspect, useDisclosure, t }: A variant={model.variant} toolName={toolName} icon={} - title={t('ask.rowTitle')} + title={t(model.titleKey)} summary={summary} bodyRaw={transcript === null ? model.bodyRaw : null} output={transcript === null ? model.output : null} diff --git a/packages/client/ui-tool/src/client/tool/toolviews/bash-sample.tsx b/packages/client/ui-tool/src/client/tool/toolviews/bash-sample.tsx index 99f71f5beb..47ae2d1b6d 100644 --- a/packages/client/ui-tool/src/client/tool/toolviews/bash-sample.tsx +++ b/packages/client/ui-tool/src/client/tool/toolviews/bash-sample.tsx @@ -15,7 +15,8 @@ import { terminalCardModel, terminalFailed, } from '../models/terminal-card-model.ts' -import { formatToolBody, toolRowModel, type ToolRowState } from '../models/tool-call-model.ts' +import { formatToolBody, toolRowModel, toolTitleKey, type ToolRowState } from '../models/tool-call-model.ts' +import { PreparingToolRow } from '../components/PreparingToolRow.tsx' import { CONVERSATION_NS as NS } from '../../locale.ts' import css from './bash-sample.module.css' @@ -38,7 +39,13 @@ function stateStatus(state: ToolRowState, t: BashRowProps['t']): string | null { * @param props - tool call, Session sources, locale, and inspection callback. * @returns the Bash output row. */ -export const BashRow = memo(function BashRow({ toolName, block, sessionId, useSessions, inspect, useDisclosure, t }: BashRowProps) { +export const BashRow = memo(function BashRow(props: BashRowProps) { + if (props.phase === 'preparing') return + return +}) + +const StartedBashRow = memo(function StartedBashRow({ toolName, block, sessionId, useSessions, inspect, useDisclosure, t }: Exclude) { const model = useMemo(() => toolRowModel(toolName, block), [toolName, block]) // An omitted shell workdir is the session workspace; relative values resolve // against it before reaching the terminal primitive. diff --git a/packages/client/ui-tool/src/client/tool/toolviews/details-row.tsx b/packages/client/ui-tool/src/client/tool/toolviews/details-row.tsx index a2bfcb447d..bd6d395ac2 100644 --- a/packages/client/ui-tool/src/client/tool/toolviews/details-row.tsx +++ b/packages/client/ui-tool/src/client/tool/toolviews/details-row.tsx @@ -4,6 +4,7 @@ import type { Context } from '@deepseek-ai/cordis' import { IconAgentPresetOutlineRegular, IconBranchOutlineRegular, IconChecklistOutlineRegular, IconClockOutlineRegular, IconCodeOutlineRegular, IconCordisPluginOutlineRegular, IconGoalOutlineRegular, IconSearchOutlineRegular, + IconUsersOutlineRegular, } from '@deepseek-ai/dsh-client-ui-primitives' import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' import type { ToolCallViewProps } from '../../contract/slots.ts' @@ -13,44 +14,7 @@ import { detailsCardModel } from '../models/details-card-model.ts' import { parsedToolCall } from '../models/raw-tool-call.ts' import { toolRowModel } from '../models/tool-call-model.ts' -const TITLE_KEYS = { - create_goal: 'tool.title.createGoal', - get_goal: 'tool.title.getGoal', - update_goal: 'tool.title.updateGoal', - schedule_create: 'tool.title.createSchedule', - schedule_list: 'tool.title.listSchedules', - schedule_delete: 'tool.title.deleteSchedule', - cordis_inspect_list: 'tool.title.inspectProviders', - cordis_inspect_query: 'tool.title.queryRuntime', - cordis_inspect_self: 'tool.title.inspectPlugins', - workflow: 'tool.title.workflow', - ralph: 'tool.title.ralph', - session_event_read: 'tool.title.readEvent', - session_event_search: 'tool.title.searchEvents', - session_event_trace: 'tool.title.traceEvent', - session_search: 'tool.title.searchSessions', - session_trace: 'tool.title.traceSession', - list_subagent_models: 'tool.title.listModels', - subagent: 'tool.title.subagent', - list_agents: 'tool.title.listAgents', - send_message: 'tool.title.sendMessage', - interrupt_agent: 'tool.title.interruptAgent', - job_list: 'tool.title.listJobs', - job_output: 'tool.title.readJob', - job_kill: 'tool.title.killJob', - terminal_open: 'tool.title.openTerminal', - terminal_read: 'tool.title.readTerminal', - terminal_list: 'tool.title.listTerminals', - terminal_signal: 'tool.title.signalTerminal', - terminal_close: 'tool.title.closeTerminal', - lsp: 'tool.title.lsp', - spawn_teammate: 'tool.title.spawnTeammate', - team_task_create: 'tool.title.createTeamTask', - team_task_get: 'tool.title.getTeamTask', - team_task_update: 'tool.title.updateTeamTask', - team_task_list: 'tool.title.listTeamTasks', - wait_agent: 'tool.title.waitAgent', -} as const +type DetailsRowProps = ToolCallViewProps & PropsLocale<'conversation'> const LSP_TITLE_KEYS = { goToDefinition: 'tool.title.findDefinition', @@ -59,7 +23,11 @@ const LSP_TITLE_KEYS = { hover: 'tool.title.hoverSymbol', } as const +/** Teammate-coordination tools presented with the two-person team icon. */ +const TEAMMATE_TOOLS = new Set(['spawn_teammate', 'list_agents', 'send_message', 'interrupt_agent', 'wait_agent']) + function detailIcon(toolName: string) { + if (TEAMMATE_TOOLS.has(toolName)) return if (toolName.startsWith('schedule_')) return if (toolName.endsWith('_goal')) return if (toolName.startsWith('cordis_')) return @@ -75,14 +43,14 @@ function detailIcon(toolName: string) { * @param props - Tool call, row actions, and locale supplied by the keyed slot. * @returns A Tool row with structured details or generic input/output. */ -export function DetailsRow({ toolName, block, cwd, home, openFile, inspect, useDisclosure, t }: ToolCallViewProps & PropsLocale<'conversation'>) { +export function DetailsRow({ toolName, block, cwd, home, openFile, inspect, useDisclosure, t }: DetailsRowProps) { const model = toolRowModel(toolName, block, cwd, home) const locale = document.documentElement.lang const details = useMemo(() => detailsCardModel(block, t, locale), [block, t, locale]) const operation = toolName === 'lsp' ? parsedToolCall(block)?.args.operation : undefined const titleKey = typeof operation === 'string' && Object.hasOwn(LSP_TITLE_KEYS, operation) ? LSP_TITLE_KEYS[operation as keyof typeof LSP_TITLE_KEYS] - : Object.hasOwn(TITLE_KEYS, toolName) ? TITLE_KEYS[toolName as keyof typeof TITLE_KEYS] : model.titleKey + : model.titleKey return ( snapshot.hasMore) const diff = useMemo(() => todoDiffModel(block, baseline, hasMore, t), [block, baseline, hasMore, t]) const model = toolRowModel(toolName, block) - const argsRaw = ('kind' in block ? block.call?.argsRaw : block.argsRaw) ?? '' - const summary = summarize(argsRaw, t) ?? { text: model.summary, extra: 0 } + const summary = summarize(model.bodyRaw, t) ?? { text: model.summary, extra: 0 } return ( } - title={t('todo.rowTitle')} + title={t(model.titleKey)} summary={summary.text} summarySuffix={[diff?.summary, summary.extra > 0 ? `+${summary.extra}` : null] .filter((part): part is string => part !== null && part !== undefined).join(' · ') || null} diff --git a/packages/client/ui-tool/tests/ask-question-row.client.spec.tsx b/packages/client/ui-tool/tests/ask-question-row.client.spec.tsx index 3c8106a977..7e833c9659 100644 --- a/packages/client/ui-tool/tests/ask-question-row.client.spec.tsx +++ b/packages/client/ui-tool/tests/ask-question-row.client.spec.tsx @@ -33,13 +33,13 @@ const resultNode = (argsRaw: string, resultText: string | null, over?: Partial - ({ callId: 'c1', name: 'ask_user_question', argsRaw, turn: 1, step: 1, time: 1_000, subCalls: [] }) + ({ phase: 'start' as const, callId: 'c1', name: 'ask_user_question', argsRaw, turn: 1, step: 1, time: 1_000, subCalls: [] }) const t = makeTranslate(zh, commonZh) -function rowProps(block: unknown): Parameters[0] { +function rowProps(block: Parameters[0]['block']): Parameters[0] { return { - useDisclosure, callId: 'c1', toolName: 'ask_user_question', block, t, + useDisclosure, callId: 'c1', toolName: 'ask_user_question', ...('kind' in block ? { phase: 'result' as const, block: block } : { phase: block.phase, block: block }), t, openFile: vi.fn(), sessionId: 's1', useSessions: () => undefined, @@ -98,7 +98,7 @@ describe('AskQuestionRow', () => { ], }))} />) - expect(screen.getByText(`ask_user_question · ${READABLE_ARGS}`)).toBeTruthy() + expect(screen.getByText(READABLE_ARGS)).toBeTruthy() fireEvent.click(screen.getByRole('button', { expanded: false })) expect(view.container.querySelector('[class*="ioCard"]')).not.toBeNull() expect(view.container.textContent).toContain('"type": "reasoning"') @@ -124,7 +124,7 @@ describe('AskQuestionRow', () => { { label: 'empty result content', text: null }, ])('settled result falls back to the generic summary on $label', ({ text }) => { render() - expect(screen.getByText(`ask_user_question · ${ARGS}`)).toBeTruthy() + expect(screen.getByText(ARGS)).toBeTruthy() }) it.each([ @@ -214,19 +214,19 @@ describe('AskQuestionRow', () => { expect(view.container.querySelector('[data-state="stopped"]')).not.toBeNull() expect(view.container.querySelector('[data-state="stopped"] svg')).not.toBeNull() expect(screen.queryByText('已取消')).toBeNull() - expect(screen.getByText(`ask_user_question · ${ARGS}`)).toBeTruthy() + expect(screen.getByText(ARGS)).toBeTruthy() }) it('other tool errors keep the generic summary with the error state', () => { const view = render() expect(view.container.querySelector('[data-state="error"]')).not.toBeNull() expect(view.container.querySelector('[data-state="error"] svg')).not.toBeNull() - expect(screen.getByText(`ask_user_question · ${ARGS}`)).toBeTruthy() + expect(screen.getByText(ARGS)).toBeTruthy() }) it('window-truncated result (call head lost) falls back to the callId summary', () => { render() - expect(screen.getByText('ask_user_question · c1')).toBeTruthy() + expect(screen.getByText('c1')).toBeTruthy() }) it('leading toggle expands the raw args body', () => { diff --git a/packages/client/ui-tool/tests/chat-ptc-subcalls.client.spec.tsx b/packages/client/ui-tool/tests/chat-ptc-subcalls.client.spec.tsx index 3e2af18a61..857a8e9858 100644 --- a/packages/client/ui-tool/tests/chat-ptc-subcalls.client.spec.tsx +++ b/packages/client/ui-tool/tests/chat-ptc-subcalls.client.spec.tsx @@ -4,7 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { cleanup, fireEvent } from '@testing-library/react' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import type { - ChatSnapshot, RunningToolCall, ToolCallBlock, ToolResultNode, + ChatSnapshot, StartedToolCall, ToolCallBlock, ToolResultNode, } from '@deepseek-ai/dsh-client-ui-chat/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' import { SlotTestRuntime, stubConfigForm } from '@deepseek-ai/dsh-client-test-runtime' @@ -50,8 +50,8 @@ const codeResult = (seq: number, callId: string): ToolResultNode => ({ subCalls: [], }) -const runningCode = (callId: string): RunningToolCall => ({ - callId, name: 'run_code', argsRaw: RUN_CODE_ARGS, turn: 9, step: 0, time: 9_000, +const runningCode = (callId: string): StartedToolCall => ({ + phase: 'start' as const, callId, name: 'run_code', argsRaw: RUN_CODE_ARGS, turn: 9, step: 0, time: 9_000, subCalls: [], }) @@ -70,7 +70,7 @@ const subCall = ( function snapshotWith( nodes: ToolResultNode[], subCalls: readonly ToolCallBlock[], - runningCalls: RunningToolCall[] = [], + runningCalls: StartedToolCall[] = [], ): ChatSnapshot { const nestedNodes = nodes.map(node => ({ ...node, subCalls })) const nestedRunningCalls = runningCalls.map(call => ({ ...call, subCalls })) @@ -189,7 +189,7 @@ describe('run_code sub-calls through the real chat machinery', () => { // Each run-control verb names its act and shows the package id; without the // owned titles all three would read "Tool call · cordis_run · dyn-2". - expect(nest.querySelector('[data-tool="cordis_runtime_inspect"]')?.textContent).toContain('Inspect') + expect(nest.querySelector('[data-tool="cordis_runtime_inspect"]')?.textContent).toContain('Query Cordis environment') expect(nest.querySelector('[data-tool="cordis_run"]')?.textContent).toContain('Run Cordis Plugindyn-2') expect(nest.querySelector('[data-tool="cordis_undefine"]')?.textContent).toContain('Remove Cordis Plugindyn-2') // None of them is a code row: the program belongs to cordis_define, whose @@ -258,13 +258,13 @@ describe('run_code sub-calls through the real chat machinery', () => { it('a started-but-unsettled sub-call renders the running state exactly like a native in-flight row', async () => { const parent = 'call-live' const runningSub: ToolCallBlock = { - callId: `${parent}:code:1`, name: 'grep', argsRaw: '{"pattern":"todo"}', + phase: 'start' as const, callId: `${parent}:code:1`, name: 'grep', argsRaw: '{"pattern":"todo"}', parentCallId: parent, turn: 0, step: 0, time: 21_000, subCalls: [], } const b = await bench(snapshotWith([], [runningSub], [runningCode(parent)])) const view = mountApp(b.runtime) - // The nested row derives 'running' from the RunningToolCall shape — the + // The nested row derives 'running' from the StartedToolCall shape — the // same data-state chrome (row sweep) a native in-flight row wears. const nested = view.container.querySelector('[data-subcalls] [data-variant][data-state="running"]') expect(nested).not.toBeNull() diff --git a/packages/client/ui-tool/tests/coverage-tails.client.spec.tsx b/packages/client/ui-tool/tests/coverage-tails.client.spec.tsx index 8c261eb5de..6e64b9e55a 100644 --- a/packages/client/ui-tool/tests/coverage-tails.client.spec.tsx +++ b/packages/client/ui-tool/tests/coverage-tails.client.spec.tsx @@ -6,7 +6,7 @@ import { cleanup, render } from '@testing-library/react' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import { bindSnapshotSelector, makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client' -import type { RunningToolCall, ToolResultNode } from '@deepseek-ai/dsh-client-ui-chat/client' +import type { StartedToolCall, ToolResultNode } from '@deepseek-ai/dsh-client-ui-chat/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' import { GenericToolCard, type GenericToolCardProps } from '../src/client/tool/toolviews/GenericToolCard.tsx' @@ -33,9 +33,9 @@ function listStore() { }) } -function bashProps(block: RunningToolCall | ToolResultNode): BashRowProps { +function bashProps(block: StartedToolCall | ToolResultNode): BashRowProps { return { - useDisclosure, callId: 'c1', toolName: 'bash', block, openFile: vi.fn(), + useDisclosure, callId: 'c1', toolName: 'bash', ...('kind' in block ? { phase: 'result' as const, block: block } : { phase: block.phase, block: block }), openFile: vi.fn(), sessionId: SID, useSessions: bindSnapshotSelector(listStore()), t, } as BashRowProps @@ -61,7 +61,7 @@ describe('Tool presentation tails', () => { } const props: GenericToolCardProps = { loadImage: vi.fn(() => Promise.reject(new Error('not used'))), - useDisclosure, callId: 'c5', toolName: 'todo_write', block: settled, openFile: vi.fn(), t, + useDisclosure, callId: 'c5', toolName: 'todo_write', phase: 'result' as const, block: settled, openFile: vi.fn(), t, } const view = render() expect(view.container.querySelector('[data-variant="others"] svg')).not.toBeNull() @@ -83,8 +83,8 @@ describe('Tool presentation tails', () => { }) it('BashRow retains its business icon for failed and stopped states', () => { - const running: RunningToolCall = { - callId: 'c1', name: 'bash', argsRaw: '{"command":"ls","description":"List"}', + const running: StartedToolCall = { + phase: 'start' as const, callId: 'c1', name: 'bash', argsRaw: '{"command":"ls","description":"List"}', turn: 1, step: 1, time: 1_000, subCalls: [], } const errorResult: ToolResultNode = { diff --git a/packages/client/ui-tool/tests/details-models.client.spec.ts b/packages/client/ui-tool/tests/details-models.client.spec.ts index a38c41ff34..631772f168 100644 --- a/packages/client/ui-tool/tests/details-models.client.spec.ts +++ b/packages/client/ui-tool/tests/details-models.client.spec.ts @@ -3,8 +3,9 @@ import { describe, expect, it } from 'vitest' import type { ToolResultNode } from '@deepseek-ai/dsh-client-ui-chat/client' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' -import { en } from '@deepseek-ai/dsh-client-ui-conversation/src/client/locales.ts' +import { en, zh } from '@deepseek-ai/dsh-client-ui-conversation/src/client/locales.ts' import { en as commonEn } from '@deepseek-ai/dsh-client-locale/src/locales/en.ts' +import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' import { statusLine } from '@deepseek-ai/dsh-tool-jobs/src/render.ts' import { presentation } from '@deepseek-ai/dsh-tool-session-query/src/presentation.ts' import { renderList, renderRead, renderSpawn } from '@deepseek-ai/dsh-tool-terminal/src/render.ts' @@ -51,6 +52,16 @@ describe('shared detail values', () => { }) describe('control detail adapters', () => { + it.each([ + [{ timedOut: false }, '子智能体状态 · 检测到变化'], + [{ timedOut: true }, '子智能体状态 · 等待超时'], + [{ timedOut: false, noProgress: { message: 'No pending work' } }, '没有正在运行的子智能体'], + ] as const)('names subagents in Chinese wait results (%j)', (value, summary) => { + const translate = makeTranslate(zh, commonZh) + expect(translate('tool.title.waitAgent')).toBe('等待子智能体') + expect(detailsCardModel(output('wait_agent', JSON.stringify(value)), translate, 'zh-CN')?.summary).toBe(summary) + }) + it('uses producer wording for agent, job, terminal, and LSP lists', () => { const agents = details('list_agents', 'a1 [running] — Build\na2 [ready] parent=a1 depth=1 — Review', { scope: 'descendants' }) expect(agents?.summary).toBe('2 agents') @@ -78,7 +89,7 @@ describe('control detail adapters', () => { expect(details('send_message', '{"status":"queued"}', { agent_id: 'a1', message: 'hello' })?.items[0]?.badge?.label).toBe('Queued') expect(details('send_message', 'message delivered to agent a1', { agent_id: 'a1', message: 'hello' })?.summary).toBe('a1 · Message delivered') expect(details('interrupt_agent', '{"previousStatus":"running"}', { agent_id: 'a1' })?.items[0]?.fields).toContainEqual({ label: 'Previous status', value: 'Running' }) - expect(details('wait_agent', '{"timedOut":true}')?.summary).toBe('Teammate activity · Wait timed out') + expect(details('wait_agent', '{"timedOut":true}')?.summary).toBe('Subagent activity · Wait timed out') expect(details('wait_agent', '{"timedOut":false,"noProgress":{"message":"No peer"}}')?.items[0]?.description).toBe('No peer') expect(details('subagent', 'started background subagent job job-1', { prompt: 'Do work' })?.items[0]?.fields).toContainEqual({ label: 'Job ID', value: 'job-1' }) expect(details('subagent', 'A useful reply', { prompt: 'Do work' })?.items[0]?.markdown).toBe('A useful reply') diff --git a/packages/client/ui-tool/tests/details-row.client.spec.tsx b/packages/client/ui-tool/tests/details-row.client.spec.tsx index acca5c7617..9586990b97 100644 --- a/packages/client/ui-tool/tests/details-row.client.spec.tsx +++ b/packages/client/ui-tool/tests/details-row.client.spec.tsx @@ -3,6 +3,7 @@ import { cleanup, fireEvent, render, screen } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' import type { ToolResultNode } from '@deepseek-ai/dsh-client-ui-chat/client' +import { IconUsersOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { en } from '@deepseek-ai/dsh-client-ui-conversation/src/client/locales.ts' import { en as commonEn } from '@deepseek-ai/dsh-client-locale/src/locales/en.ts' @@ -62,7 +63,7 @@ describe('detailsCardModel', () => { expect(detailsCardModel({ ...block, call: { name: 'create_goal', argsRaw: '{' } }, t, 'en')).toBeNull() expect(detailsCardModel({ ...block, content: [{ type: 'text', text: 'partial {' }] }, t, 'en')).toBeNull() expect(detailsCardModel({ ...block, content: [...block.content, { type: 'text', text: 'Extra result' }] }, t, 'en')).toBeNull() - expect(detailsCardModel({ callId: 'c1', name: 'create_goal', argsRaw: '{}', turn: 1, step: 1, time: 1000, subCalls: [] }, t, 'en')).toBeNull() + expect(detailsCardModel({ phase: 'start' as const, callId: 'c1', name: 'create_goal', argsRaw: '{}', turn: 1, step: 1, time: 1000, subCalls: [] }, t, 'en')).toBeNull() expect(detailsCardModel({ ...block, parentCallId: 'parent' }, t, 'en')?.items[0]?.title).toBe(goal.objective) }) }) @@ -90,6 +91,14 @@ describe('DetailsRow', () => { expect(inspect).toHaveBeenCalledOnce() }) + it('presents teammate-coordination tools under the two-person team icon', () => { + const inspect = vi.fn() + const expectedIcon = render().container.querySelector('svg')!.outerHTML + const view = render([0]} />) + expect(screen.getByText('Wait for subagent')).toBeTruthy() + expect(view.container.querySelector('svg')?.outerHTML).toBe(expectedIcon) + }) + it('registers the supported tool names through the scoped keyed slot', () => { const register = vi.fn((_spec: unknown, _component: unknown) => () => undefined) const inject = vi.fn((_name: string, callback: () => Iterable<() => void>) => { diff --git a/packages/client/ui-tool/tests/diff-card.client.spec.tsx b/packages/client/ui-tool/tests/diff-card.client.spec.tsx index 7be88bb9d6..3f9282b29f 100644 --- a/packages/client/ui-tool/tests/diff-card.client.spec.tsx +++ b/packages/client/ui-tool/tests/diff-card.client.spec.tsx @@ -5,7 +5,7 @@ import { useDisclosure } from '@deepseek-ai/dsh-client-ui-chat/src/client/chat/u import { cleanup, fireEvent, render } from '@testing-library/react' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' -import type { RunningToolCall, ToolResultNode } from '@deepseek-ai/dsh-client-ui-chat/client' +import type { StartedToolCall, ToolResultNode } from '@deepseek-ai/dsh-client-ui-chat/client' import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' @@ -27,8 +27,8 @@ const ARGS = '{"file_path":"notes/demo.txt","old_string":"hello","new_string":"h const DIFFS = [{ path: 'notes/demo.txt', oldText: 'hello', newText: 'hello fixture' }] -const running = (over?: Partial): RunningToolCall => ({ - callId: 'c1', name: 'edit', argsRaw: ARGS, +const running = (over?: Partial): StartedToolCall => ({ + phase: 'start' as const, callId: 'c1', name: 'edit', argsRaw: ARGS, turn: 1, step: 1, time: 1_000, subCalls: [], ...over, }) @@ -159,9 +159,9 @@ describe('diffCardModel', () => { }) describe('chat row diff body', () => { - const ownerProps = (block: RunningToolCall | ToolResultNode): GenericToolCardProps => ({ + const ownerProps = (block: StartedToolCall | ToolResultNode): GenericToolCardProps => ({ loadImage: vi.fn(() => Promise.reject(new Error('not used'))), - useDisclosure, callId: 'c1', toolName: 'edit', block, openFile: vi.fn(), t, + useDisclosure, callId: 'c1', toolName: 'edit', ...('kind' in block ? { phase: 'result' as const, block: block } : { phase: block.phase, block: block }), openFile: vi.fn(), t, }) it('the expanded body is the applied diff, capped tighter than the panel', () => { @@ -187,7 +187,7 @@ describe('chat row diff body', () => { const view = render( Promise.reject(new Error('not used'))), t, - block: settled({ + phase: 'result' as const, block: settled({ call: { name: 'some_tool', argsRaw: '{"foo":"bar"}' }, meta: undefined, }), @@ -206,8 +206,8 @@ describe('FileMutationRow diff card', () => { projectionsBySession: {}, }) - const rowProps = (block: RunningToolCall | ToolResultNode, toolName = 'edit'): FileMutationRowProps => ({ - useDisclosure, callId: 'c1', toolName, block, openFile: vi.fn(), cwd: '/w/app', + const rowProps = (block: StartedToolCall | ToolResultNode, toolName = 'edit'): FileMutationRowProps => ({ + useDisclosure, callId: 'c1', toolName, ...('kind' in block ? { phase: 'result' as const, block: block } : { phase: block.phase, block: block }), openFile: vi.fn(), cwd: '/w/app', sessionId: SID, useSessions: bindSnapshotSelector(list()), t, } as FileMutationRowProps) diff --git a/packages/client/ui-tool/tests/image-card.client.spec.tsx b/packages/client/ui-tool/tests/image-card.client.spec.tsx index bd66af5ff3..5cb4229f49 100644 --- a/packages/client/ui-tool/tests/image-card.client.spec.tsx +++ b/packages/client/ui-tool/tests/image-card.client.spec.tsx @@ -18,7 +18,7 @@ import { Context } from '@deepseek-ai/cordis' import { bindSnapshotSelector, makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' -import type { RunningToolCall, ToolResultNode } from '@deepseek-ai/dsh-client-ui-chat/client' +import type { StartedToolCall, ToolResultNode } from '@deepseek-ai/dsh-client-ui-chat/client' import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots' @@ -56,8 +56,8 @@ const withImage = (attachment: unknown) => [ { type: 'image', attachment }, ] -const running = (over?: Partial): RunningToolCall => ({ - callId: 'c1', name: 'read_image', argsRaw: ARGS, +const running = (over?: Partial): StartedToolCall => ({ + phase: 'start' as const, callId: 'c1', name: 'read_image', argsRaw: ARGS, turn: 1, step: 1, time: 1_000, subCalls: [], ...over, }) @@ -275,11 +275,11 @@ describe('ReadImageRow keyed toolview', () => { } as unknown as SessionListState) const rowProps = ( - block: RunningToolCall | ToolResultNode, + block: StartedToolCall | ToolResultNode, renderSlot?: PropsRenderSlots<'tool.call.images'>['renderSlot'], loader: MessageImageLoader = loadImage, ): Parameters[0] => ({ - useDisclosure, callId: 'c1', toolName: 'read_image', block, openFile: vi.fn(), renderSlot, loadImage: loader, + useDisclosure, callId: 'c1', toolName: 'read_image', ...('kind' in block ? { phase: 'result' as const, block: block } : { phase: block.phase, block: block }), openFile: vi.fn(), renderSlot, loadImage: loader, sessionId: SID, useSessions: bindSnapshotSelector(list()), t, } as Parameters[0]) diff --git a/packages/client/ui-tool/tests/preparing-rows.client.spec.tsx b/packages/client/ui-tool/tests/preparing-rows.client.spec.tsx new file mode 100644 index 0000000000..1140e6498c --- /dev/null +++ b/packages/client/ui-tool/tests/preparing-rows.client.spec.tsx @@ -0,0 +1,111 @@ +// @vitest-environment jsdom +import { afterEach, describe, expect, it, vi } from 'vitest' +import { cleanup, fireEvent, render } from '@testing-library/react' +import { useDisclosure } from '@deepseek-ai/dsh-client-ui-chat/src/client/chat/use-disclosure.ts' +import type { StartedToolCall, ToolResultNode } from '@deepseek-ai/dsh-client-ui-chat/client' +import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' +import { en } from '@deepseek-ai/dsh-client-ui-conversation/src/client/locales.ts' +import { en as common } from '@deepseek-ai/dsh-client-locale/src/locales/en.ts' +import { GenericToolCard } from '../src/client/tool/toolviews/GenericToolCard.tsx' +import { FileMutationRow } from '../src/client/tool/toolviews/file-mutation-row.tsx' +import { ReadRow } from '../src/client/tool/toolviews/read-row.tsx' +import { ReadImageRow } from '../src/client/tool/toolviews/read-image-row.tsx' +import { SearchRow } from '../src/client/tool/toolviews/search-row.tsx' +import { WebRow } from '../src/client/tool/toolviews/web-row.tsx' +import { DetailsRow } from '../src/client/tool/toolviews/details-row.tsx' +import { TodoRow } from '../src/client/tool/toolviews/todo-row.tsx' +import { AskQuestionRow } from '../src/client/tool/toolviews/ask-question-row.tsx' +import { BashRow } from '../src/client/tool/toolviews/bash-sample.tsx' +import { parsedToolCall } from '../src/client/tool/models/raw-tool-call.ts' +import { toolRowModel } from '../src/client/tool/models/tool-call-model.ts' + +afterEach(cleanup) + +type Props = Parameters[0] & Parameters[0] + +function preparation(name: string): Props { + return { + phase: 'preparing', callId: 'call', toolName: name, + block: { phase: 'preparing', callId: 'call', name, turn: 1, step: 1, time: 1, subCalls: [] }, + t: makeTranslate(en, common), useDisclosure, openFile: vi.fn(), loadImage: vi.fn(), + useTodoHistory: vi.fn(), useSession: vi.fn(() => false), renderSlot: vi.fn(() => null), + } as Props +} + +describe('argument-free tool preparation', () => { + it.each([ + ['read', ReadRow], ['read_image', ReadImageRow], ['write', FileMutationRow], ['edit', FileMutationRow], + ['grep', SearchRow], ['glob', SearchRow], ['web_search', WebRow], ['web_fetch', WebRow], + ['todo_write', TodoRow], ['ask_user_question', AskQuestionRow], ['subagent', DetailsRow], + ['run_code', GenericToolCard], ['custom_tool', GenericToolCard], + ] as const)('%s has a tool-owned prefix without arguments or disclosure', (name, Component) => { + const props = preparation(name) + const view = render() + expect(view.container.querySelector('[data-state="preparing"]')).not.toBeNull() + expect(view.container.querySelector('svg')).not.toBeNull() + expect(view.container.textContent?.trim()).not.toBe('') + expect(view.queryByRole('button')).toBeNull() + expect(view.container.querySelector('pre')).toBeNull() + expect(parsedToolCall(props.block)).toBeNull() + expect(toolRowModel(name, props.block)).toMatchObject({ state: 'preparing', bodyRaw: null, output: null, filePath: undefined }) + fireEvent.click(view.container.firstElementChild!) + fireEvent.keyDown(view.container.firstElementChild!, { key: 'Enter' }) + expect(view.container.querySelector('[aria-expanded="true"]')).toBeNull() + }) + + it('keeps the common write row mounted as complete arguments and a result arrive', () => { + const props = preparation('write') + const view = render() + const row = view.container.querySelector('[data-tool="write"]') + const started: StartedToolCall = { + phase: 'start', callId: 'call', name: 'write', turn: 1, step: 1, time: 2, subCalls: [], + argsRaw: '{"file_path":"hello.txt","content":"hello"}', + } + view.rerender() + expect(view.container.querySelector('[data-tool="write"]')).toBe(row) + expect(view.getByText('hello.txt')).toBeTruthy() + expect(view.container.querySelector('[data-state="running"]')).not.toBeNull() + const result: ToolResultNode = { + kind: 'tool-result', seq: 3, time: 3, callId: 'call', callTime: 2, + call: { name: 'write', argsRaw: started.argsRaw }, content: [], isError: false, subCalls: [], + } + view.rerender() + expect(view.container.querySelector('[data-tool="write"]')).toBe(row) + expect(view.container.querySelector('[data-state="ok"]')).not.toBeNull() + }) + + it.each([ + ['custom_tool', GenericToolCard, 'Tool call', 'custom_tool · Inspect this file'], + ['subagent', DetailsRow, 'Create subagent', 'Inspect this file'], + ] as const)('%s retains its title and tool-name rule when arguments arrive', (name, Component, title, summary) => { + const props = preparation(name) + const view = render() + expect(view.getByText(title, { exact: true })).toBeTruthy() + expect(toolRowModel(name, props.block).summary).toBe(name === 'custom_tool' ? name : '') + expect(view.queryByText(name, { exact: true }) !== null).toBe(name === 'custom_tool') + expect(view.queryByRole('button')).toBeNull() + const started: StartedToolCall = { + phase: 'start', callId: 'call', name, turn: 1, step: 1, time: 2, subCalls: [], + argsRaw: '{"prompt":"Inspect this file"}', + } + view.rerender() + expect(view.getByText(title, { exact: true })).toBeTruthy() + expect(view.getByText(summary, { exact: true })).toBeTruthy() + expect(view.getByRole('button', { expanded: false })).toBeTruthy() + const result: ToolResultNode = { + kind: 'tool-result', seq: 3, time: 3, callId: 'call', callTime: 2, + call: { name, argsRaw: started.argsRaw }, content: [], isError: false, subCalls: [], + } + view.rerender() + expect(view.getByText(title, { exact: true })).toBeTruthy() + expect(view.getByText(summary, { exact: true })).toBeTruthy() + }) + + it('keeps specialized Bash session hooks outside its preparation branch', () => { + const useSessions = vi.fn(() => { throw new Error('Bash call details are unavailable during preparation') }) + const view = render() + expect(view.container.querySelector('[data-state="preparing"]')).not.toBeNull() + expect(useSessions).not.toHaveBeenCalled() + expect(view.queryByRole('button')).toBeNull() + }) +}) diff --git a/packages/client/ui-tool/tests/read-card.client.spec.tsx b/packages/client/ui-tool/tests/read-card.client.spec.tsx index 8932e66467..a5a9ffc0d4 100644 --- a/packages/client/ui-tool/tests/read-card.client.spec.tsx +++ b/packages/client/ui-tool/tests/read-card.client.spec.tsx @@ -7,7 +7,7 @@ import { Context } from '@deepseek-ai/cordis' import { bindSnapshotSelector, makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' -import type { RunningToolCall, ToolResultNode } from '@deepseek-ai/dsh-client-ui-chat/client' +import type { StartedToolCall, ToolResultNode } from '@deepseek-ai/dsh-client-ui-chat/client' import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' import { CHAT_READ_MAX_LINES, readCallLine, readCardModel } from '../src/client/tool/models/read-card-model.ts' @@ -54,8 +54,8 @@ const readMeta = (over?: Partial): ReadMetaFixture => ({ const readContent = (body = 'export const a = 1'): string => `src/a.ts\nfile\n\n${body}\n` -const running = (over?: Partial): RunningToolCall => ({ - callId: 'c1', name: 'read', argsRaw: ARGS, +const running = (over?: Partial): StartedToolCall => ({ + phase: 'start' as const, callId: 'c1', name: 'read', argsRaw: ARGS, turn: 1, step: 1, time: 1_000, subCalls: [], ...over, }) @@ -164,9 +164,9 @@ describe('readCallLine', () => { }) describe('GenericToolCard read body', () => { - const ownerProps = (block: RunningToolCall | ToolResultNode): GenericToolCardProps => ({ + const ownerProps = (block: StartedToolCall | ToolResultNode): GenericToolCardProps => ({ loadImage: vi.fn(() => Promise.reject(new Error('not used'))), - useDisclosure, callId: 'c1', toolName: 'read', block, openFile: vi.fn(), t, + useDisclosure, callId: 'c1', toolName: 'read', ...('kind' in block ? { phase: 'result' as const, block: block } : { phase: block.phase, block: block }), openFile: vi.fn(), t, }) /** The whole summary row is the expand toggle (ToolRow's unified interaction). */ @@ -188,7 +188,7 @@ describe('GenericToolCard read body', () => { it('a non-read tool renders the bare row with no read card', () => { const view = render( Promise.reject(new Error('not used'))), t, })} />) @@ -210,8 +210,8 @@ describe('ReadRow keyed toolview', () => { projectionsBySession: {}, }) - const rowProps = (block: RunningToolCall | ToolResultNode): Parameters[0] => ({ - useDisclosure, callId: 'c1', toolName: 'read', block, openFile: vi.fn(), + const rowProps = (block: StartedToolCall | ToolResultNode): Parameters[0] => ({ + useDisclosure, callId: 'c1', toolName: 'read', ...('kind' in block ? { phase: 'result' as const, block: block } : { phase: block.phase, block: block }), openFile: vi.fn(), sessionId: SID, useSessions: bindSnapshotSelector(list()), t, } as Parameters[0]) diff --git a/packages/client/ui-tool/tests/search-card.client.spec.tsx b/packages/client/ui-tool/tests/search-card.client.spec.tsx index 4b71b505b9..bbff0bae31 100644 --- a/packages/client/ui-tool/tests/search-card.client.spec.tsx +++ b/packages/client/ui-tool/tests/search-card.client.spec.tsx @@ -3,7 +3,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { useDisclosure } from '@deepseek-ai/dsh-client-ui-chat/src/client/chat/use-disclosure.ts' import { cleanup, fireEvent, render } from '@testing-library/react' -import type { RunningToolCall, ToolResultNode } from '@deepseek-ai/dsh-client-ui-chat/client' +import type { StartedToolCall, ToolResultNode } from '@deepseek-ai/dsh-client-ui-chat/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' @@ -62,8 +62,8 @@ const pathsMeta = (over?: Partial): PathsMeta => ({ shape: 'paths', paths: ['src/a.ts', 'src/b.ts'], truncated: false, total: 2, ...over, }) -const runningGrep = (over?: Partial): RunningToolCall => ({ - callId: 'c1', name: 'grep', argsRaw: GREP_ARGS, +const runningGrep = (over?: Partial): StartedToolCall => ({ + phase: 'start' as const, callId: 'c1', name: 'grep', argsRaw: GREP_ARGS, turn: 1, step: 1, time: 1_000, subCalls: [], ...over, }) @@ -175,9 +175,9 @@ describe('searchCardModel', () => { }) describe('chat row search body (GenericToolCard fallback)', () => { - const ownerProps = (block: RunningToolCall | ToolResultNode, toolName: string): GenericToolCardProps => ({ + const ownerProps = (block: StartedToolCall | ToolResultNode, toolName: string): GenericToolCardProps => ({ loadImage: vi.fn(() => Promise.reject(new Error('not used'))), - useDisclosure, callId: 'c1', toolName, block, openFile: vi.fn(), t, + useDisclosure, callId: 'c1', toolName, ...('kind' in block ? { phase: 'result' as const, block: block } : { phase: block.phase, block: block }), openFile: vi.fn(), t, }) /** The whole summary row is the expand toggle (ToolRow's unified interaction). */ const toggleRow = (view: { container: HTMLElement }) => { @@ -226,8 +226,8 @@ describe('chat row search body (GenericToolCard fallback)', () => { }) describe('SearchRow keyed card', () => { - const rowProps = (block: RunningToolCall | ToolResultNode, toolName: string): SearchRowProps => ({ - useDisclosure, callId: 'c1', toolName, block, openFile: vi.fn(), sessionId: SID, t, + const rowProps = (block: StartedToolCall | ToolResultNode, toolName: string): SearchRowProps => ({ + useDisclosure, callId: 'c1', toolName, ...('kind' in block ? { phase: 'result' as const, block: block } : { phase: block.phase, block: block }), openFile: vi.fn(), sessionId: SID, t, } as SearchRowProps) /** The whole summary row is the expand toggle (ToolRow's unified interaction). */ diff --git a/packages/client/ui-tool/tests/terminal-card.client.spec.tsx b/packages/client/ui-tool/tests/terminal-card.client.spec.tsx index 890e689e9f..2ee26d3c3d 100644 --- a/packages/client/ui-tool/tests/terminal-card.client.spec.tsx +++ b/packages/client/ui-tool/tests/terminal-card.client.spec.tsx @@ -5,7 +5,7 @@ import { useDisclosure } from '@deepseek-ai/dsh-client-ui-chat/src/client/chat/u import { cleanup, fireEvent, render } from '@testing-library/react' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' -import type { RunningToolCall, ToolResultNode } from '@deepseek-ai/dsh-client-ui-chat/client' +import type { StartedToolCall, ToolResultNode } from '@deepseek-ai/dsh-client-ui-chat/client' import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' @@ -45,8 +45,8 @@ const shellArgs = (over: Record = {}): string => JSON.stringify command: 'ls -la', description: 'List files', ...over, }) -const running = (over?: Partial): RunningToolCall => ({ - callId: 'c1', name: 'bash', argsRaw: ARGS, +const running = (over?: Partial): StartedToolCall => ({ + phase: 'start' as const, callId: 'c1', name: 'bash', argsRaw: ARGS, turn: 1, step: 1, time: 1_000, subCalls: [], ...over, }) @@ -292,9 +292,9 @@ describe('terminalCardModel', () => { }) describe('chat row terminal body', () => { - const ownerProps = (block: RunningToolCall | ToolResultNode): GenericToolCardProps => ({ + const ownerProps = (block: StartedToolCall | ToolResultNode): GenericToolCardProps => ({ loadImage: vi.fn(() => Promise.reject(new Error('not used'))), - useDisclosure, callId: 'c1', toolName: 'bash', block, openFile: vi.fn(), t, + useDisclosure, callId: 'c1', toolName: 'bash', ...('kind' in block ? { phase: 'result' as const, block: block } : { phase: block.phase, block: block }), openFile: vi.fn(), t, }) /** The whole summary row is the expand toggle (ToolRow's unified interaction). */ @@ -411,8 +411,8 @@ describe('BashRow terminal card', () => { projectionsBySession: {}, }) - const rowProps = (block: RunningToolCall | ToolResultNode): BashRowProps => ({ - useDisclosure, callId: 'c1', toolName: 'bash', block, openFile: vi.fn(), + const rowProps = (block: StartedToolCall | ToolResultNode): BashRowProps => ({ + useDisclosure, callId: 'c1', toolName: 'bash', ...('kind' in block ? { phase: 'result' as const, block: block } : { phase: block.phase, block: block }), openFile: vi.fn(), sessionId: SID, useSessions: bindSnapshotSelector(list()), t, } as BashRowProps) diff --git a/packages/client/ui-tool/tests/todo-row.client.spec.tsx b/packages/client/ui-tool/tests/todo-row.client.spec.tsx index 88ad0b153a..a2bb96fcc3 100644 --- a/packages/client/ui-tool/tests/todo-row.client.spec.tsx +++ b/packages/client/ui-tool/tests/todo-row.client.spec.tsx @@ -66,9 +66,9 @@ const resultNode = (argsRaw: string, over?: Partial): ToolResult content: [], isError: false, subCalls: [], ...over, }) -function rowProps(block: unknown): TodoRowProps { +function rowProps(block: TodoRowProps['block']): TodoRowProps { return { - useDisclosure, callId: 'c1', toolName: 'todo_write', block, + useDisclosure, callId: 'c1', toolName: 'todo_write', ...('kind' in block ? { phase: 'result' as const, block: block } : { phase: block.phase, block: block }), openFile: vi.fn(), sessionId: 's1', useSessions: () => undefined, @@ -97,7 +97,7 @@ describe('TodoRow', () => { it('omits the active clause when no item is in progress and reads running-call args', () => { const args = JSON.stringify({ todos: [{ content: 'x', status: 'completed' }] }) - render() + render() expect(screen.getByText('1/1 已完成')).toBeTruthy() }) @@ -110,7 +110,7 @@ describe('TodoRow', () => { it('keeps non-ok execution states visible through the shared row states', () => { const args = JSON.stringify({ todos: LIST }) - const running = render() + const running = render() expect(running.container.querySelector('[data-state="running"]')).not.toBeNull() expect(running.container.querySelector('[data-state="running"] svg')).not.toBeNull() running.unmount() @@ -122,12 +122,12 @@ describe('TodoRow', () => { const view = render() expect(view.container.querySelector('[data-state="error"]')).not.toBeNull() expect(view.container.querySelector('[data-state="error"] svg')).not.toBeNull() - expect(screen.getByText('todo_write · not json')).toBeTruthy() + expect(screen.getByText('not json')).toBeTruthy() }) it('falls back when parsed args carry no todos array', () => { render() - expect(screen.getByText('todo_write · {"other":1}')).toBeTruthy() + expect(screen.getByText('{"other":1}')).toBeTruthy() }) it('leading toggle expands a read-only checklist', () => { @@ -146,12 +146,12 @@ describe('TodoRow', () => { { label: 'null items', argsRaw: '{"todos":[null]}' }, ])('falls back to the generic summary on valid JSON with an invalid shape ($label)', ({ argsRaw }) => { render() - expect(screen.getByText(`todo_write · ${argsRaw}`)).toBeTruthy() + expect(screen.getByText(argsRaw)).toBeTruthy() }) it('window-truncated result falls back to the callId summary', () => { render() - expect(screen.getByText('todo_write · c1')).toBeTruthy() + expect(screen.getByText('c1')).toBeTruthy() }) it('injects the keyed toolview declaration directly', () => { diff --git a/packages/client/ui-tool/tests/tool-call-tree.client.spec.tsx b/packages/client/ui-tool/tests/tool-call-tree.client.spec.tsx index 9f8c1c4e3d..cdcced8391 100644 --- a/packages/client/ui-tool/tests/tool-call-tree.client.spec.tsx +++ b/packages/client/ui-tool/tests/tool-call-tree.client.spec.tsx @@ -61,6 +61,31 @@ function props( } describe('ToolCallTree', () => { + it('dispatches only the parent and changed child among 100 sibling calls', () => { + const children = Array.from({ length: 100 }, (_, index) => root(`child-${index}`, { name: 'custom_child', argsRaw: '{}' })) + const block = { ...root('parent', { name: 'custom_parent', argsRaw: '{}' }), subCalls: children } + const owners: ToolCallOwnerProps[] = [] + const initial = props(block, undefined, owners) + const view = render() + expect(owners).toHaveLength(101) + owners.length = 0 + const changed = { ...children[42]!, isError: true } + const next = { ...block, subCalls: children.map((child, index) => index === 42 ? changed : child) } + view.rerender() + expect(owners.map(owner => owner.callId)).toEqual(['parent', 'child-42']) + }) + + it('dispatches preparation through the existing keyed toolview with no argument fields', () => { + const owners: ToolCallOwnerProps[] = [] + const view = render() + expect(owners[0]).toMatchObject({ phase: 'preparing', callId: 'preparing', toolName: 'write' }) + expect(owners[0]?.block).not.toHaveProperty('argsRaw') + expect(view.container.querySelector('[data-chat-call-id="preparing"] [data-state="preparing"]')).not.toBeNull() + expect(view.queryByRole('button')).toBeNull() + }) + it('forwards one stable disclosure Hook to nested calls and resets only their open state', () => { const reset = createSnapshotStore(0) const useDisclosure = bindDisclosure(reset) @@ -139,13 +164,14 @@ describe('ToolCallTree', () => { it('dispatches a running call by its wire name and forwards inspect', () => { const owners: ToolCallOwnerProps[] = [] const block: ToolCallBlock = { - callId: 'running', name: 'bash', argsRaw: '{"command":"pwd"}', + phase: 'start' as const, callId: 'running', name: 'bash', argsRaw: '{"command":"pwd"}', turn: 1, step: 0, time: 1_000, subCalls: [], } const treeProps = props(block, undefined, owners) render() expect(owners[0]?.toolName).toBe('bash') + expect(owners[0]?.phase).toBe('start') const inspect = owners[0]?.inspect expect(inspect).toBeDefined() inspect?.() diff --git a/packages/client/ui-tool/tests/tool-fixtures.client.ts b/packages/client/ui-tool/tests/tool-fixtures.client.ts index 17d1c53443..75cd57b72d 100644 --- a/packages/client/ui-tool/tests/tool-fixtures.client.ts +++ b/packages/client/ui-tool/tests/tool-fixtures.client.ts @@ -3,7 +3,7 @@ import type { SessionLiveEventEntry } from '@deepseek-ai/dsh-api-session-control import { SessionSeq } from '@deepseek-ai/dsh-session/types' import { isJsonValue, type JsonValue } from '@deepseek-ai/dsh-util-values' import type { - ChatConversationViewNode, ChatSnapshot, ConversationNode, RunningToolCall, ToolResultNode, + ChatConversationViewNode, ChatSnapshot, ConversationNode, StartedToolCall, ToolResultNode, } from '@deepseek-ai/dsh-client-ui-chat/client' function jsonFixture(value: unknown): JsonValue { @@ -14,7 +14,7 @@ function jsonFixture(value: unknown): JsonValue { /** Build the canonical Chat slice consumed by Tool rows and details tests. */ export function toolChatSnapshot( settled: readonly ConversationNode[] = [], - running: readonly RunningToolCall[] = [], + running: readonly StartedToolCall[] = [], ): ChatSnapshot { const roots = [...settled.filter(node => node.kind === 'tool-result'), ...running] const nodes: ChatConversationViewNode[] = roots.map(root => ({ diff --git a/packages/client/ui-tool/tests/tool-row.client.spec.tsx b/packages/client/ui-tool/tests/tool-row.client.spec.tsx index 723f2eb359..f9338cac00 100644 --- a/packages/client/ui-tool/tests/tool-row.client.spec.tsx +++ b/packages/client/ui-tool/tests/tool-row.client.spec.tsx @@ -3,7 +3,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { useDisclosure } from '@deepseek-ai/dsh-client-ui-chat/src/client/chat/use-disclosure.ts' import { cleanup, fireEvent, render } from '@testing-library/react' -import type { RunningToolCall, ToolResultNode } from '@deepseek-ai/dsh-client-ui-chat/client' +import type { StartedToolCall, ToolResultNode } from '@deepseek-ai/dsh-client-ui-chat/client' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' import { localizeAutoReviewDenial, normalizeAutoReviewReason } from '../src/client/tool/models/auto-review-denial.ts' @@ -21,8 +21,8 @@ afterEach(() => { const t: GenericToolCardProps['t'] = makeTranslate(zh, commonZh) -const running = (over?: Partial): RunningToolCall => ({ - callId: 'c1', name: 'bash', argsRaw: '{"command":"ls -la","description":"List files"}', +const running = (over?: Partial): StartedToolCall => ({ + phase: 'start' as const, callId: 'c1', name: 'bash', argsRaw: '{"command":"ls -la","description":"List files"}', turn: 1, step: 1, time: 1_000, subCalls: [], ...over, }) @@ -494,9 +494,9 @@ describe('ToolRow', () => { }) describe('GenericToolCard', () => { - const props = (toolName: string, block: RunningToolCall | ToolResultNode): GenericToolCardProps => ({ + const props = (toolName: string, block: StartedToolCall | ToolResultNode): GenericToolCardProps => ({ loadImage: vi.fn(() => Promise.reject(new Error('not used'))), - useDisclosure, callId: 'c1', toolName, block, openFile: vi.fn(), t, + useDisclosure, callId: 'c1', toolName, ...('kind' in block ? { phase: 'result' as const, block: block } : { phase: block.phase, block: block }), openFile: vi.fn(), t, }) it('renders the classified variant row from the frozen slice', () => { @@ -522,7 +522,7 @@ describe('GenericToolCard', () => { it('unknown tools land on the others variant titled Tool call', () => { const view = render( - , + , ) expect(view.getByText('工具调用')).toBeTruthy() expect(view.container.querySelector('[data-variant="others"]')).not.toBeNull() diff --git a/packages/client/ui-tool/tests/toolview-slot.client.spec.tsx b/packages/client/ui-tool/tests/toolview-slot.client.spec.tsx index e1d220b53b..6262cf31e4 100644 --- a/packages/client/ui-tool/tests/toolview-slot.client.spec.tsx +++ b/packages/client/ui-tool/tests/toolview-slot.client.spec.tsx @@ -203,7 +203,7 @@ describe('keyed toolview hole through the real machinery', () => { // names its act and carries the package id rather than falling back to the // generic "Tool call · · " row. const rowText = (name: string) => view.container.querySelector(`[data-tool="${name}"]`)?.textContent - expect(rowText('cordis_runtime_inspect')).toContain('Inspect') + expect(rowText('cordis_runtime_inspect')).toContain('Query Cordis environment') expect(rowText('cordis_run')).toContain('Run Cordis Plugindyn-2') expect(rowText('cordis_stop')).toContain('Stop Cordis Plugindyn-2') expect(rowText('cordis_undefine')).toContain('Remove Cordis Plugindyn-2') diff --git a/packages/client/ui-tool/tests/toolview-type-chain.client.spec.tsx b/packages/client/ui-tool/tests/toolview-type-chain.client.spec.tsx index e3c80fc5e4..1882f24939 100644 --- a/packages/client/ui-tool/tests/toolview-type-chain.client.spec.tsx +++ b/packages/client/ui-tool/tests/toolview-type-chain.client.spec.tsx @@ -22,6 +22,12 @@ describe('toolview type negatives (compile-time; body never runs)', () => { } void overreaching const drifted = (props: ToolCallViewProps): ReactNode => { + if (props.phase === 'preparing') { + // @ts-expect-error preparation has no complete arguments + void props.block.argsRaw + // @ts-expect-error preparation has no result content + void props.block.content + } // @ts-expect-error the Tool call union has no pre-parsed args member void props.block.argsParsed return null diff --git a/packages/client/ui-tool/tests/web-card.client.spec.tsx b/packages/client/ui-tool/tests/web-card.client.spec.tsx index b7981f5056..1ead44b794 100644 --- a/packages/client/ui-tool/tests/web-card.client.spec.tsx +++ b/packages/client/ui-tool/tests/web-card.client.spec.tsx @@ -3,7 +3,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { useDisclosure } from '@deepseek-ai/dsh-client-ui-chat/src/client/chat/use-disclosure.ts' import { cleanup, fireEvent, render } from '@testing-library/react' -import type { RunningToolCall, ToolResultNode } from '@deepseek-ai/dsh-client-ui-chat/client' +import type { StartedToolCall, ToolResultNode } from '@deepseek-ai/dsh-client-ui-chat/client' import type { ToolCallOwnerProps } from '@deepseek-ai/dsh-client-ui-tool/client' import { IconGlobeOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives' import { webCardModel } from '../src/client/tool/models/web-card-model.ts' @@ -49,8 +49,8 @@ const fetchMeta = (over?: Partial): FetchMeta => ({ url: 'https://example.com/page', statusCode: 200, truncated: false, ...over, }) -const runningSearch = (over?: Partial): RunningToolCall => ({ - callId: 'c1', name: 'web_search', argsRaw: SEARCH_ARGS, +const runningSearch = (over?: Partial): StartedToolCall => ({ + phase: 'start' as const, callId: 'c1', name: 'web_search', argsRaw: SEARCH_ARGS, turn: 1, step: 1, time: 1_000, subCalls: [], ...over, }) @@ -124,13 +124,13 @@ describe('webCardModel', () => { }) describe('chat row web body', () => { - const ownerProps = (block: RunningToolCall | ToolResultNode, toolName: string): ToolCallOwnerProps => ({ - useDisclosure, callId: block.callId, toolName, block, openFile: vi.fn(), loadImage: vi.fn(() => Promise.reject(new Error('not used'))), + const ownerProps = (block: StartedToolCall | ToolResultNode, toolName: string): ToolCallOwnerProps => ({ + useDisclosure, callId: block.callId, toolName, ...('kind' in block ? { phase: 'result' as const, block: block } : { phase: block.phase, block: block }), openFile: vi.fn(), loadImage: vi.fn(() => Promise.reject(new Error('not used'))), }) // WebRow reads only toolName/block off the full runtime share plus the locale // seat; the standard kit is unused, so the cast supplies the owner slice and // `t` alone (as BashRow's tests do for the terminal card). - const rowProps = (block: RunningToolCall | ToolResultNode, toolName: string): Parameters[0] => + const rowProps = (block: StartedToolCall | ToolResultNode, toolName: string): Parameters[0] => ({ ...ownerProps(block, toolName), t } as unknown as Parameters[0]) /** The whole summary row is the expand toggle (ToolRow's unified interaction). */ diff --git a/packages/client/ui-trajectory/README.i18n.yaml b/packages/client/ui-trajectory/README.i18n.yaml index 94e9837fe7..e79506c955 100644 --- a/packages/client/ui-trajectory/README.i18n.yaml +++ b/packages/client/ui-trajectory/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-trajectory/README.md -README.md: d15b1c7c6d605ba1b73180566a84fe29ab08530c -README.zh.md: 87cbae17c53a112cc62054a6150df9565a7bd8c0 +README.md: 03553df4c69da9d5ec64416a8e65765d5dbcc904 +README.zh.md: 7ce33007c40e77e9b288df988c4e7b7597576646 diff --git a/packages/client/ui-trajectory/README.md b/packages/client/ui-trajectory/README.md index d15b1c7c6d..03553df4c6 100644 --- a/packages/client/ui-trajectory/README.md +++ b/packages/client/ui-trajectory/README.md @@ -55,6 +55,8 @@ The view is a pure projection: Trajectory-owned Definitions assemble business re Native and nested PTC Tool results retain their raw structured error details. Failed records show the error code in the ledger and the error name and code in the inspector. +Tool records begin at durable tool/call events and use complete arguments. Chat's transient preparing stage does not create Trajectory tool rows or alter historical tool timing. + A complete appended prompt without a loaded request header appears as a standalone system row; only its known text is available, with no inferred request options or tool catalog. Prepending its request history replaces that standalone presentation without duplicating the prompt. In-history system prompt changes compare against the most recent request state, including earlier prompt updates without a new request header. Each request retains the prompt and change that applied at its own position. Surface replacements, including compaction, restore the last nonempty surviving system prompt even without a new system event; an unloaded prompt remains unavailable until its page arrives. ### Virtual rows diff --git a/packages/client/ui-trajectory/README.zh.md b/packages/client/ui-trajectory/README.zh.md index 87cbae17c5..7ce33007c4 100644 --- a/packages/client/ui-trajectory/README.zh.md +++ b/packages/client/ui-trajectory/README.zh.md @@ -55,6 +55,8 @@ Trajectory 标签页让你以按轮次组织的事件记录表和交互式时间 原生与嵌套 PTC 工具结果保留原始结构化错误详情。失败记录在记录表中显示错误代码,在检查器中显示错误名称和代码。 +工具记录从持久 tool/call 事件开始,使用完整参数。Chat 的瞬态 preparing 阶段不创建 Trajectory 工具行,也不改变历史工具计时。 + 完整的追加提示词在请求头未加载时显示为独立系统行;仅提供已知文本,不推断请求选项或工具目录。补入其请求历史后,该独立展示被替代而不重复提示词。历史中的系统提示词变更与最近的请求状态比较,包括没有新请求头的先前提示词更新。每个请求保留其所在位置生效的提示词与变更。包括压缩在内的 surface 替换会恢复最后一个非空的存活系统提示词,即使没有新的系统事件;未加载的提示词在对应分页到达前仍不可用。 ### 虚拟行 diff --git a/packages/client/ui-trajectory/package.json b/packages/client/ui-trajectory/package.json index 766c6a5948..b9a31b6307 100644 --- a/packages/client/ui-trajectory/package.json +++ b/packages/client/ui-trajectory/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-trajectory", "description": "Trajectory event ledger with an interactive timing overview: pure-consumer plugin registering into the conversation ViewMap (no service)", - "version": "0.1.7-alpha.1", + "version": "0.1.7-alpha.2", "publishConfig": { "access": "public" }, @@ -43,30 +43,30 @@ }, "license": "MIT", "peerDependencies": { - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:~" }, "devDependencies": { - "@deepseek-ai/dsh-agent": "workspace:^", - "@deepseek-ai/dsh-client-locale": "workspace:^", - "@deepseek-ai/dsh-client-test-runtime": "workspace:^", - "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", - "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", - "@deepseek-ai/dsh-client-ui-slots": "workspace:^", - "@deepseek-ai/dsh-compaction": "workspace:^", - "@deepseek-ai/dsh-tools": "workspace:^", + "@deepseek-ai/dsh-agent": "workspace:*", + "@deepseek-ai/dsh-client-locale": "workspace:*", + "@deepseek-ai/dsh-client-test-runtime": "workspace:*", + "@deepseek-ai/dsh-client-ui-primitives": "workspace:*", + "@deepseek-ai/dsh-client-ui-conversation": "workspace:*", + "@deepseek-ai/dsh-client-ui-slots": "workspace:*", + "@deepseek-ai/dsh-compaction": "workspace:*", + "@deepseek-ai/dsh-tools": "workspace:*", "@types/react": "~18.3.1", "@types/react-dom": "~18.3.0", - "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/cordis": "workspace:~", "react": "^18.2.0", "react-dom": "^18.2.0", - "@deepseek-ai/dsh-client-store": "workspace:^", - "@deepseek-ai/dsh-client-ui-chat": "workspace:^", - "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/dsh-api-session-controller": "workspace:^", - "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", - "@deepseek-ai/dsh-client-ui-session": "workspace:^", - "@deepseek-ai/dsh-llm": "workspace:^", - "@deepseek-ai/dsh-attachment": "workspace:^", + "@deepseek-ai/dsh-client-store": "workspace:*", + "@deepseek-ai/dsh-client-ui-chat": "workspace:*", + "@deepseek-ai/dsh-session": "workspace:*", + "@deepseek-ai/dsh-api-session-controller": "workspace:*", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:*", + "@deepseek-ai/dsh-client-ui-session": "workspace:*", + "@deepseek-ai/dsh-llm": "workspace:*", + "@deepseek-ai/dsh-attachment": "workspace:*", "@tanstack/react-virtual": "^3.14.9", "diff": "^9.0.0" }, diff --git a/packages/client/ui-trajectory/src/client/layout.ts b/packages/client/ui-trajectory/src/client/layout.ts index e99b96a962..acbc06e6c0 100644 --- a/packages/client/ui-trajectory/src/client/layout.ts +++ b/packages/client/ui-trajectory/src/client/layout.ts @@ -168,6 +168,7 @@ export function deriveTrajectoryLayout( if (startedAt !== null) callStartById.set(result.callId, startedAt) } for (const call of runningCalls) { + if (call.phase === 'preparing') continue const startedAt = finiteTime(call.time) if (startedAt !== null) callStartById.set(call.callId, startedAt) } @@ -503,7 +504,7 @@ export function deriveTrajectoryLayout( const seenCalls = collectCallIds(turns) for (const call of runningCalls) { - if (seenCalls.has(call.callId)) continue + if (call.phase === 'preparing' || seenCalls.has(call.callId)) continue const laidList: LaidCell[] = [{ absTime: null, toolName: call.name, @@ -1020,6 +1021,7 @@ function expandSubCalls( const out: LaidCell[] = [] let index = startIndex for (const sub of subs) { + if (!('kind' in sub) && sub.phase === 'preparing') continue const settled = 'kind' in sub const resultPreview = settled ? summarizeResult(sub, t) : undefined const laid: LaidCell = { diff --git a/packages/client/ui-trajectory/src/client/trajectory-request-header-definition.ts b/packages/client/ui-trajectory/src/client/trajectory-request-header-definition.ts index 488dca8775..0fd17453aa 100644 --- a/packages/client/ui-trajectory/src/client/trajectory-request-header-definition.ts +++ b/packages/client/ui-trajectory/src/client/trajectory-request-header-definition.ts @@ -38,6 +38,7 @@ function trajectorySystemMessageDefinition(inspect: SystemPromptInspector): Conv ? { id: String(event.seq), role: 'start' } : null, start: (_context, match, reader) => { + if (match.event.type === 'assistant/live-chunk') throw new Error('system-message requires a durable event') const prior = reader.previous('trajectory-system-message')?.state const state = inspect(prior, match.event) const node = state.effective diff --git a/packages/client/ui-trajectory/src/client/trajectory-tool-definition.ts b/packages/client/ui-trajectory/src/client/trajectory-tool-definition.ts index 3d3c5d608a..901ba7b0ca 100644 --- a/packages/client/ui-trajectory/src/client/trajectory-tool-definition.ts +++ b/packages/client/ui-trajectory/src/client/trajectory-tool-definition.ts @@ -1,7 +1,7 @@ import type { Context } from '@deepseek-ai/cordis' import type { - ConversationMatch, ConversationNodeContext, ConversationNodeDefinition, RunningToolCall, - ToolCallBlock, ToolResultNode, + ConversationMatch, ConversationNodeContext, ConversationNodeDefinition, StartedToolCall, + ToolResultNode, } from '@deepseek-ai/dsh-client-ui-conversation/client' import type {} from '@deepseek-ai/dsh-tools/types' import { trajectoryNode } from './trajectory-definition-common.ts' @@ -11,6 +11,8 @@ import { trajectoryNode } from './trajectory-definition-common.ts' * architecture/2026-08-09-client-conversation-node-assembly.md. */ const MAX_DEPTH = 256 +type ToolCallBlock = StartedToolCall | ToolResultNode + interface ToolState { readonly rootId: string readonly calls: ReadonlyMap @@ -28,11 +30,12 @@ interface DispatchData { readonly content?: ToolResultNode['content'] } -function rootCall(match: ConversationMatch): RunningToolCall { +function rootCall(match: ConversationMatch): StartedToolCall { if (match.event.type !== 'tool/call') { throw new Error('trajectory-tool-call start requires tool/call') } return { + phase: 'start', callId: String(match.event.data.callId), name: match.event.data.name, argsRaw: match.event.data.arguments, @@ -45,7 +48,7 @@ function rootCall(match: ConversationMatch): RunningToolCall { function rootResult( match: ConversationMatch, - previous?: RunningToolCall, + previous?: StartedToolCall, ): ToolResultNode | undefined { if (match.event.type !== 'tool/result') return undefined const message = match.event.data.message @@ -74,8 +77,9 @@ function locationStep(match: ConversationMatch): number { return match.location.kind === 'step' ? match.location.step.step : 0 } -function childCall(match: ConversationMatch, data: DispatchData): RunningToolCall { +function childCall(match: ConversationMatch, data: DispatchData): StartedToolCall { return { + phase: 'start', callId: data.subCallId, parentCallId: data.parentCallId, name: data.name, diff --git a/packages/client/ui-trajectory/tests/layout.client.spec.tsx b/packages/client/ui-trajectory/tests/layout.client.spec.tsx index df27f38e65..94ee2c24ee 100644 --- a/packages/client/ui-trajectory/tests/layout.client.spec.tsx +++ b/packages/client/ui-trajectory/tests/layout.client.spec.tsx @@ -115,7 +115,7 @@ describe('deriveTrajectoryLayout', () => { nodes: [], partial: null, runningCalls: [{ - callId: 'r1', name: 'bash', argsRaw: '{"command":"pwd"}', + phase: 'start' as const, callId: 'r1', name: 'bash', argsRaw: '{"command":"pwd"}', turn: 1, step: 2, time: 9_000, subCalls: [], }], }) @@ -179,7 +179,7 @@ describe('deriveTrajectoryLayout', () => { nodes: [], partial: { ...partial, blocks: [] }, runningCalls: [{ - callId: 'c1', name: 'bash', argsRaw: '{"command":"pwd"}', + phase: 'start' as const, callId: 'c1', name: 'bash', argsRaw: '{"command":"pwd"}', turn: 1, step: 1, time: 9_000, subCalls: [], }], }) @@ -540,7 +540,7 @@ describe('run_code sub-dispatch cells', () => { it('a running (unsettled) sub-call renders a subtool cell with blank time', () => { const running = { - callId: 'p1:code:1', name: 'grep', argsRaw: '{"pattern":"x"}', + phase: 'start' as const, callId: 'p1:code:1', name: 'grep', argsRaw: '{"pattern":"x"}', turn: 0, step: 0, time: 6_400, subCalls: [], } const turns = deriveTrajectoryLayout({ nodes: withSubCalls([running]), partial: null, runningCalls: [] }) diff --git a/packages/client/ui-trajectory/tests/snapshot-builder.client.spec.ts b/packages/client/ui-trajectory/tests/snapshot-builder.client.spec.ts index c26486f7dd..93c8228ef6 100644 --- a/packages/client/ui-trajectory/tests/snapshot-builder.client.spec.ts +++ b/packages/client/ui-trajectory/tests/snapshot-builder.client.spec.ts @@ -216,7 +216,7 @@ describe('TrajectorySnapshotBuilder', () => { contribution('tool', 7, { kind: 'tool', root: { - callId: 'call-edit', + phase: 'start' as const, callId: 'call-edit', name: 'edit', argsRaw: '{}', turn: 1, diff --git a/packages/client/ui-trajectory/tests/views.client.spec.tsx b/packages/client/ui-trajectory/tests/views.client.spec.tsx index 64e33e0cb8..a66dead29b 100644 --- a/packages/client/ui-trajectory/tests/views.client.spec.tsx +++ b/packages/client/ui-trajectory/tests/views.client.spec.tsx @@ -1428,7 +1428,7 @@ describe('TrajectoryView state', () => { content: [], isError: false, subCalls: [{ - callId: 'hidden-child', parentCallId: 'hidden-root', name: 'bash', argsRaw: '{}', + phase: 'start' as const, callId: 'hidden-child', parentCallId: 'hidden-root', name: 'bash', argsRaw: '{}', turn: 1, step: 1, time: 3, subCalls: [], }], }, 'hidden-child'], diff --git a/packages/client/ui-user-questions/package.json b/packages/client/ui-user-questions/package.json index f84d156b5a..af7574df7c 100644 --- a/packages/client/ui-user-questions/package.json +++ b/packages/client/ui-user-questions/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-user-questions", "description": "Web ask_user_question composer takeover and plan-review presentation UI", - "version": "0.1.7-alpha.1", + "version": "0.1.7-alpha.2", "publishConfig": { "access": "public" }, @@ -44,30 +44,30 @@ }, "license": "MIT", "peerDependencies": { - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:~" }, "devDependencies": { - "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-agent": "workspace:^", - "@deepseek-ai/dsh-api-remotes": "workspace:^", - "@deepseek-ai/dsh-api-session-controller": "workspace:^", - "@deepseek-ai/dsh-client-locale": "workspace:^", - "@deepseek-ai/dsh-client-store": "workspace:^", - "@deepseek-ai/dsh-system-prompt": "workspace:^", - "@deepseek-ai/dsh-tools": "workspace:^", - "@deepseek-ai/dsh-user-questions": "workspace:^", + "@deepseek-ai/cordis": "workspace:~", + "@deepseek-ai/dsh-agent": "workspace:*", + "@deepseek-ai/dsh-api-remotes": "workspace:*", + "@deepseek-ai/dsh-api-session-controller": "workspace:*", + "@deepseek-ai/dsh-client-locale": "workspace:*", + "@deepseek-ai/dsh-client-store": "workspace:*", + "@deepseek-ai/dsh-system-prompt": "workspace:*", + "@deepseek-ai/dsh-tools": "workspace:*", + "@deepseek-ai/dsh-user-questions": "workspace:*", "@types/react": "~18.3.1", - "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", - "@deepseek-ai/dsh-client-ui-slots": "workspace:^", - "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", - "@deepseek-ai/dsh-client-ui-session": "workspace:^", - "@deepseek-ai/dsh-typert-protocol": "workspace:^", + "@deepseek-ai/dsh-client-ui-primitives": "workspace:*", + "@deepseek-ai/dsh-client-ui-slots": "workspace:*", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:*", + "@deepseek-ai/dsh-client-ui-session": "workspace:*", + "@deepseek-ai/dsh-typert-protocol": "workspace:*", "react": "^18.2.0", - "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", - "@deepseek-ai/dsh-client-connection": "workspace:^", - "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-client-ui-conversation": "workspace:*", + "@deepseek-ai/dsh-client-connection": "workspace:*", + "@deepseek-ai/dsh-session": "workspace:*", "clsx": "^2.0.0", - "@deepseek-ai/dsh-llm": "workspace:^" + "@deepseek-ai/dsh-llm": "workspace:*" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-workflow-run/package.json b/packages/client/ui-workflow-run/package.json index 8fb7adbf80..95243a24ea 100644 --- a/packages/client/ui-workflow-run/package.json +++ b/packages/client/ui-workflow-run/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-workflow-run", "description": "Durable workflow-run Conversation Node and nested member disclosure for dsh web", - "version": "0.1.7-alpha.1", + "version": "0.1.7-alpha.2", "publishConfig": { "access": "public" }, @@ -50,25 +50,25 @@ ], "license": "MIT", "peerDependencies": { - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:~" }, "devDependencies": { - "@deepseek-ai/dsh-api-session-controller": "workspace:^", - "@deepseek-ai/dsh-client-locale": "workspace:^", - "@deepseek-ai/dsh-client-store": "workspace:^", - "@deepseek-ai/dsh-client-test-runtime": "workspace:^", - "@deepseek-ai/dsh-client-ui-chat": "workspace:^", - "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", - "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", - "@deepseek-ai/dsh-client-ui-slots": "workspace:^", - "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", - "@deepseek-ai/dsh-client-ui-session": "workspace:^", - "@deepseek-ai/dsh-client-ui-workspace": "workspace:^", - "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/dsh-tool-workflow": "workspace:^", - "@deepseek-ai/dsh-workflow": "workspace:^", + "@deepseek-ai/dsh-api-session-controller": "workspace:*", + "@deepseek-ai/dsh-client-locale": "workspace:*", + "@deepseek-ai/dsh-client-store": "workspace:*", + "@deepseek-ai/dsh-client-test-runtime": "workspace:*", + "@deepseek-ai/dsh-client-ui-chat": "workspace:*", + "@deepseek-ai/dsh-client-ui-conversation": "workspace:*", + "@deepseek-ai/dsh-client-ui-primitives": "workspace:*", + "@deepseek-ai/dsh-client-ui-slots": "workspace:*", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:*", + "@deepseek-ai/dsh-client-ui-session": "workspace:*", + "@deepseek-ai/dsh-client-ui-workspace": "workspace:*", + "@deepseek-ai/dsh-session": "workspace:*", + "@deepseek-ai/dsh-tool-workflow": "workspace:*", + "@deepseek-ai/dsh-workflow": "workspace:*", "@types/react": "~18.3.1", - "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/cordis": "workspace:~", "react": "^18.2.0" } } diff --git a/packages/client/ui-workspace/package.json b/packages/client/ui-workspace/package.json index 7bbb525f01..b604aee9ff 100644 --- a/packages/client/ui-workspace/package.json +++ b/packages/client/ui-workspace/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-workspace", "description": "Workspace picker plugin: one WorkspacePicker registered into the sidebar and empty-state workspace slots", - "version": "0.1.7-alpha.1", + "version": "0.1.7-alpha.2", "publishConfig": { "access": "public" }, @@ -48,32 +48,32 @@ }, "license": "MIT", "peerDependencies": { - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:~" }, "devDependencies": { - "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-api-remotes": "workspace:^", - "@deepseek-ai/dsh-agent": "workspace:^", - "@deepseek-ai/dsh-api-session-controller": "workspace:^", - "@deepseek-ai/dsh-api-workspace-controller": "workspace:^", - "@deepseek-ai/dsh-client-connection": "workspace:^", - "@deepseek-ai/dsh-client-locale": "workspace:^", - "@deepseek-ai/dsh-client-store": "workspace:^", - "@deepseek-ai/dsh-client-test-runtime": "workspace:^", - "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", - "@deepseek-ai/dsh-client-ui-layout": "workspace:^", - "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", - "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", - "@deepseek-ai/dsh-client-ui-session": "workspace:^", - "@deepseek-ai/dsh-client-ui-sidebar": "workspace:^", - "@deepseek-ai/dsh-client-ui-slots": "workspace:^", - "@deepseek-ai/dsh-jobs": "workspace:^", - "@deepseek-ai/dsh-schedule": "workspace:^", - "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/dsh-subagent": "workspace:^", - "@deepseek-ai/dsh-typert-protocol": "workspace:^", - "@deepseek-ai/dsh-util-values": "workspace:^", - "@deepseek-ai/dsh-util-workspace-path": "workspace:^", + "@deepseek-ai/cordis": "workspace:~", + "@deepseek-ai/dsh-api-remotes": "workspace:*", + "@deepseek-ai/dsh-agent": "workspace:*", + "@deepseek-ai/dsh-api-session-controller": "workspace:*", + "@deepseek-ai/dsh-api-workspace-controller": "workspace:*", + "@deepseek-ai/dsh-client-connection": "workspace:*", + "@deepseek-ai/dsh-client-locale": "workspace:*", + "@deepseek-ai/dsh-client-store": "workspace:*", + "@deepseek-ai/dsh-client-test-runtime": "workspace:*", + "@deepseek-ai/dsh-client-ui-conversation": "workspace:*", + "@deepseek-ai/dsh-client-ui-layout": "workspace:*", + "@deepseek-ai/dsh-client-ui-primitives": "workspace:*", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:*", + "@deepseek-ai/dsh-client-ui-session": "workspace:*", + "@deepseek-ai/dsh-client-ui-sidebar": "workspace:*", + "@deepseek-ai/dsh-client-ui-slots": "workspace:*", + "@deepseek-ai/dsh-jobs": "workspace:*", + "@deepseek-ai/dsh-schedule": "workspace:*", + "@deepseek-ai/dsh-session": "workspace:*", + "@deepseek-ai/dsh-subagent": "workspace:*", + "@deepseek-ai/dsh-typert-protocol": "workspace:*", + "@deepseek-ai/dsh-util-values": "workspace:*", + "@deepseek-ai/dsh-util-workspace-path": "workspace:*", "@types/react": "~18.3.1", "clsx": "^2.0.0", "react": "^18.2.0" diff --git a/packages/client/ui-workspace/src/client/locales.ts b/packages/client/ui-workspace/src/client/locales.ts index 8f35978565..7aeccaf86d 100644 --- a/packages/client/ui-workspace/src/client/locales.ts +++ b/packages/client/ui-workspace/src/client/locales.ts @@ -62,8 +62,8 @@ export const zh = { 'archive.confirm.desc': '“{title}”仍有正在进行的工作。归档会先停止这些工作;之后可在侧栏“显示已归档”中恢复会话,被停止的工作不会自动继续。', 'archive.confirm.activity': '将被停止的工作', 'archive.confirm.turn': '进行中的回合', - 'archive.confirm.subagents.one': '{n} 个运行中的子代理:{names}', - 'archive.confirm.subagents.other': '{n} 个运行中的子代理:{names}', + 'archive.confirm.subagents.one': '{n} 个运行中的子智能体:{names}', + 'archive.confirm.subagents.other': '{n} 个运行中的子智能体:{names}', 'archive.confirm.jobs.one': '{n} 个后台任务:{names}', 'archive.confirm.jobs.other': '{n} 个后台任务:{names}', 'archive.confirm.schedules.one': '{n} 条定时提醒:{names}', @@ -90,8 +90,8 @@ export const zh = { 'actions.newSession': '新会话', 'actions.newSession.aria': '在“{name}”中新建会话', 'status.running': '进行中', - 'status.subagentsRunning.one': '{n} 个子代理运行中', - 'status.subagentsRunning.other': '{n} 个子代理运行中', + 'status.subagentsRunning.one': '{n} 个子智能体运行中', + 'status.subagentsRunning.other': '{n} 个子智能体运行中', 'status.idle': '空闲', 'status.waitingApproval': '等待审批', 'status.planReview': '计划待审', diff --git a/packages/client/ui-workspace/tests/rows.client.spec.tsx b/packages/client/ui-workspace/tests/rows.client.spec.tsx index 41e1b6e037..290dc0c44f 100644 --- a/packages/client/ui-workspace/tests/rows.client.spec.tsx +++ b/packages/client/ui-workspace/tests/rows.client.spec.tsx @@ -411,12 +411,12 @@ describe('workspace browser rows', () => { render() const row = screen.getByRole('treeitem') expect(row.querySelector('[data-state="ongoing"]')).not.toBeNull() - expect(screen.getByText('2 个子代理运行中')).toBeTruthy() + expect(screen.getByText('2 个子智能体运行中')).toBeTruthy() expect(screen.queryByText('进行中')).toBeNull() fireEvent.pointerEnter(row.parentElement as HTMLElement) act(() => { vi.advanceTimersByTime(800) }) - expect(screen.getAllByText('2 个子代理运行中')).toHaveLength(2) + expect(screen.getAllByText('2 个子智能体运行中')).toHaveLength(2) } finally { vi.useRealTimers() } @@ -433,12 +433,12 @@ describe('workspace browser rows', () => { const row = screen.getByRole('treeitem') expect(row.querySelectorAll('[data-state="ongoing"]')).toHaveLength(1) expect(screen.getByText('进行中')).toBeTruthy() - expect(screen.getByText('1 个子代理运行中')).toBeTruthy() + expect(screen.getByText('1 个子智能体运行中')).toBeTruthy() fireEvent.pointerEnter(row.parentElement as HTMLElement) act(() => { vi.advanceTimersByTime(800) }) expect(screen.getAllByText('进行中')).toHaveLength(2) - expect(screen.getAllByText('1 个子代理运行中')).toHaveLength(2) + expect(screen.getAllByText('1 个子智能体运行中')).toHaveLength(2) } finally { vi.useRealTimers() } @@ -454,7 +454,7 @@ describe('workspace browser rows', () => { expect(row.querySelector('[data-state="warning"]')).not.toBeNull() expect(row.querySelector('[data-state="ongoing"]')).toBeNull() expect(screen.getByText('等待回答')).toBeTruthy() - expect(screen.getByText('1 个子代理运行中')).toBeTruthy() + expect(screen.getByText('1 个子智能体运行中')).toBeTruthy() }) it('shows the green done dot on a finished search result row', () => { diff --git a/packages/client/ui-workspace/tests/session-actions.client.spec.tsx b/packages/client/ui-workspace/tests/session-actions.client.spec.tsx index fb594bc44d..bd96b3337d 100644 --- a/packages/client/ui-workspace/tests/session-actions.client.spec.tsx +++ b/packages/client/ui-workspace/tests/session-actions.client.spec.tsx @@ -379,7 +379,7 @@ describe('SessionArchiveConfirmDialog', () => { const lines = [...screen.getByRole('list', { name: '将被停止的工作' }).querySelectorAll('li')].map(li => li.textContent) expect(lines).toEqual([ '进行中的回合', - '2 个运行中的子代理:reviewer、child-2', + '2 个运行中的子智能体:reviewer、child-2', '1 个后台任务:pnpm run build', '1 条定时提醒:check the build', ]) diff --git a/packages/client/web/package.json b/packages/client/web/package.json index dbdceed9cc..03c4707cd8 100644 --- a/packages/client/web/package.json +++ b/packages/client/web/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-web", "description": "Web boot kernel: static module table, Cordis loader, framework-free boot page, and UI-renderer handoff", - "version": "0.1.7-alpha.1", + "version": "0.1.7-alpha.2", "publishConfig": { "access": "public" }, @@ -27,23 +27,23 @@ }, "license": "MIT", "devDependencies": { - "@deepseek-ai/cordis-plugin-loader": "workspace:^", - "@deepseek-ai/dsh-client-modules": "workspace:^", - "@deepseek-ai/dsh-client-store": "workspace:^", - "@deepseek-ai/dsh-client-ui-dockkit": "workspace:^", - "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", - "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", - "@deepseek-ai/dsh-client-ui-slots": "workspace:^", + "@deepseek-ai/cordis-plugin-loader": "workspace:~", + "@deepseek-ai/dsh-client-modules": "workspace:*", + "@deepseek-ai/dsh-client-store": "workspace:*", + "@deepseek-ai/dsh-client-ui-dockkit": "workspace:*", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:*", + "@deepseek-ai/dsh-client-ui-primitives": "workspace:*", + "@deepseek-ai/dsh-client-ui-slots": "workspace:*", "@types/react": "~18.3.1", "@types/react-dom": "~18.3.0", - "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/cordis": "workspace:~", "react": "^18.2.0", "react-dom": "^18.2.0", "typescript": "^6.0.3", - "@deepseek-ai/dsh-host-webserver": "workspace:^" + "@deepseek-ai/dsh-host-webserver": "workspace:*" }, "peerDependencies": { - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:~" }, "files": [ "lib/index.js", diff --git a/packages/compaction/command-compact/package.json b/packages/compaction/command-compact/package.json index cb2280b0e6..f1124114e6 100644 --- a/packages/compaction/command-compact/package.json +++ b/packages/compaction/command-compact/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-command-compact", "description": "Human-facing slash command for explicit session compaction", - "version": "0.1.7-alpha.1", + "version": "0.1.7-alpha.2", "publishConfig": { "access": "public" }, @@ -27,18 +27,18 @@ ], "license": "MIT", "peerDependencies": { - "@deepseek-ai/dsh-commands": "workspace:^", - "@deepseek-ai/dsh-compaction": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/dsh-commands": "workspace:*", + "@deepseek-ai/dsh-compaction": "workspace:*", + "@deepseek-ai/cordis": "workspace:~" }, "devDependencies": { - "@deepseek-ai/cordis-plugin-include": "workspace:^", - "@deepseek-ai/cordis-plugin-loader": "workspace:^", - "@deepseek-ai/dsh-agent": "workspace:^", - "@deepseek-ai/dsh-commands": "workspace:^", - "@deepseek-ai/dsh-compaction": "workspace:^", - "@deepseek-ai/dsh-llm": "workspace:^", - "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis-plugin-include": "workspace:~", + "@deepseek-ai/cordis-plugin-loader": "workspace:~", + "@deepseek-ai/dsh-agent": "workspace:*", + "@deepseek-ai/dsh-commands": "workspace:*", + "@deepseek-ai/dsh-compaction": "workspace:*", + "@deepseek-ai/dsh-llm": "workspace:*", + "@deepseek-ai/dsh-session": "workspace:*", + "@deepseek-ai/cordis": "workspace:~" } } diff --git a/packages/compaction/compaction-basic/package.json b/packages/compaction/compaction-basic/package.json index a7d9bf1b37..028c88f678 100644 --- a/packages/compaction/compaction-basic/package.json +++ b/packages/compaction/compaction-basic/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-compaction-basic", "description": "Token-meter-driven compaction policy and LLM summarization backend for the DeepSeek Harness", - "version": "0.1.7-alpha.1", + "version": "0.1.7-alpha.2", "publishConfig": { "access": "public" }, @@ -27,14 +27,14 @@ ], "license": "MIT", "peerDependencies": { - "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-agent": "workspace:^", - "@deepseek-ai/dsh-commands": "workspace:^", - "@deepseek-ai/dsh-compaction": "workspace:^", - "@deepseek-ai/dsh-compaction-tool-result-pruner": "workspace:^", - "@deepseek-ai/dsh-llm": "workspace:^", - "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/dsh-token-meter": "workspace:^" + "@deepseek-ai/cordis": "workspace:~", + "@deepseek-ai/dsh-agent": "workspace:*", + "@deepseek-ai/dsh-commands": "workspace:*", + "@deepseek-ai/dsh-compaction": "workspace:*", + "@deepseek-ai/dsh-compaction-tool-result-pruner": "workspace:*", + "@deepseek-ai/dsh-llm": "workspace:*", + "@deepseek-ai/dsh-session": "workspace:*", + "@deepseek-ai/dsh-token-meter": "workspace:*" }, "peerDependenciesMeta": { "@deepseek-ai/dsh-compaction-tool-result-pruner": { @@ -42,26 +42,26 @@ } }, "dependencies": { - "@deepseek-ai/dsh-util-values": "workspace:^", - "@deepseek-ai/schemastery": "workspace:^" + "@deepseek-ai/dsh-util-values": "workspace:*", + "@deepseek-ai/schemastery": "workspace:~" }, "devDependencies": { - "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/cordis-plugin-include": "workspace:^", - "@deepseek-ai/cordis-plugin-loader": "workspace:^", - "@deepseek-ai/dsh-agent": "workspace:^", - "@deepseek-ai/dsh-agent-loop": "workspace:^", - "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^", - "@deepseek-ai/dsh-commands": "workspace:^", - "@deepseek-ai/dsh-compaction": "workspace:^", - "@deepseek-ai/dsh-compaction-tool-result-pruner": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/dsh-llm": "workspace:^", - "@deepseek-ai/dsh-llm-retry": "workspace:^", - "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/dsh-session-projection": "workspace:^", - "@deepseek-ai/dsh-token-meter": "workspace:^", - "@deepseek-ai/dsh-tools": "workspace:^", - "@deepseek-ai/dsh-compaction-image-offload": "workspace:^" + "@deepseek-ai/cordis": "workspace:~", + "@deepseek-ai/cordis-plugin-include": "workspace:~", + "@deepseek-ai/cordis-plugin-loader": "workspace:~", + "@deepseek-ai/dsh-agent": "workspace:*", + "@deepseek-ai/dsh-agent-loop": "workspace:*", + "@deepseek-ai/dsh-agent-loop-testkit": "workspace:*", + "@deepseek-ai/dsh-commands": "workspace:*", + "@deepseek-ai/dsh-compaction": "workspace:*", + "@deepseek-ai/dsh-compaction-tool-result-pruner": "workspace:*", + "@deepseek-ai/dsh-invariants": "workspace:*", + "@deepseek-ai/dsh-llm": "workspace:*", + "@deepseek-ai/dsh-llm-retry": "workspace:*", + "@deepseek-ai/dsh-session": "workspace:*", + "@deepseek-ai/dsh-session-projection": "workspace:*", + "@deepseek-ai/dsh-token-meter": "workspace:*", + "@deepseek-ai/dsh-tools": "workspace:*", + "@deepseek-ai/dsh-compaction-image-offload": "workspace:*" } } diff --git a/packages/compaction/compaction-image-offload/package.json b/packages/compaction/compaction-image-offload/package.json index 9e44e450ff..1053f6cbd4 100644 --- a/packages/compaction/compaction-image-offload/package.json +++ b/packages/compaction/compaction-image-offload/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-compaction-image-offload", "description": "Durable image offload for image-capable routes: replace over-budget request images with placeholders and retry", - "version": "0.1.7-alpha.1", + "version": "0.1.7-alpha.2", "publishConfig": { "access": "public" }, @@ -31,27 +31,27 @@ ], "license": "MIT", "peerDependencies": { - "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-agent": "workspace:^", - "@deepseek-ai/dsh-compaction": "workspace:^", - "@deepseek-ai/dsh-llm": "workspace:^", - "@deepseek-ai/dsh-session": "workspace:^" + "@deepseek-ai/cordis": "workspace:~", + "@deepseek-ai/dsh-agent": "workspace:*", + "@deepseek-ai/dsh-compaction": "workspace:*", + "@deepseek-ai/dsh-llm": "workspace:*", + "@deepseek-ai/dsh-session": "workspace:*" }, "devDependencies": { - "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-agent": "workspace:^", - "@deepseek-ai/dsh-agent-loop": "workspace:^", - "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^", - "@deepseek-ai/dsh-attachment": "workspace:^", - "@deepseek-ai/dsh-compaction": "workspace:^", - "@deepseek-ai/dsh-compaction-basic": "workspace:^", - "@deepseek-ai/dsh-llm": "workspace:^", - "@deepseek-ai/dsh-llm-deepseek": "workspace:^", - "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/dsh-session-projection": "workspace:^", - "@deepseek-ai/dsh-token-meter": "workspace:^" + "@deepseek-ai/cordis": "workspace:~", + "@deepseek-ai/dsh-agent": "workspace:*", + "@deepseek-ai/dsh-agent-loop": "workspace:*", + "@deepseek-ai/dsh-agent-loop-testkit": "workspace:*", + "@deepseek-ai/dsh-attachment": "workspace:*", + "@deepseek-ai/dsh-compaction": "workspace:*", + "@deepseek-ai/dsh-compaction-basic": "workspace:*", + "@deepseek-ai/dsh-llm": "workspace:*", + "@deepseek-ai/dsh-llm-deepseek": "workspace:*", + "@deepseek-ai/dsh-session": "workspace:*", + "@deepseek-ai/dsh-session-projection": "workspace:*", + "@deepseek-ai/dsh-token-meter": "workspace:*" }, "dependencies": { - "@deepseek-ai/dsh-util-values": "workspace:^" + "@deepseek-ai/dsh-util-values": "workspace:*" } } diff --git a/packages/compaction/compaction-tool-result-pruner/package.json b/packages/compaction/compaction-tool-result-pruner/package.json index 20f2fdb52d..7d30cded68 100644 --- a/packages/compaction/compaction-tool-result-pruner/package.json +++ b/packages/compaction/compaction-tool-result-pruner/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-compaction-tool-result-pruner", "description": "Replay-safe model-free head/middle/tail pruning for tool-result surface nodes", - "version": "0.1.7-alpha.1", + "version": "0.1.7-alpha.2", "publishConfig": { "access": "public" }, @@ -27,26 +27,26 @@ ], "license": "MIT", "peerDependencies": { - "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-compaction": "workspace:^", - "@deepseek-ai/dsh-llm": "workspace:^", - "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/dsh-token-meter": "workspace:^" + "@deepseek-ai/cordis": "workspace:~", + "@deepseek-ai/dsh-compaction": "workspace:*", + "@deepseek-ai/dsh-llm": "workspace:*", + "@deepseek-ai/dsh-session": "workspace:*", + "@deepseek-ai/dsh-token-meter": "workspace:*" }, "dependencies": { - "@deepseek-ai/dsh-util-values": "workspace:^", - "@deepseek-ai/schemastery": "workspace:^" + "@deepseek-ai/dsh-util-values": "workspace:*", + "@deepseek-ai/schemastery": "workspace:~" }, "devDependencies": { - "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/cordis-plugin-include": "workspace:^", - "@deepseek-ai/cordis-plugin-loader": "workspace:^", - "@deepseek-ai/dsh-compaction": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/dsh-llm": "workspace:^", - "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/dsh-session-projection": "workspace:^", - "@deepseek-ai/dsh-token-meter": "workspace:^", - "@deepseek-ai/dsh-compaction-image-offload": "workspace:^" + "@deepseek-ai/cordis": "workspace:~", + "@deepseek-ai/cordis-plugin-include": "workspace:~", + "@deepseek-ai/cordis-plugin-loader": "workspace:~", + "@deepseek-ai/dsh-compaction": "workspace:*", + "@deepseek-ai/dsh-invariants": "workspace:*", + "@deepseek-ai/dsh-llm": "workspace:*", + "@deepseek-ai/dsh-session": "workspace:*", + "@deepseek-ai/dsh-session-projection": "workspace:*", + "@deepseek-ai/dsh-token-meter": "workspace:*", + "@deepseek-ai/dsh-compaction-image-offload": "workspace:*" } } diff --git a/packages/compaction/compaction/package.json b/packages/compaction/compaction/package.json index 9abbe6e1a1..067a42d1a1 100644 --- a/packages/compaction/compaction/package.json +++ b/packages/compaction/compaction/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-compaction", "description": "Abstract compaction service seam (ctx.compaction) for the DeepSeek Harness", - "version": "0.1.7-alpha.1", + "version": "0.1.7-alpha.2", "publishConfig": { "access": "public" }, @@ -41,19 +41,19 @@ ], "license": "MIT", "peerDependencies": { - "@deepseek-ai/dsh-brand": "workspace:^", - "@deepseek-ai/dsh-commands": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/dsh-llm": "workspace:^", - "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/dsh-brand": "workspace:*", + "@deepseek-ai/dsh-commands": "workspace:*", + "@deepseek-ai/dsh-invariants": "workspace:*", + "@deepseek-ai/dsh-llm": "workspace:*", + "@deepseek-ai/dsh-session": "workspace:*", + "@deepseek-ai/cordis": "workspace:~" }, "devDependencies": { - "@deepseek-ai/dsh-brand": "workspace:^", - "@deepseek-ai/dsh-commands": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/dsh-llm": "workspace:^", - "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/dsh-brand": "workspace:*", + "@deepseek-ai/dsh-commands": "workspace:*", + "@deepseek-ai/dsh-invariants": "workspace:*", + "@deepseek-ai/dsh-llm": "workspace:*", + "@deepseek-ai/dsh-session": "workspace:*", + "@deepseek-ai/cordis": "workspace:~" } } diff --git a/packages/computer-use/computer-use/package.json b/packages/computer-use/computer-use/package.json index 06e8ffe1ce..5892b9f69a 100644 --- a/packages/computer-use/computer-use/package.json +++ b/packages/computer-use/computer-use/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-computer-use", "description": "Exclusive named computer-use provider registration", - "version": "0.1.7-alpha.1", + "version": "0.1.7-alpha.2", "publishConfig": { "access": "public" }, @@ -32,11 +32,11 @@ ], "license": "MIT", "peerDependencies": { - "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-brand": "workspace:^" + "@deepseek-ai/cordis": "workspace:~", + "@deepseek-ai/dsh-brand": "workspace:*" }, "devDependencies": { - "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-brand": "workspace:^" + "@deepseek-ai/cordis": "workspace:~", + "@deepseek-ai/dsh-brand": "workspace:*" } } diff --git a/packages/context/agent-instructions/package.json b/packages/context/agent-instructions/package.json index f1aadb8161..90cfff97ab 100644 --- a/packages/context/agent-instructions/package.json +++ b/packages/context/agent-instructions/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-agent-instructions", "description": "Workspace context loader for AGENTS.md/CLAUDE.md instruction files", - "version": "0.1.7-alpha.1", + "version": "0.1.7-alpha.2", "publishConfig": { "access": "public" }, @@ -27,34 +27,34 @@ ], "license": "MIT", "peerDependencies": { - "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-agent": "workspace:^", - "@deepseek-ai/dsh-fs": "workspace:^", - "@deepseek-ai/dsh-home-paths": "workspace:^", - "@deepseek-ai/dsh-llm": "workspace:^", - "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/dsh-session-projection": "workspace:^", - "@deepseek-ai/dsh-tools": "workspace:^" + "@deepseek-ai/cordis": "workspace:~", + "@deepseek-ai/dsh-agent": "workspace:*", + "@deepseek-ai/dsh-fs": "workspace:*", + "@deepseek-ai/dsh-home-paths": "workspace:*", + "@deepseek-ai/dsh-llm": "workspace:*", + "@deepseek-ai/dsh-session": "workspace:*", + "@deepseek-ai/dsh-session-projection": "workspace:*", + "@deepseek-ai/dsh-tools": "workspace:*" }, "dependencies": { - "@deepseek-ai/dsh-util-values": "workspace:^", - "@deepseek-ai/schemastery": "workspace:^" + "@deepseek-ai/dsh-util-values": "workspace:*", + "@deepseek-ai/schemastery": "workspace:~" }, "devDependencies": { - "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/cordis-plugin-loader": "workspace:^", - "@deepseek-ai/dsh-agent": "workspace:^", - "@deepseek-ai/dsh-agent-loop": "workspace:^", - "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^", - "@deepseek-ai/dsh-fs": "workspace:^", - "@deepseek-ai/dsh-fs-local": "workspace:^", - "@deepseek-ai/dsh-home-paths": "workspace:^", - "@deepseek-ai/dsh-llm": "workspace:^", - "@deepseek-ai/dsh-llm-deepseek": "workspace:^", - "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/dsh-session-projection": "workspace:^", - "@deepseek-ai/dsh-system-prompt": "workspace:^", - "@deepseek-ai/dsh-tool-fs": "workspace:^", - "@deepseek-ai/dsh-tools": "workspace:^" + "@deepseek-ai/cordis": "workspace:~", + "@deepseek-ai/cordis-plugin-loader": "workspace:~", + "@deepseek-ai/dsh-agent": "workspace:*", + "@deepseek-ai/dsh-agent-loop": "workspace:*", + "@deepseek-ai/dsh-agent-loop-testkit": "workspace:*", + "@deepseek-ai/dsh-fs": "workspace:*", + "@deepseek-ai/dsh-fs-local": "workspace:*", + "@deepseek-ai/dsh-home-paths": "workspace:*", + "@deepseek-ai/dsh-llm": "workspace:*", + "@deepseek-ai/dsh-llm-deepseek": "workspace:*", + "@deepseek-ai/dsh-session": "workspace:*", + "@deepseek-ai/dsh-session-projection": "workspace:*", + "@deepseek-ai/dsh-system-prompt": "workspace:*", + "@deepseek-ai/dsh-tool-fs": "workspace:*", + "@deepseek-ai/dsh-tools": "workspace:*" } } diff --git a/packages/context/file-reference-local/package.json b/packages/context/file-reference-local/package.json index 3ba90a6f9c..a42d833f65 100644 --- a/packages/context/file-reference-local/package.json +++ b/packages/context/file-reference-local/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-file-reference-local", "description": "Local-filesystem ctx.fileReferences provider with bounded fuzzy indexes", - "version": "0.1.7-alpha.1", + "version": "0.1.7-alpha.2", "publishConfig": { "access": "public" }, @@ -32,20 +32,20 @@ ], "license": "MIT", "dependencies": { - "@deepseek-ai/schemastery": "workspace:^" + "@deepseek-ai/schemastery": "workspace:~" }, "peerDependencies": { - "@deepseek-ai/dsh-agent": "workspace:^", - "@deepseek-ai/dsh-file-reference": "workspace:^", - "@deepseek-ai/dsh-system-prompt": "workspace:^", - "@deepseek-ai/dsh-tools": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/dsh-agent": "workspace:*", + "@deepseek-ai/dsh-file-reference": "workspace:*", + "@deepseek-ai/dsh-system-prompt": "workspace:*", + "@deepseek-ai/dsh-tools": "workspace:*", + "@deepseek-ai/cordis": "workspace:~" }, "devDependencies": { - "@deepseek-ai/dsh-agent": "workspace:^", - "@deepseek-ai/dsh-file-reference": "workspace:^", - "@deepseek-ai/dsh-system-prompt": "workspace:^", - "@deepseek-ai/dsh-tools": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/dsh-agent": "workspace:*", + "@deepseek-ai/dsh-file-reference": "workspace:*", + "@deepseek-ai/dsh-system-prompt": "workspace:*", + "@deepseek-ai/dsh-tools": "workspace:*", + "@deepseek-ai/cordis": "workspace:~" } } diff --git a/packages/context/file-reference/package.json b/packages/context/file-reference/package.json index 4e62eec197..6b7f48d3e5 100644 --- a/packages/context/file-reference/package.json +++ b/packages/context/file-reference/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-file-reference", "description": "File-reference discovery contract and shared @file grammar", - "version": "0.1.7-alpha.1", + "version": "0.1.7-alpha.2", "publishConfig": { "access": "public" }, @@ -36,11 +36,11 @@ ], "license": "MIT", "peerDependencies": { - "@deepseek-ai/dsh-agent": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/dsh-agent": "workspace:*", + "@deepseek-ai/cordis": "workspace:~" }, "devDependencies": { - "@deepseek-ai/dsh-agent": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/dsh-agent": "workspace:*", + "@deepseek-ai/cordis": "workspace:~" } } diff --git a/packages/context/session-reference/package.json b/packages/context/session-reference/package.json index 63b6a472e2..3427d2a847 100644 --- a/packages/context/session-reference/package.json +++ b/packages/context/session-reference/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-reference", "description": "Cross-session snapshot references and durable untrusted model context (ctx.sessionReferenceResolver)", - "version": "0.1.7-alpha.1", + "version": "0.1.7-alpha.2", "publishConfig": { "access": "public" }, @@ -44,26 +44,26 @@ ], "license": "MIT", "dependencies": { - "@deepseek-ai/dsh-brand": "workspace:^", - "@deepseek-ai/dsh-util-values": "workspace:^", - "@deepseek-ai/schemastery": "workspace:^", + "@deepseek-ai/dsh-brand": "workspace:*", + "@deepseek-ai/dsh-util-values": "workspace:*", + "@deepseek-ai/schemastery": "workspace:~", "zod": "^4.4.3" }, "peerDependencies": { - "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-agent": "workspace:^", - "@deepseek-ai/dsh-compaction": "workspace:^", - "@deepseek-ai/dsh-llm": "workspace:^", - "@deepseek-ai/dsh-output-retention": "workspace:^", - "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/dsh-session-projection": "workspace:^", - "@deepseek-ai/dsh-session-projection-cache": "workspace:^", - "@deepseek-ai/dsh-session-query": "workspace:^", - "@deepseek-ai/dsh-session-title": "workspace:^", - "@deepseek-ai/dsh-spill": "workspace:^", - "@deepseek-ai/dsh-subagent": "workspace:^", - "@deepseek-ai/dsh-system-prompt": "workspace:^", - "@deepseek-ai/dsh-typert-protocol": "workspace:^" + "@deepseek-ai/cordis": "workspace:~", + "@deepseek-ai/dsh-agent": "workspace:*", + "@deepseek-ai/dsh-compaction": "workspace:*", + "@deepseek-ai/dsh-llm": "workspace:*", + "@deepseek-ai/dsh-output-retention": "workspace:*", + "@deepseek-ai/dsh-session": "workspace:*", + "@deepseek-ai/dsh-session-projection": "workspace:*", + "@deepseek-ai/dsh-session-projection-cache": "workspace:*", + "@deepseek-ai/dsh-session-query": "workspace:*", + "@deepseek-ai/dsh-session-title": "workspace:*", + "@deepseek-ai/dsh-spill": "workspace:*", + "@deepseek-ai/dsh-subagent": "workspace:*", + "@deepseek-ai/dsh-system-prompt": "workspace:*", + "@deepseek-ai/dsh-typert-protocol": "workspace:*" }, "peerDependenciesMeta": { "@deepseek-ai/dsh-spill": { @@ -74,26 +74,26 @@ } }, "devDependencies": { - "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/cordis-plugin-include": "workspace:^", - "@deepseek-ai/cordis-plugin-loader": "workspace:^", - "@deepseek-ai/dsh-agent": "workspace:^", - "@deepseek-ai/dsh-compaction": "workspace:^", - "@deepseek-ai/dsh-fs-local": "workspace:^", - "@deepseek-ai/dsh-llm": "workspace:^", - "@deepseek-ai/dsh-output-retention": "workspace:^", - "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/dsh-session-projection": "workspace:^", - "@deepseek-ai/dsh-session-projection-cache": "workspace:^", - "@deepseek-ai/dsh-session-query": "workspace:^", - "@deepseek-ai/dsh-session-query-sqlite": "workspace:^", - "@deepseek-ai/dsh-session-title": "workspace:^", - "@deepseek-ai/dsh-spill": "workspace:^", - "@deepseek-ai/dsh-subagent": "workspace:^", - "@deepseek-ai/dsh-spill-local": "workspace:^", - "@deepseek-ai/dsh-system-prompt": "workspace:^", - "@deepseek-ai/dsh-tool-fs": "workspace:^", - "@deepseek-ai/dsh-tools": "workspace:^", - "@deepseek-ai/dsh-typert-protocol": "workspace:^" + "@deepseek-ai/cordis": "workspace:~", + "@deepseek-ai/cordis-plugin-include": "workspace:~", + "@deepseek-ai/cordis-plugin-loader": "workspace:~", + "@deepseek-ai/dsh-agent": "workspace:*", + "@deepseek-ai/dsh-compaction": "workspace:*", + "@deepseek-ai/dsh-fs-local": "workspace:*", + "@deepseek-ai/dsh-llm": "workspace:*", + "@deepseek-ai/dsh-output-retention": "workspace:*", + "@deepseek-ai/dsh-session": "workspace:*", + "@deepseek-ai/dsh-session-projection": "workspace:*", + "@deepseek-ai/dsh-session-projection-cache": "workspace:*", + "@deepseek-ai/dsh-session-query": "workspace:*", + "@deepseek-ai/dsh-session-query-sqlite": "workspace:*", + "@deepseek-ai/dsh-session-title": "workspace:*", + "@deepseek-ai/dsh-spill": "workspace:*", + "@deepseek-ai/dsh-subagent": "workspace:*", + "@deepseek-ai/dsh-spill-local": "workspace:*", + "@deepseek-ai/dsh-system-prompt": "workspace:*", + "@deepseek-ai/dsh-tool-fs": "workspace:*", + "@deepseek-ai/dsh-tools": "workspace:*", + "@deepseek-ai/dsh-typert-protocol": "workspace:*" } } diff --git a/packages/context/time-context/package.json b/packages/context/time-context/package.json index fbe6b3e1a6..85b9acf6d3 100644 --- a/packages/context/time-context/package.json +++ b/packages/context/time-context/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-time-context", "description": "Opt-in durable per-step context with the current time and elapsed time", - "version": "0.1.7-alpha.1", + "version": "0.1.7-alpha.2", "publishConfig": { "access": "public" }, @@ -32,34 +32,34 @@ ], "license": "MIT", "dependencies": { - "@deepseek-ai/dsh-util-values": "workspace:^", - "@deepseek-ai/schemastery": "workspace:^", + "@deepseek-ai/dsh-util-values": "workspace:*", + "@deepseek-ai/schemastery": "workspace:~", "zod": "^4.4.3" }, "peerDependencies": { - "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-agent": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/dsh-llm": "workspace:^", - "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/dsh-session-projection": "workspace:^" + "@deepseek-ai/cordis": "workspace:~", + "@deepseek-ai/dsh-agent": "workspace:*", + "@deepseek-ai/dsh-invariants": "workspace:*", + "@deepseek-ai/dsh-llm": "workspace:*", + "@deepseek-ai/dsh-session": "workspace:*", + "@deepseek-ai/dsh-session-projection": "workspace:*" }, "devDependencies": { - "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-agent": "workspace:^", - "@deepseek-ai/dsh-agent-loop": "workspace:^", - "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^", - "@deepseek-ai/dsh-app-boot": "workspace:^", - "@deepseek-ai/dsh-bash-local": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/dsh-llm": "workspace:^", - "@deepseek-ai/dsh-loader-smoke": "workspace:^", - "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/dsh-session-checkpoint-policy": "workspace:^", - "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", - "@deepseek-ai/dsh-session-projection": "workspace:^", - "@deepseek-ai/dsh-subprocess-local": "workspace:^", - "@deepseek-ai/dsh-system-prompt": "workspace:^", - "@deepseek-ai/dsh-tools": "workspace:^" + "@deepseek-ai/cordis": "workspace:~", + "@deepseek-ai/dsh-agent": "workspace:*", + "@deepseek-ai/dsh-agent-loop": "workspace:*", + "@deepseek-ai/dsh-agent-loop-testkit": "workspace:*", + "@deepseek-ai/dsh-app-boot": "workspace:*", + "@deepseek-ai/dsh-bash-local": "workspace:*", + "@deepseek-ai/dsh-invariants": "workspace:*", + "@deepseek-ai/dsh-llm": "workspace:*", + "@deepseek-ai/dsh-loader-smoke": "workspace:*", + "@deepseek-ai/dsh-session": "workspace:*", + "@deepseek-ai/dsh-session-checkpoint-policy": "workspace:*", + "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:*", + "@deepseek-ai/dsh-session-projection": "workspace:*", + "@deepseek-ai/dsh-subprocess-local": "workspace:*", + "@deepseek-ai/dsh-system-prompt": "workspace:*", + "@deepseek-ai/dsh-tools": "workspace:*" } } diff --git a/packages/context/tmux-context/package.json b/packages/context/tmux-context/package.json index a55f52143a..40638291d6 100644 --- a/packages/context/tmux-context/package.json +++ b/packages/context/tmux-context/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tmux-context", "description": "Opt-in durable per-step context with this agent's tmux pane and window location", - "version": "0.1.7-alpha.1", + "version": "0.1.7-alpha.2", "publishConfig": { "access": "public" }, @@ -27,24 +27,24 @@ ], "license": "MIT", "dependencies": { - "@deepseek-ai/schemastery": "workspace:^", + "@deepseek-ai/schemastery": "workspace:~", "zod": "^4.4.3" }, "peerDependencies": { - "@deepseek-ai/dsh-agent": "workspace:^", - "@deepseek-ai/dsh-shell": "workspace:^", - "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-session-projection": "workspace:^" + "@deepseek-ai/dsh-agent": "workspace:*", + "@deepseek-ai/dsh-shell": "workspace:*", + "@deepseek-ai/dsh-session": "workspace:*", + "@deepseek-ai/cordis": "workspace:~", + "@deepseek-ai/dsh-session-projection": "workspace:*" }, "devDependencies": { - "@deepseek-ai/dsh-agent": "workspace:^", - "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^", - "@deepseek-ai/dsh-shell": "workspace:^", - "@deepseek-ai/dsh-llm": "workspace:^", - "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/dsh-system-prompt": "workspace:^", - "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-session-projection": "workspace:^" + "@deepseek-ai/dsh-agent": "workspace:*", + "@deepseek-ai/dsh-agent-loop-testkit": "workspace:*", + "@deepseek-ai/dsh-shell": "workspace:*", + "@deepseek-ai/dsh-llm": "workspace:*", + "@deepseek-ai/dsh-session": "workspace:*", + "@deepseek-ai/dsh-system-prompt": "workspace:*", + "@deepseek-ai/cordis": "workspace:~", + "@deepseek-ai/dsh-session-projection": "workspace:*" } } diff --git a/packages/core/agent-default-model/package.json b/packages/core/agent-default-model/package.json index 3c178f4b2f..e27550b897 100644 --- a/packages/core/agent-default-model/package.json +++ b/packages/core/agent-default-model/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-agent-default-model", "description": "Default model selection shared by Agent entry points", - "version": "0.1.7-alpha.1", + "version": "0.1.7-alpha.2", "publishConfig": { "access": "public" }, @@ -27,18 +27,18 @@ ], "license": "MIT", "dependencies": { - "@deepseek-ai/schemastery": "workspace:^", - "@deepseek-ai/dsh-config-editor": "workspace:^" + "@deepseek-ai/schemastery": "workspace:~", + "@deepseek-ai/dsh-config-editor": "workspace:*" }, "peerDependencies": { - "@deepseek-ai/dsh-agent": "workspace:^", - "@deepseek-ai/dsh-llm": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/dsh-agent": "workspace:*", + "@deepseek-ai/dsh-llm": "workspace:*", + "@deepseek-ai/cordis": "workspace:~" }, "devDependencies": { - "@deepseek-ai/dsh-agent": "workspace:^", - "@deepseek-ai/dsh-llm": "workspace:^", - "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-settings": "workspace:^" + "@deepseek-ai/dsh-agent": "workspace:*", + "@deepseek-ai/dsh-llm": "workspace:*", + "@deepseek-ai/cordis": "workspace:~", + "@deepseek-ai/dsh-settings": "workspace:*" } } diff --git a/packages/core/agent-loop/package.json b/packages/core/agent-loop/package.json index 775a18287a..ddfb2645ac 100644 --- a/packages/core/agent-loop/package.json +++ b/packages/core/agent-loop/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-agent-loop", "description": "The concrete agent loop plugin for the DeepSeek Harness", - "version": "0.1.7-alpha.1", + "version": "0.1.7-alpha.2", "publishConfig": { "access": "public" }, @@ -31,35 +31,35 @@ ], "license": "MIT", "peerDependencies": { - "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-agent": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/dsh-llm": "workspace:^", - "@deepseek-ai/dsh-scope": "workspace:^", - "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/dsh-session-persistence": "workspace:^", - "@deepseek-ai/dsh-session-projection": "workspace:^", - "@deepseek-ai/dsh-system-prompt": "workspace:^", - "@deepseek-ai/dsh-tools": "workspace:^" + "@deepseek-ai/cordis": "workspace:~", + "@deepseek-ai/dsh-agent": "workspace:*", + "@deepseek-ai/dsh-invariants": "workspace:*", + "@deepseek-ai/dsh-llm": "workspace:*", + "@deepseek-ai/dsh-scope": "workspace:*", + "@deepseek-ai/dsh-session": "workspace:*", + "@deepseek-ai/dsh-session-persistence": "workspace:*", + "@deepseek-ai/dsh-session-projection": "workspace:*", + "@deepseek-ai/dsh-system-prompt": "workspace:*", + "@deepseek-ai/dsh-tools": "workspace:*" }, "dependencies": { - "@deepseek-ai/dsh-brand": "workspace:^", - "@deepseek-ai/dsh-util-values": "workspace:^", - "@deepseek-ai/schemastery": "workspace:^", + "@deepseek-ai/dsh-brand": "workspace:*", + "@deepseek-ai/dsh-util-values": "workspace:*", + "@deepseek-ai/schemastery": "workspace:~", "zod": "^4.4.3" }, "devDependencies": { - "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-llm-pi-ai": "workspace:^", - "@deepseek-ai/dsh-agent": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/dsh-llm": "workspace:^", - "@deepseek-ai/dsh-scope": "workspace:^", - "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/dsh-session-persistence": "workspace:^", - "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", - "@deepseek-ai/dsh-session-projection": "workspace:^", - "@deepseek-ai/dsh-system-prompt": "workspace:^", - "@deepseek-ai/dsh-tools": "workspace:^" + "@deepseek-ai/cordis": "workspace:~", + "@deepseek-ai/dsh-llm-pi-ai": "workspace:*", + "@deepseek-ai/dsh-agent": "workspace:*", + "@deepseek-ai/dsh-invariants": "workspace:*", + "@deepseek-ai/dsh-llm": "workspace:*", + "@deepseek-ai/dsh-scope": "workspace:*", + "@deepseek-ai/dsh-session": "workspace:*", + "@deepseek-ai/dsh-session-persistence": "workspace:*", + "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:*", + "@deepseek-ai/dsh-session-projection": "workspace:*", + "@deepseek-ai/dsh-system-prompt": "workspace:*", + "@deepseek-ai/dsh-tools": "workspace:*" } } diff --git a/packages/core/agent-tool-presentation/package.json b/packages/core/agent-tool-presentation/package.json index 766045b25d..c0ad0084a0 100644 --- a/packages/core/agent-tool-presentation/package.json +++ b/packages/core/agent-tool-presentation/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-agent-tool-presentation", "description": "Agent-plane presentation selector: composes one agent's tools as PTC mode, native, or both", - "version": "0.1.7-alpha.1", + "version": "0.1.7-alpha.2", "publishConfig": { "access": "public" }, @@ -27,19 +27,19 @@ ], "license": "MIT", "dependencies": { - "@deepseek-ai/schemastery": "workspace:^" + "@deepseek-ai/schemastery": "workspace:~" }, "peerDependencies": { - "@deepseek-ai/dsh-tools": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/dsh-tools": "workspace:*", + "@deepseek-ai/cordis": "workspace:~" }, "devDependencies": { - "@deepseek-ai/dsh-agent": "workspace:^", - "@deepseek-ai/dsh-ptc-runtime": "workspace:^", - "@deepseek-ai/dsh-scope": "workspace:^", - "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/dsh-system-prompt": "workspace:^", - "@deepseek-ai/dsh-tools": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/dsh-agent": "workspace:*", + "@deepseek-ai/dsh-ptc-runtime": "workspace:*", + "@deepseek-ai/dsh-scope": "workspace:*", + "@deepseek-ai/dsh-session": "workspace:*", + "@deepseek-ai/dsh-system-prompt": "workspace:*", + "@deepseek-ai/dsh-tools": "workspace:*", + "@deepseek-ai/cordis": "workspace:~" } } diff --git a/packages/core/agent/package.json b/packages/core/agent/package.json index 328e14dc26..0e39e03c71 100644 --- a/packages/core/agent/package.json +++ b/packages/core/agent/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-agent", "description": "Agent interface, registry, initiator scope, and event vocabulary for the DeepSeek Harness", - "version": "0.1.7-alpha.1", + "version": "0.1.7-alpha.2", "publishConfig": { "access": "public" }, @@ -37,16 +37,16 @@ ], "license": "MIT", "peerDependencies": { - "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/dsh-llm": "workspace:^", - "@deepseek-ai/dsh-scope": "workspace:^", - "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/dsh-session-projection": "workspace:^", - "@deepseek-ai/dsh-system-prompt": "workspace:^", - "@deepseek-ai/dsh-typert-protocol": "workspace:^", - "@deepseek-ai/dsh-util-values": "workspace:^", - "@deepseek-ai/dsh-workspace": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/dsh-invariants": "workspace:*", + "@deepseek-ai/dsh-llm": "workspace:*", + "@deepseek-ai/dsh-scope": "workspace:*", + "@deepseek-ai/dsh-session": "workspace:*", + "@deepseek-ai/dsh-session-projection": "workspace:*", + "@deepseek-ai/dsh-system-prompt": "workspace:*", + "@deepseek-ai/dsh-typert-protocol": "workspace:*", + "@deepseek-ai/dsh-util-values": "workspace:*", + "@deepseek-ai/dsh-workspace": "workspace:*", + "@deepseek-ai/cordis": "workspace:~" }, "peerDependenciesMeta": { "@deepseek-ai/dsh-workspace": { @@ -54,16 +54,16 @@ } }, "devDependencies": { - "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/dsh-llm": "workspace:^", - "@deepseek-ai/dsh-scope": "workspace:^", - "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/dsh-session-projection": "workspace:^", - "@deepseek-ai/dsh-system-prompt": "workspace:^", - "@deepseek-ai/dsh-typert-protocol": "workspace:^", - "@deepseek-ai/dsh-typert-registry": "workspace:^", - "@deepseek-ai/dsh-util-values": "workspace:^", - "@deepseek-ai/dsh-workspace": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/dsh-invariants": "workspace:*", + "@deepseek-ai/dsh-llm": "workspace:*", + "@deepseek-ai/dsh-scope": "workspace:*", + "@deepseek-ai/dsh-session": "workspace:*", + "@deepseek-ai/dsh-session-projection": "workspace:*", + "@deepseek-ai/dsh-system-prompt": "workspace:*", + "@deepseek-ai/dsh-typert-protocol": "workspace:*", + "@deepseek-ai/dsh-typert-registry": "workspace:*", + "@deepseek-ai/dsh-util-values": "workspace:*", + "@deepseek-ai/dsh-workspace": "workspace:*", + "@deepseek-ai/cordis": "workspace:~" } } diff --git a/packages/core/scope/package.json b/packages/core/scope/package.json index 6800bec82a..56ec1246c6 100644 --- a/packages/core/scope/package.json +++ b/packages/core/scope/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-scope", "description": "Scoped-context registration primitive (scope tags, scope-filtered event dispatch) for the DeepSeek Harness", - "version": "0.1.7-alpha.1", + "version": "0.1.7-alpha.2", "publishConfig": { "access": "public" }, @@ -32,11 +32,11 @@ ], "license": "MIT", "peerDependencies": { - "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/dsh-invariants": "workspace:*", + "@deepseek-ai/cordis": "workspace:~" }, "devDependencies": { - "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/dsh-invariants": "workspace:*", + "@deepseek-ai/cordis": "workspace:~" } } diff --git a/packages/core/session/package.json b/packages/core/session/package.json index 1280425853..3cce03e091 100644 --- a/packages/core/session/package.json +++ b/packages/core/session/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session", "description": "Event-sourced session store for the DeepSeek Harness", - "version": "0.1.7-alpha.1", + "version": "0.1.7-alpha.2", "publishConfig": { "access": "public" }, @@ -45,19 +45,19 @@ ], "license": "MIT", "peerDependencies": { - "@deepseek-ai/dsh-scope": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/dsh-scope": "workspace:*", + "@deepseek-ai/cordis": "workspace:~" }, "devDependencies": { - "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/dsh-scope": "workspace:^", - "@deepseek-ai/dsh-typert-protocol": "workspace:^", - "@deepseek-ai/dsh-typert-registry": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/dsh-invariants": "workspace:*", + "@deepseek-ai/dsh-scope": "workspace:*", + "@deepseek-ai/dsh-typert-protocol": "workspace:*", + "@deepseek-ai/dsh-typert-registry": "workspace:*", + "@deepseek-ai/cordis": "workspace:~" }, "dependencies": { - "@deepseek-ai/dsh-llm": "workspace:^", - "@deepseek-ai/dsh-brand": "workspace:^", - "@deepseek-ai/dsh-util-values": "workspace:^" + "@deepseek-ai/dsh-llm": "workspace:*", + "@deepseek-ai/dsh-brand": "workspace:*", + "@deepseek-ai/dsh-util-values": "workspace:*" } } diff --git a/packages/core/system-prompt/package.json b/packages/core/system-prompt/package.json index 2437ea15ba..0ab266f3a6 100644 --- a/packages/core/system-prompt/package.json +++ b/packages/core/system-prompt/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-system-prompt", "description": "System prompt assembly registry for the DeepSeek Harness", - "version": "0.1.7-alpha.1", + "version": "0.1.7-alpha.2", "publishConfig": { "access": "public" }, @@ -32,18 +32,18 @@ ], "license": "MIT", "peerDependencies": { - "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/dsh-llm": "workspace:^", - "@deepseek-ai/dsh-scope": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/dsh-invariants": "workspace:*", + "@deepseek-ai/dsh-llm": "workspace:*", + "@deepseek-ai/dsh-scope": "workspace:*", + "@deepseek-ai/cordis": "workspace:~" }, "dependencies": { - "@deepseek-ai/schemastery": "workspace:^" + "@deepseek-ai/schemastery": "workspace:~" }, "devDependencies": { - "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/dsh-llm": "workspace:^", - "@deepseek-ai/dsh-scope": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/dsh-invariants": "workspace:*", + "@deepseek-ai/dsh-llm": "workspace:*", + "@deepseek-ai/dsh-scope": "workspace:*", + "@deepseek-ai/cordis": "workspace:~" } } diff --git a/packages/core/tools/package.json b/packages/core/tools/package.json index 805e36f866..b5839d2e50 100644 --- a/packages/core/tools/package.json +++ b/packages/core/tools/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tools", "description": "Tool registry and execution pipeline for the DeepSeek Harness", - "version": "0.1.7-alpha.1", + "version": "0.1.7-alpha.2", "publishConfig": { "access": "public" }, @@ -41,35 +41,35 @@ ], "license": "MIT", "peerDependencies": { - "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-agent": "workspace:^", - "@deepseek-ai/dsh-ptc-runtime": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/dsh-llm": "workspace:^", - "@deepseek-ai/dsh-scope": "workspace:^", - "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/dsh-system-prompt": "workspace:^", - "@deepseek-ai/dsh-user-approval": "workspace:^", - "@deepseek-ai/dsh-sandbox": "workspace:^", - "@deepseek-ai/dsh-sandbox-policy": "workspace:^" + "@deepseek-ai/cordis": "workspace:~", + "@deepseek-ai/dsh-agent": "workspace:*", + "@deepseek-ai/dsh-ptc-runtime": "workspace:*", + "@deepseek-ai/dsh-invariants": "workspace:*", + "@deepseek-ai/dsh-llm": "workspace:*", + "@deepseek-ai/dsh-scope": "workspace:*", + "@deepseek-ai/dsh-session": "workspace:*", + "@deepseek-ai/dsh-system-prompt": "workspace:*", + "@deepseek-ai/dsh-user-approval": "workspace:*", + "@deepseek-ai/dsh-sandbox": "workspace:*", + "@deepseek-ai/dsh-sandbox-policy": "workspace:*" }, "dependencies": { - "@deepseek-ai/dsh-brand": "workspace:^", - "@deepseek-ai/dsh-util-values": "workspace:^", - "@deepseek-ai/schemastery": "workspace:^" + "@deepseek-ai/dsh-brand": "workspace:*", + "@deepseek-ai/dsh-util-values": "workspace:*", + "@deepseek-ai/schemastery": "workspace:~" }, "devDependencies": { - "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-agent": "workspace:^", - "@deepseek-ai/dsh-ptc-runtime": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/dsh-llm": "workspace:^", - "@deepseek-ai/dsh-scope": "workspace:^", - "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/dsh-session-projection": "workspace:^", - "@deepseek-ai/dsh-system-prompt": "workspace:^", - "@deepseek-ai/dsh-user-approval": "workspace:^", - "@deepseek-ai/dsh-sandbox": "workspace:^", - "@deepseek-ai/dsh-sandbox-policy": "workspace:^" + "@deepseek-ai/cordis": "workspace:~", + "@deepseek-ai/dsh-agent": "workspace:*", + "@deepseek-ai/dsh-ptc-runtime": "workspace:*", + "@deepseek-ai/dsh-invariants": "workspace:*", + "@deepseek-ai/dsh-llm": "workspace:*", + "@deepseek-ai/dsh-scope": "workspace:*", + "@deepseek-ai/dsh-session": "workspace:*", + "@deepseek-ai/dsh-session-projection": "workspace:*", + "@deepseek-ai/dsh-system-prompt": "workspace:*", + "@deepseek-ai/dsh-user-approval": "workspace:*", + "@deepseek-ai/dsh-sandbox": "workspace:*", + "@deepseek-ai/dsh-sandbox-policy": "workspace:*" } } diff --git a/packages/credentials/authorization/package.json b/packages/credentials/authorization/package.json index 15a497a54d..6e8c3ddc7b 100644 --- a/packages/credentials/authorization/package.json +++ b/packages/credentials/authorization/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-authorization", "description": "Authorization seam (ctx.authorization): plugin-owned flows that obtain a credential through a conversation with the human", - "version": "0.1.7-alpha.1", + "version": "0.1.7-alpha.2", "publishConfig": { "access": "public" }, @@ -37,15 +37,15 @@ ], "license": "MIT", "peerDependencies": { - "@deepseek-ai/dsh-credentials": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/dsh-llm": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/dsh-credentials": "workspace:*", + "@deepseek-ai/dsh-invariants": "workspace:*", + "@deepseek-ai/dsh-llm": "workspace:*", + "@deepseek-ai/cordis": "workspace:~" }, "devDependencies": { - "@deepseek-ai/dsh-credentials": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/dsh-llm": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/dsh-credentials": "workspace:*", + "@deepseek-ai/dsh-invariants": "workspace:*", + "@deepseek-ai/dsh-llm": "workspace:*", + "@deepseek-ai/cordis": "workspace:~" } } diff --git a/packages/credentials/credentials-local/package.json b/packages/credentials/credentials-local/package.json index f4995df85c..c69efa42a0 100644 --- a/packages/credentials/credentials-local/package.json +++ b/packages/credentials/credentials-local/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-credentials-local", "description": "File-backed credentials provider ($DSH_HOME/.env under the live process environment) for the DeepSeek Harness", - "version": "0.1.7-alpha.1", + "version": "0.1.7-alpha.2", "publishConfig": { "access": "public" }, @@ -27,22 +27,22 @@ ], "license": "MIT", "peerDependencies": { - "@deepseek-ai/dsh-atomic-write": "workspace:^", - "@deepseek-ai/dsh-credentials": "workspace:^", - "@deepseek-ai/dsh-launch-environment": "workspace:^", - "@deepseek-ai/dsh-home-paths": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/dsh-atomic-write": "workspace:*", + "@deepseek-ai/dsh-credentials": "workspace:*", + "@deepseek-ai/dsh-launch-environment": "workspace:*", + "@deepseek-ai/dsh-home-paths": "workspace:*", + "@deepseek-ai/cordis": "workspace:~" }, "dependencies": { "chokidar": "^4.0.3", - "@deepseek-ai/schemastery": "workspace:^", + "@deepseek-ai/schemastery": "workspace:~", "yaml": "^2.9.0" }, "devDependencies": { - "@deepseek-ai/dsh-atomic-write": "workspace:^", - "@deepseek-ai/dsh-credentials": "workspace:^", - "@deepseek-ai/dsh-launch-environment": "workspace:^", - "@deepseek-ai/dsh-home-paths": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/dsh-atomic-write": "workspace:*", + "@deepseek-ai/dsh-credentials": "workspace:*", + "@deepseek-ai/dsh-launch-environment": "workspace:*", + "@deepseek-ai/dsh-home-paths": "workspace:*", + "@deepseek-ai/cordis": "workspace:~" } } diff --git a/packages/credentials/credentials/package.json b/packages/credentials/credentials/package.json index e34be8589a..65fe3cb106 100644 --- a/packages/credentials/credentials/package.json +++ b/packages/credentials/credentials/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-credentials", "description": "Abstract credential seam (ctx.credentials): settings carry references to secrets, providers own the values", - "version": "0.1.7-alpha.1", + "version": "0.1.7-alpha.2", "publishConfig": { "access": "public" }, @@ -37,14 +37,14 @@ ], "license": "MIT", "peerDependencies": { - "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^" + "@deepseek-ai/cordis": "workspace:~", + "@deepseek-ai/dsh-invariants": "workspace:*" }, "devDependencies": { - "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^" + "@deepseek-ai/cordis": "workspace:~", + "@deepseek-ai/dsh-invariants": "workspace:*" }, "dependencies": { - "@deepseek-ai/dsh-brand": "workspace:^" + "@deepseek-ai/dsh-brand": "workspace:*" } } diff --git a/packages/credentials/deepseek-account-platform/package.json b/packages/credentials/deepseek-account-platform/package.json index 5db5f3247a..abf1170a15 100644 --- a/packages/credentials/deepseek-account-platform/package.json +++ b/packages/credentials/deepseek-account-platform/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-deepseek-account-platform", "description": "Authorize DeepSeek accounts through browser PKCE", - "version": "0.1.7-alpha.1", + "version": "0.1.7-alpha.2", "publishConfig": { "access": "public" }, @@ -32,21 +32,21 @@ ], "license": "MIT", "peerDependencies": { - "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-deepseek-account": "workspace:^", - "@deepseek-ai/dsh-authorization": "workspace:^", - "@deepseek-ai/dsh-credentials": "workspace:^", - "@deepseek-ai/dsh-host-webserver": "workspace:^" + "@deepseek-ai/cordis": "workspace:~", + "@deepseek-ai/dsh-deepseek-account": "workspace:*", + "@deepseek-ai/dsh-authorization": "workspace:*", + "@deepseek-ai/dsh-credentials": "workspace:*", + "@deepseek-ai/dsh-host-webserver": "workspace:*" }, "devDependencies": { - "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-deepseek-account": "workspace:^", - "@deepseek-ai/dsh-authorization": "workspace:^", - "@deepseek-ai/dsh-credentials": "workspace:^", - "@deepseek-ai/dsh-host-webserver": "workspace:^" + "@deepseek-ai/cordis": "workspace:~", + "@deepseek-ai/dsh-deepseek-account": "workspace:*", + "@deepseek-ai/dsh-authorization": "workspace:*", + "@deepseek-ai/dsh-credentials": "workspace:*", + "@deepseek-ai/dsh-host-webserver": "workspace:*" }, "dependencies": { - "@deepseek-ai/schemastery": "workspace:^", + "@deepseek-ai/schemastery": "workspace:~", "zod": "^4.4.3" } } diff --git a/packages/credentials/deepseek-account/package.json b/packages/credentials/deepseek-account/package.json index 9940eb6146..b0f00e6a7c 100644 --- a/packages/credentials/deepseek-account/package.json +++ b/packages/credentials/deepseek-account/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-deepseek-account", "description": "Read account state and resolve official API credentials", - "version": "0.1.7-alpha.1", + "version": "0.1.7-alpha.2", "publishConfig": { "access": "public" }, @@ -32,11 +32,11 @@ ], "license": "MIT", "peerDependencies": { - "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-brand": "workspace:^" + "@deepseek-ai/cordis": "workspace:~", + "@deepseek-ai/dsh-brand": "workspace:*" }, "devDependencies": { - "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-brand": "workspace:^" + "@deepseek-ai/cordis": "workspace:~", + "@deepseek-ai/dsh-brand": "workspace:*" } } diff --git a/packages/deliverables/tool-present/package.json b/packages/deliverables/tool-present/package.json index 316b890fbf..a91f72803a 100644 --- a/packages/deliverables/tool-present/package.json +++ b/packages/deliverables/tool-present/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-present", "description": "Explicit workspace file delivery declarations for the DeepSeek Harness", - "version": "0.1.7-alpha.1", + "version": "0.1.7-alpha.2", "publishConfig": { "access": "public" }, @@ -32,29 +32,29 @@ "lib/types/**/*.d.ts" ], "dependencies": { - "@deepseek-ai/schemastery": "workspace:^" + "@deepseek-ai/schemastery": "workspace:~" }, "peerDependencies": { - "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-agent": "workspace:^", - "@deepseek-ai/dsh-fs": "workspace:^", - "@deepseek-ai/dsh-llm": "workspace:^", - "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/dsh-session-projection": "workspace:^", - "@deepseek-ai/dsh-tools": "workspace:^" + "@deepseek-ai/cordis": "workspace:~", + "@deepseek-ai/dsh-agent": "workspace:*", + "@deepseek-ai/dsh-fs": "workspace:*", + "@deepseek-ai/dsh-llm": "workspace:*", + "@deepseek-ai/dsh-session": "workspace:*", + "@deepseek-ai/dsh-session-projection": "workspace:*", + "@deepseek-ai/dsh-tools": "workspace:*" }, "devDependencies": { - "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-agent": "workspace:^", - "@deepseek-ai/dsh-fs": "workspace:^", - "@deepseek-ai/dsh-llm": "workspace:^", - "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/dsh-session-projection": "workspace:^", - "@deepseek-ai/dsh-tools": "workspace:^", - "@deepseek-ai/dsh-agent-loop": "workspace:^", - "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^", - "@deepseek-ai/dsh-fs-local": "workspace:^", - "@deepseek-ai/dsh-system-prompt": "workspace:^", - "@deepseek-ai/dsh-scope": "workspace:^" + "@deepseek-ai/cordis": "workspace:~", + "@deepseek-ai/dsh-agent": "workspace:*", + "@deepseek-ai/dsh-fs": "workspace:*", + "@deepseek-ai/dsh-llm": "workspace:*", + "@deepseek-ai/dsh-session": "workspace:*", + "@deepseek-ai/dsh-session-projection": "workspace:*", + "@deepseek-ai/dsh-tools": "workspace:*", + "@deepseek-ai/dsh-agent-loop": "workspace:*", + "@deepseek-ai/dsh-agent-loop-testkit": "workspace:*", + "@deepseek-ai/dsh-fs-local": "workspace:*", + "@deepseek-ai/dsh-system-prompt": "workspace:*", + "@deepseek-ai/dsh-scope": "workspace:*" } } diff --git a/packages/deliverables/workspace-changes/package.json b/packages/deliverables/workspace-changes/package.json index 274a8e97c6..a062496179 100644 --- a/packages/deliverables/workspace-changes/package.json +++ b/packages/deliverables/workspace-changes/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-workspace-changes", "description": "Per-turn workspace file changes recorded from git working-tree snapshots and whole-file captures, with per-file comparisons, for the DeepSeek Harness", - "version": "0.1.7-alpha.1", + "version": "0.1.7-alpha.2", "publishConfig": { "access": "public" }, @@ -32,25 +32,25 @@ "lib/types/**/*.d.ts" ], "dependencies": { - "@deepseek-ai/schemastery": "workspace:^", + "@deepseek-ai/schemastery": "workspace:~", "diff": "^9.0.0" }, "peerDependencies": { - "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-agent": "workspace:^", - "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/dsh-subprocess": "workspace:^", - "@deepseek-ai/dsh-tools": "workspace:^" + "@deepseek-ai/cordis": "workspace:~", + "@deepseek-ai/dsh-agent": "workspace:*", + "@deepseek-ai/dsh-session": "workspace:*", + "@deepseek-ai/dsh-subprocess": "workspace:*", + "@deepseek-ai/dsh-tools": "workspace:*" }, "devDependencies": { - "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/cordis-plugin-include": "workspace:^", - "@deepseek-ai/cordis-plugin-loader": "workspace:^", - "@deepseek-ai/dsh-agent": "workspace:^", - "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/dsh-subprocess": "workspace:^", - "@deepseek-ai/dsh-subprocess-local": "workspace:^", - "@deepseek-ai/dsh-tools": "workspace:^", - "@deepseek-ai/dsh-llm": "workspace:^" + "@deepseek-ai/cordis": "workspace:~", + "@deepseek-ai/cordis-plugin-include": "workspace:~", + "@deepseek-ai/cordis-plugin-loader": "workspace:~", + "@deepseek-ai/dsh-agent": "workspace:*", + "@deepseek-ai/dsh-session": "workspace:*", + "@deepseek-ai/dsh-subprocess": "workspace:*", + "@deepseek-ai/dsh-subprocess-local": "workspace:*", + "@deepseek-ai/dsh-tools": "workspace:*", + "@deepseek-ai/dsh-llm": "workspace:*" } } diff --git a/packages/document/office-to-pdf/README.i18n.yaml b/packages/document/office-to-pdf/README.i18n.yaml index b298d219f5..94b37cb130 100644 --- a/packages/document/office-to-pdf/README.i18n.yaml +++ b/packages/document/office-to-pdf/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/document/office-to-pdf/README.md -README.md: 6655ccff204e33848589468326fc8df7e4d1a07b -README.zh.md: e3aa43a42780378439812874a8eaf178e9c153b9 +README.md: 0ebc409be297ba3377b0c6990c2eb7cd279251cf +README.zh.md: 7bdfc974fd24361822fc17c75ede038a3f7af61a diff --git a/packages/document/office-to-pdf/README.md b/packages/document/office-to-pdf/README.md index 6655ccff20..0ebc409be2 100644 --- a/packages/document/office-to-pdf/README.md +++ b/packages/document/office-to-pdf/README.md @@ -29,7 +29,7 @@ The [Web bundle](../../bundle/web-app/README.md) mounts this provider as `office Callers submit authorized source identity, version, optional byte size, a deferred bounded read, Office extension, and scheduling priority through `ctx.officeToPdf.convert()`. A changed source version rejects conversion. Results contain caller-owned PDF bytes, missing fonts, a cache key, and a conversion generation that changes on configuration replacement. Cancellation rejects with its reason; conversion failures use `OfficeToPdfError`. -The provider depends on the independently published [`@deepseek-ai/libreoffice-kit`](https://github.com/deepseek-harness/libreoffice-kit/tree/main/packages/entry) npm API at kit version `0.0.1`. Application packaging selects the matching native package declared in the kit’s `optionalDependencies`, or WASM when no native package is declared for that target. A missing declared native engine rejects packaging without selecting WASM. The [platform engine decision](../../../.agents/notes/implemented/architecture/2026-09-15-platform-office-engines.md) defines installation and packaging; the [release ownership decision](../../../.agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.md) defines the independent kit and Harness responsibilities. +The provider depends on the independently published [`@deepseek-ai/libreoffice-kit`](https://github.com/deepseek-harness/libreoffice-kit/tree/main/packages/entry) npm API at kit version `0.1.0`. Application packaging selects the matching native package declared in the kit’s `optionalDependencies`, or WASM when no native package is declared for that target. A missing declared native engine rejects packaging without selecting WASM. The [platform engine decision](../../../.agents/notes/implemented/architecture/2026-09-15-platform-office-engines.md) defines installation and packaging; the [release ownership decision](../../../.agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.md) defines the independent kit and Harness responsibilities. Browsers request PDFs through the `officeToPdf.render` Remote method with a Session identity, Office path, and priority. This entry uses `workspaceFiles` for authorization and source versions, then reads raw bytes through `fs.readBytes` within the conversion reservation. In-process `convert()` does not require those services. Responses retain the source path and version and carry native PDF bytes through the binary Remote multipart transport, plus missing fonts and conversion generation. The `officeToPdf.generation` Remote method returns the current provider generation; `api/remotes` mounts the generated Client descriptor. diff --git a/packages/document/office-to-pdf/README.zh.md b/packages/document/office-to-pdf/README.zh.md index e3aa43a427..7bdfc974fd 100644 --- a/packages/document/office-to-pdf/README.zh.md +++ b/packages/document/office-to-pdf/README.zh.md @@ -29,7 +29,7 @@ kind: "package-reference" 调用方通过 `ctx.officeToPdf.convert()` 提交已授权源的标识、版本、可选字节数、延迟的有界读取、Office 扩展名和调度优先级。源版本变化会拒绝转换。结果包含调用方拥有的 PDF 字节、缺失字体、缓存键和转换 generation;配置替换后 generation 随之改变。取消以原因为拒绝值,转换失败使用 `OfficeToPdfError`。 -此 provider 依赖独立发布的 [`@deepseek-ai/libreoffice-kit`](https://github.com/deepseek-harness/libreoffice-kit/tree/main/packages/entry) npm API,kit 版本为 `0.0.1`。应用打包选择 kit 的 `optionalDependencies` 中声明的匹配原生包;目标没有声明原生包时选择 WASM。已声明的原生引擎缺失时拒绝打包,不会选择 WASM。[平台引擎决策](../../../.agents/notes/implemented/architecture/2026-09-15-platform-office-engines.zh.md)定义安装与打包策略;[发布归属决策](../../../.agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.zh.md)定义独立 kit 与 Harness 各自的职责。 +此 provider 依赖独立发布的 [`@deepseek-ai/libreoffice-kit`](https://github.com/deepseek-harness/libreoffice-kit/tree/main/packages/entry) npm API,kit 版本为 `0.1.0`。应用打包选择 kit 的 `optionalDependencies` 中声明的匹配原生包;目标没有声明原生包时选择 WASM。已声明的原生引擎缺失时拒绝打包,不会选择 WASM。[平台引擎决策](../../../.agents/notes/implemented/architecture/2026-09-15-platform-office-engines.zh.md)定义安装与打包策略;[发布归属决策](../../../.agents/notes/implemented/architecture/2026-09-14-independent-libreoffice-kit.zh.md)定义独立 kit 与 Harness 各自的职责。 浏览器通过 `officeToPdf.render` Remote 方法请求 PDF,参数为 Session 标识、Office 路径和优先级。此入口使用 `workspaceFiles` 完成授权和源版本检查,再通过 `fs.readBytes` 在转换预留容量内读取原始字节。进程内 `convert()` 不要求这些服务。响应保留源文件路径与版本,通过二进制 Remote 的 multipart 传输携带原生 PDF 字节,并携带缺失字体和转换 generation。`officeToPdf.generation` Remote 方法返回当前提供方 generation;`api/remotes` 负责挂载生成的 Client 描述符。 diff --git a/packages/document/office-to-pdf/package.json b/packages/document/office-to-pdf/package.json index b2fcb0b1ec..ae5db5d7a3 100644 --- a/packages/document/office-to-pdf/package.json +++ b/packages/document/office-to-pdf/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-office-to-pdf", "description": "Shared Office-to-PDF conversion with bounded queues and caching", - "version": "0.1.7-alpha.1", + "version": "0.1.7-alpha.2", "publishConfig": { "access": "public" }, @@ -44,21 +44,21 @@ "lib/typert.remote-client.d.ts" ], "peerDependencies": { - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:~" }, "devDependencies": { - "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/cordis-plugin-loader": "workspace:^", - "@deepseek-ai/cordis-plugin-include": "workspace:^", - "@deepseek-ai/dsh-api-workspace-files": "workspace:^", - "@deepseek-ai/dsh-fs": "workspace:^", - "@deepseek-ai/dsh-session": "workspace:^" + "@deepseek-ai/cordis": "workspace:~", + "@deepseek-ai/cordis-plugin-loader": "workspace:~", + "@deepseek-ai/cordis-plugin-include": "workspace:~", + "@deepseek-ai/dsh-api-workspace-files": "workspace:*", + "@deepseek-ai/dsh-fs": "workspace:*", + "@deepseek-ai/dsh-session": "workspace:*" }, "dependencies": { - "@deepseek-ai/libreoffice-kit": "0.0.1", - "@deepseek-ai/schemastery": "workspace:^", - "@deepseek-ai/dsh-brand": "workspace:^", - "@deepseek-ai/dsh-typert-protocol": "workspace:^", + "@deepseek-ai/libreoffice-kit": "^0.1.0", + "@deepseek-ai/schemastery": "workspace:~", + "@deepseek-ai/dsh-brand": "workspace:*", + "@deepseek-ai/dsh-typert-protocol": "workspace:*", "zod": "^4.4.3" } } diff --git a/packages/document/office-to-pdf/tests/provider.spec.ts b/packages/document/office-to-pdf/tests/provider.spec.ts index 216b9e1f4c..70ed68eff0 100644 --- a/packages/document/office-to-pdf/tests/provider.spec.ts +++ b/packages/document/office-to-pdf/tests/provider.spec.ts @@ -22,6 +22,7 @@ function distinct(index: number): OfficeToPdfRequest { let ctx: Context let render: ReturnType> let dispose: ReturnType> +let converter: Converter beforeEach(() => { ctx = new Context() @@ -30,7 +31,13 @@ beforeEach(() => { return { backend: 'native', missingFonts: ['Missing Serif'] } }) dispose = vi.fn().mockResolvedValue(undefined) - kit.create.mockReset().mockImplementation(async () => ({ backend: 'native', render, dispose })) + converter = { + backend: 'native', render, dispose, + renderImages: vi.fn().mockRejectedValue(new Error('Unexpected Converter.renderImages call')), + convert: vi.fn().mockRejectedValue(new Error('Unexpected Converter.convert call')), + recalculate: vi.fn().mockRejectedValue(new Error('Unexpected Converter.recalculate call')), + } + kit.create.mockReset().mockImplementation(async () => ({ ...converter })) }) afterEach(async () => { await ctx.fiber.dispose() }) @@ -214,7 +221,7 @@ it('joins initialization during disposal and never starts a render after cancell const rejected = expect(work).rejects.toMatchObject({ code: 'unavailable' }) await entered.promise const closing = ctx.fiber.dispose() - release.resolve({ backend: 'wasm', render, dispose }) + release.resolve({ ...converter, backend: 'wasm' }) await rejected await closing expect(render).not.toHaveBeenCalled() @@ -255,8 +262,8 @@ it('joins every converter disposal before reporting an engine cleanup failure', }) const failed = new Error('engine cleanup failed') kit.create.mockReset() - .mockResolvedValueOnce({ backend: 'native', render, dispose: async () => { throw failed } }) - .mockResolvedValueOnce({ backend: 'native', render, dispose: async () => { cleanupEntered.resolve(undefined); await cleanupRelease.promise } }) + .mockResolvedValueOnce({ ...converter, dispose: async () => { throw failed } }) + .mockResolvedValueOnce({ ...converter, dispose: async () => { cleanupEntered.resolve(undefined); await cleanupRelease.promise } }) const work = [provider.convert(distinct(1)), provider.convert(distinct(2))] try { await entered.promise } finally { released.resolve(undefined); await Promise.all(work) } let disposed = false diff --git a/packages/experimental/agent-team-profile/package.json b/packages/experimental/agent-team-profile/package.json index 8b8f13ab09..ba73fb30ce 100644 --- a/packages/experimental/agent-team-profile/package.json +++ b/packages/experimental/agent-team-profile/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-experimental-agent-team-profile", "description": "Agent Teams collaboration, tools, and Web UI in one experimental bundle", - "version": "0.1.7-alpha.1", + "version": "0.1.7-alpha.2", "icon": "./icon.svg", "publishConfig": { "access": "public" @@ -38,16 +38,16 @@ } }, "dependencies": { - "@deepseek-ai/dsh-experimental-agent-team": "workspace:^", - "@deepseek-ai/dsh-experimental-client-ui-agent-team": "workspace:^", - "@deepseek-ai/dsh-experimental-tool-agent-team": "workspace:^" + "@deepseek-ai/dsh-experimental-agent-team": "workspace:*", + "@deepseek-ai/dsh-experimental-client-ui-agent-team": "workspace:*", + "@deepseek-ai/dsh-experimental-tool-agent-team": "workspace:*" }, "peerDependencies": { - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:~" }, "devDependencies": { - "@deepseek-ai/cordis-plugin-include": "workspace:^", - "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/cordis-plugin-include": "workspace:~", + "@deepseek-ai/cordis": "workspace:~", "js-yaml": "^4.2.0" } } diff --git a/packages/experimental/agent-team-profile/tests/profile.spec.ts b/packages/experimental/agent-team-profile/tests/profile.spec.ts index dd65a021ec..b75da63252 100644 --- a/packages/experimental/agent-team-profile/tests/profile.spec.ts +++ b/packages/experimental/agent-team-profile/tests/profile.spec.ts @@ -20,9 +20,9 @@ describe('Agent Teams profile bundle', () => { expect(manifest.publishConfig?.access).toBe('public') expect(manifest.dsh?.bundle?.patch).toBe('./cordis.patch.yml') expect(manifest.dependencies).toMatchObject({ - '@deepseek-ai/dsh-experimental-agent-team': 'workspace:^', - '@deepseek-ai/dsh-experimental-client-ui-agent-team': 'workspace:^', - '@deepseek-ai/dsh-experimental-tool-agent-team': 'workspace:^', + '@deepseek-ai/dsh-experimental-agent-team': 'workspace:*', + '@deepseek-ai/dsh-experimental-client-ui-agent-team': 'workspace:*', + '@deepseek-ai/dsh-experimental-tool-agent-team': 'workspace:*', }) const parsed = yaml.load( diff --git a/packages/experimental/agent-team/README.i18n.yaml b/packages/experimental/agent-team/README.i18n.yaml index 52019ce198..be20145568 100644 --- a/packages/experimental/agent-team/README.i18n.yaml +++ b/packages/experimental/agent-team/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/experimental/agent-team/README.md -README.md: 6ae7d82214ca9e041324e353057ba6850675c043 -README.zh.md: 89176c285a8bd9d5a1875618dffb0b2eb4890a3e +README.md: 6d2aa6041039022e6508541fdb738181bbb54045 +README.zh.md: 9ea2977a976583aac08ca8bc53d61ff4812e1766 diff --git a/packages/experimental/agent-team/README.md b/packages/experimental/agent-team/README.md index 6ae7d82214..6d2aa60410 100644 --- a/packages/experimental/agent-team/README.md +++ b/packages/experimental/agent-team/README.md @@ -118,7 +118,8 @@ The [Agent Teams Agent Note](../../../.agents/notes/implemented/feature/2026-08- | [`src/mailbox.ts`](src/mailbox.ts) | Durable queue, target-local dispatch, acknowledgement, and recovery | | [`src/task-board.ts`](src/task-board.ts) | Task CAS commands, DAG validation, and derived views | | [`src/journal.ts`](src/journal.ts) | Serialized Lead-log transactions and commit notification | -| [`src/projection.ts`](src/projection.ts) | Strict replay projection that decodes and validates Team events | +| [`src/projection.ts`](src/projection.ts) | Strict replay projection that decodes and validates Team events and publishes the `agentTeam` client view | +| [`src/task-view.ts`](src/task-view.ts) | Pure task readiness, owner-name, and write-overlap derivation shared by the task board and the client view | | [`src/activity.ts`](src/activity.ts) | One-shot change waiters and disposal release | | [`src/lifecycle.ts`](src/lifecycle.ts) | Shared admission cutoff and bounded settlement | | [`src/invariant.ts`](src/invariant.ts) | Invariant companion that replays candidate events before append | @@ -171,9 +172,11 @@ Read these pages when the package-level contract is not enough. They move from t -### Browser Remote +### Browser projection -`TeamService` exposes the read-only `agentTeams/view` Remote method for browser clients. Task creation and updates belong to Team agents through the service and model tools. The `./remote` export supplies the Client contribution mounted by the Web UI, while `./client` exports browser-safe roster and task views. +The `agentTeam` Session projection publishes the Lead Session's durable roster identities and phases, member errors, non-deleted task views, and any `failure` beside the last valid state. Its `apply` replaces only the touched collection; mailbox-only changes retain the client view reference and produce no frame. The [subsystem reference](../../../docs/subsystems/agent-team.md#web-projection) defines the wire types. + +The [Web UI](../client-ui-agent-team/README.md) reads the shared Session projections and overlays activity from Session status. Task creation and updates belong to Team agents through the service and model tools. The `./client` export supplies browser-safe roster, task, and projection types. ## Model Experience @@ -198,6 +201,7 @@ Peer messages append after the target's reusable history prefix. Cold resume reu These limits describe what a team cannot do yet or what needs special operational care. They are current package constraints, not a comparison with other coordination mechanisms. +- **Whole-view broadcasts** — each roster or task change sends the complete roster and non-deleted task board, including descriptions, to every connected browser, even when it is viewing another Session. - **Experimental prototype with no stability promise** — the package is public, but its contracts can change freely while it incubates. - **One process and one shared checkout** — members share cwd and observe edits immediately; this package provides no worktree, remote member, merge, or filesystem lock. - **Advisory write scopes** — Bash, formatters, code generators, and direct external writers can bypass filesystem version checks; Leads must coordinate ownership and review the final diff. diff --git a/packages/experimental/agent-team/README.zh.md b/packages/experimental/agent-team/README.zh.md index 89176c285a..9ea2977a97 100644 --- a/packages/experimental/agent-team/README.zh.md +++ b/packages/experimental/agent-team/README.zh.md @@ -118,7 +118,8 @@ Lead 可以停止 teammate 的当前轮次,而不会删除其排队的消息 | [`src/mailbox.ts`](src/mailbox.ts) | 持久队列、目标本地投递、确认与恢复 | | [`src/task-board.ts`](src/task-board.ts) | 任务 CAS 命令、DAG 校验与派生视图 | | [`src/journal.ts`](src/journal.ts) | 串行化的 Lead 日志事务与提交通知 | -| [`src/projection.ts`](src/projection.ts) | 解码并校验 Team 事件的严格回放投影 | +| [`src/projection.ts`](src/projection.ts) | 解码并校验 Team 事件、发布 `agentTeam` 客户端视图的严格回放投影 | +| [`src/task-view.ts`](src/task-view.ts) | 任务板与客户端视图共用的纯任务派生:就绪状态、owner 名称与写入范围重叠 | | [`src/activity.ts`](src/activity.ts) | 一次性变更等待者与 dispose(资源释放)时的等待解除 | | [`src/lifecycle.ts`](src/lifecycle.ts) | 共享准入截止与有界结算 | | [`src/invariant.ts`](src/invariant.ts) | 在 append 前回放候选事件的不变式伴生插件 | @@ -171,9 +172,11 @@ dispose 会关闭准入、中止并等待已获准的创建与 mailbox dispatch -### 浏览器 Remote +### 浏览器投影 -`TeamService` 向浏览器客户端公开只读的 `agentTeams/view` Remote method。任务创建与更新由 Team agent 通过服务和模型工具执行。`./remote` 导出由 Web UI 挂载的 Client contribution,`./client` 导出可供浏览器使用的成员与任务视图。 +`agentTeam` Session 投影发布 Lead Session 的持久成员身份与阶段、成员错误、未删除任务视图,以及最后有效状态旁的 `failure`。其 `apply` 只替换被触及的集合;仅邮箱的变化保留客户端视图引用,不产生 frame。[子系统参考](../../../docs/subsystems/agent-team.zh.md#web-projection) 定义传输类型。 + +[Web UI](../client-ui-agent-team/README.zh.md) 读取共享 Session 投影,并从 Session 状态叠加活动信息。任务创建与更新由 Team agent 通过服务和模型工具完成。`./client` 导出可供浏览器使用的 roster、任务与投影类型。 ## 模型体验 @@ -198,6 +201,7 @@ Peer 消息追加在 target 可复用历史前缀之后。冷恢复会先复用 这些限制说明一支团队目前不能做什么、或哪些方面需要特别的运维关注。它们是当前包约束,不是与其他协作机制的对比。 +- **完整视图广播** — 每次 roster 或任务变化都会把完整 roster 和未删除任务板(含描述)发给所有已连接浏览器,即使它正在查看其他 Session。 - **实验原型,无稳定性承诺**——本包公开发布,但孵化期间约定仍可自由变更。 - **单进程、共享 checkout**——成员共享 cwd,修改立即可见;本包不提供 worktree、远端成员、merge 或文件锁。 - **write scope 仅作提示**——Bash、formatter、代码生成器与直接外部写入可以绕过文件版本检查;Lead 必须协调 owner 并检查最终 diff。 diff --git a/packages/experimental/agent-team/package.json b/packages/experimental/agent-team/package.json index 14767611c6..680ea5c4c4 100644 --- a/packages/experimental/agent-team/package.json +++ b/packages/experimental/agent-team/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-experimental-agent-team", "description": "Implicit-root Agent Teams roster, durable peer mailbox, and shared task DAG", - "version": "0.1.7-alpha.1", + "version": "0.1.7-alpha.2", "publishConfig": { "access": "public" }, @@ -30,14 +30,6 @@ "types": "./lib/types/types.d.ts", "default": "./lib/types/types.js" }, - "./typert": { - "types": "./lib/typert.host.d.ts", - "default": "./lib/typert.host.js" - }, - "./remote": { - "types": "./lib/typert.remote-client.d.ts", - "default": "./lib/typert.remote-client.js" - }, "./locale/*.json": "./locale/*.json", "./src/*": "./src/*", "./package.json": "./package.json" @@ -47,44 +39,38 @@ "lib/index.js", "lib/invariant.js", "lib/types/**/*.js", - "lib/types/**/*.d.ts", - "lib/typert.host.js", - "lib/typert.host.d.ts", - "lib/typert.remote-client.js", - "lib/typert.remote-client.d.ts" + "lib/types/**/*.d.ts" ], "license": "MIT", "dependencies": { - "@deepseek-ai/dsh-brand": "workspace:^", - "@deepseek-ai/schemastery": "workspace:^", + "@deepseek-ai/dsh-brand": "workspace:*", + "@deepseek-ai/schemastery": "workspace:~", "zod": "^4.4.3" }, "peerDependencies": { - "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-agent": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/dsh-llm": "workspace:^", - "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/dsh-session-persistence": "workspace:^", - "@deepseek-ai/dsh-session-projection": "workspace:^", - "@deepseek-ai/dsh-subagent": "workspace:^", - "@deepseek-ai/dsh-typert-protocol": "workspace:^" + "@deepseek-ai/cordis": "workspace:~", + "@deepseek-ai/dsh-agent": "workspace:*", + "@deepseek-ai/dsh-invariants": "workspace:*", + "@deepseek-ai/dsh-llm": "workspace:*", + "@deepseek-ai/dsh-session": "workspace:*", + "@deepseek-ai/dsh-session-persistence": "workspace:*", + "@deepseek-ai/dsh-session-projection": "workspace:*", + "@deepseek-ai/dsh-subagent": "workspace:*" }, "devDependencies": { - "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-agent": "workspace:^", - "@deepseek-ai/dsh-agent-loop": "workspace:^", - "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/dsh-llm": "workspace:^", - "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/dsh-session-persistence": "workspace:^", - "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", - "@deepseek-ai/dsh-session-projection": "workspace:^", - "@deepseek-ai/dsh-session-query": "workspace:^", - "@deepseek-ai/dsh-subagent": "workspace:^", - "@deepseek-ai/dsh-subagent-fork-in-process": "workspace:^", - "@deepseek-ai/dsh-subagent-spawn-in-process": "workspace:^", - "@deepseek-ai/dsh-typert-protocol": "workspace:^" + "@deepseek-ai/cordis": "workspace:~", + "@deepseek-ai/dsh-agent": "workspace:*", + "@deepseek-ai/dsh-agent-loop": "workspace:*", + "@deepseek-ai/dsh-agent-loop-testkit": "workspace:*", + "@deepseek-ai/dsh-invariants": "workspace:*", + "@deepseek-ai/dsh-llm": "workspace:*", + "@deepseek-ai/dsh-session": "workspace:*", + "@deepseek-ai/dsh-session-persistence": "workspace:*", + "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:*", + "@deepseek-ai/dsh-session-projection": "workspace:*", + "@deepseek-ai/dsh-session-query": "workspace:*", + "@deepseek-ai/dsh-subagent": "workspace:*", + "@deepseek-ai/dsh-subagent-fork-in-process": "workspace:*", + "@deepseek-ai/dsh-subagent-spawn-in-process": "workspace:*" } } diff --git a/packages/experimental/agent-team/src/client.ts b/packages/experimental/agent-team/src/client.ts index b1fd173d10..c56076fa7c 100644 --- a/packages/experimental/agent-team/src/client.ts +++ b/packages/experimental/agent-team/src/client.ts @@ -1,9 +1,11 @@ -/** Client-safe Agent Teams roster and task views. */ +/** Client-safe Agent Teams roster and task views plus the `agentTeam` projection vocabulary. */ export type { + TeamMemberPhase, + TeamMemberProjection, TeamMemberView, + TeamProjection, TeamTaskId, TeamTaskStatus, TeamTaskView, - TeamView, } from './types.ts' diff --git a/packages/experimental/agent-team/src/index.ts b/packages/experimental/agent-team/src/index.ts index 03ddbc7a48..96a79bea16 100644 --- a/packages/experimental/agent-team/src/index.ts +++ b/packages/experimental/agent-team/src/index.ts @@ -1,10 +1,9 @@ /** Agent Teams service façade over roster, mailbox, task, and runtime lifecycle owners. */ -import { Context } from '@deepseek-ai/cordis' +import { Context, Service } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' import type { Agent } from '@deepseek-ai/dsh-agent' import type {} from '@deepseek-ai/dsh-session-persistence' -import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' import { TeamActivity } from './activity.ts' import { errorMessage, TeamError } from './error.ts' import { TeamJournal } from './journal.ts' @@ -24,7 +23,6 @@ import type { SpawnTeammateResult, TeamMemberView, TeamTaskView, - TeamView, TeamWaitResult, UpdateTeamTaskRequest, } from './types.ts' @@ -55,7 +53,7 @@ function positiveLimit(name: string, value: number): number { } /** Agent Teams service backed by the exact live Lead Session log. */ -export class TeamService extends TypertRemoteService { +export class TeamService extends Service { static inject = ['agents', 'sessions', 'sessionPersistence', 'sessionProjections', 'subagents'] static Config: z = z.object({ @@ -233,19 +231,6 @@ export class TeamService extends TypertRemoteService { return this.roster.tryMembership(agent) } - /** - * Read the current roster and non-deleted task board through the generated Remote API. - * @param agent - exact live Team member used as the authority credential. - * @returns detached current roster and task views. - */ - @Remote('view') - remoteView(agent: Agent): TeamView { - return { - members: this.listMembers(agent), - tasks: this.listTasks(agent), - } - } - /** Queue one contained recovery pass after publication has unwound. */ private scheduleRecovery(agent: Agent): void { queueMicrotask(() => { diff --git a/packages/experimental/agent-team/src/invariant.ts b/packages/experimental/agent-team/src/invariant.ts index dc5fd3ddc1..442df6a6e6 100644 --- a/packages/experimental/agent-team/src/invariant.ts +++ b/packages/experimental/agent-team/src/invariant.ts @@ -16,7 +16,7 @@ export const name = 'team-invariant' /** Invariant registry required by the companion. */ export const inject = ['invariants'] -/** Validate candidate Team events against the projected committed prefix. */ +/** Validate candidate Team events against the projected committed prefix; `apply` never mutates the committed state. */ const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => { ctx.on('internal/dispatch', (_mode, eventName, args) => { if (eventName !== 'session/event') return @@ -24,7 +24,7 @@ const install: InvariantInstaller = Object.assign((ctx: Context, fail: Invariant /* v8 ignore next -- non-Team Session events have no Agent Teams invariant. */ if (!isTeamEvent(event)) return const state = ctx.sessionProjections.stateOf(session, 'agentTeam') as TeamProjectionState - const candidate = teamProjectionDefinition.apply(structuredClone(state), event) + const candidate = teamProjectionDefinition.apply(state, event) if (candidate.failure !== undefined) { fail(`session event ${event.seq} violates the Agent Teams stream: ${candidate.failure}`) } diff --git a/packages/experimental/agent-team/src/projection.ts b/packages/experimental/agent-team/src/projection.ts index e4ade61e9e..bc9239d3d8 100644 --- a/packages/experimental/agent-team/src/projection.ts +++ b/packages/experimental/agent-team/src/projection.ts @@ -1,4 +1,4 @@ -/** Host-only Team state projected incrementally from committed Session events. */ +/** Team state projected incrementally from committed Session events, with a durable-only client view. */ import { z } from 'zod' import { brandString } from '@deepseek-ai/dsh-brand' @@ -7,10 +7,13 @@ import type { SessionEvent, SessionEventMap, SessionId } from '@deepseek-ai/dsh- import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection' import type { TeamId, + TeamMemberProjection, TeamMemberSnapshot, TeamMessageId, TeamMessageSnapshot, + TeamProjection, TeamTaskSnapshot, + TeamTaskView, } from './types.ts' import { TeamId as toTeamId, @@ -18,6 +21,7 @@ import { TeamTaskId as toTeamTaskId, } from './types.ts' import { assertTaskGraphCandidate } from './task-graph.ts' +import { projectTaskView } from './task-view.ts' const nonNegativeSafeInteger = z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER) const positiveSafeInteger = nonNegativeSafeInteger.min(1) @@ -119,20 +123,24 @@ const teamMessageDeliveredEventSchema = z.object({ targetId: sessionIdSchema, }).strict() as z.ZodType -/** Current Team state selected by durable Team identity. */ +/** + * Current Team state selected by durable Team identity. Every applied Team + * event produces a new state object and replaces only the collection it + * touched; untouched collections keep their references. + */ export interface TeamState { readonly id: TeamId - readonly members: TeamMemberSnapshot[] - readonly tasks: TeamTaskSnapshot[] - readonly messages: TeamMessageSnapshot[] - readonly delivered: TeamMessageId[] - nextTaskNumber: number + readonly members: readonly TeamMemberSnapshot[] + readonly tasks: readonly TeamTaskSnapshot[] + readonly messages: readonly TeamMessageSnapshot[] + readonly delivered: readonly TeamMessageId[] + readonly nextTaskNumber: number } /** * Construct empty state for one Team identity. * @param rootId - root Session identity. - * @returns mutable empty Team state. + * @returns empty Team state. */ export function emptyTeamState(rootId: SessionId): TeamProjectionState { return { @@ -147,7 +155,7 @@ export function emptyTeamState(rootId: SessionId): TeamProjectionState { /** Checkpoint-safe state for the Team owned by the projected Session. */ export interface TeamProjectionState extends TeamState { - failure?: string + readonly failure?: string } declare module '@deepseek-ai/dsh-session-projection/types' { @@ -214,23 +222,30 @@ function parseCurrentTeamEvent(event: TeamSessionEvent): TeamSessionEvent { } } -function applyProjectionEvent(state: TeamProjectionState, event: SessionEvent): void { - if (state.failure !== undefined) return - if (!isTeamEvent(event)) return +function applyProjectionEvent(state: TeamProjectionState, event: SessionEvent): TeamProjectionState { + if (state.failure !== undefined) return state + if (!isTeamEvent(event)) return state try { const selector = parsePersisted(event.type, teamEventSelectorSchema, event.data) - if (selector.teamId !== state.id) return + if (selector.teamId !== state.id) return state if (selector.version !== 2) { throw new Error(`unsupported Agent Teams event version ${String(selector.version)}`) } - applyCurrentTeamEvent(state, parseCurrentTeamEvent(event)) + return applyCurrentTeamEvent(state, parseCurrentTeamEvent(event)) } catch (error: unknown) { /* v8 ignore next -- the owned Team transition throws Error instances. */ - state.failure = error instanceof Error ? error.message : String(error) + return { ...state, failure: error instanceof Error ? error.message : String(error) } } } -function applyCurrentTeamEvent(state: TeamState, event: TeamSessionEvent): void { +function replaceAt(items: readonly T[], index: number, item: T): T[] { + const next = [...items] + if (index < 0) next.push(item) + else next[index] = item + return next +} + +function applyCurrentTeamEvent(state: TeamProjectionState, event: TeamSessionEvent): TeamProjectionState { switch (event.type) { case 'team/member': { const member = event.data.member @@ -250,9 +265,7 @@ function applyCurrentTeamEvent(state: TeamState, event: TeamSessionEvent): void throw new Error(`teammate "${member.name}" has an invalid ${prior.phase} -> ${member.phase} transition`) } } - if (index < 0) state.members.push(member) - else state.members[index] = member - break + return { ...state, members: replaceAt(state.members, index, member) } } case 'team/task': { const task = event.data.task @@ -265,48 +278,117 @@ function applyCurrentTeamEvent(state: TeamState, event: TeamSessionEvent): void throw new Error(`team task "${task.id}" revision is not contiguous`) } assertTaskGraphCandidate(state.tasks, task) + let nextTaskNumber = state.nextTaskNumber const match = numericTaskIdPattern.exec(task.id) if (match !== null) { const number = Number(match[1]) - state.nextTaskNumber = Math.max( - state.nextTaskNumber, + nextTaskNumber = Math.max( + nextTaskNumber, number === Number.MAX_SAFE_INTEGER ? number : number + 1, ) } - if (index < 0) state.tasks.push(task) - else state.tasks[index] = task - break + return { ...state, tasks: replaceAt(state.tasks, index, task), nextTaskNumber } } case 'team/message/queued': { const message = event.data.message if (state.messages.some(candidate => candidate.id === message.id)) { throw new Error(`team message "${message.id}" was queued twice`) } - state.messages.push(message) - break + return { ...state, messages: [...state.messages, message] } } case 'team/message/delivered': { const queued = state.messages.find(message => message.id === event.data.messageId) if (queued === undefined) throw new Error(`team message "${event.data.messageId}" was delivered before queueing`) if (queued.targetId !== event.data.targetId) throw new Error(`team message "${event.data.messageId}" target changed`) if (state.delivered.includes(event.data.messageId)) throw new Error(`team message "${event.data.messageId}" was delivered twice`) - state.delivered.push(event.data.messageId) - break + return { ...state, delivered: [...state.delivered, event.data.messageId] } } /* v8 ignore next 2 -- TeamEventType is closed and every member is handled above. */ default: - return + return state } } -/** Host-only Team projection selected by the projected Session identity. */ +const teamMemberProjectionSchema = z.object({ + id: sessionIdSchema, + name: z.string(), + role: z.enum(['lead', 'teammate']), + phase: z.enum(['provisioning', 'active', 'failed']), + error: z.string().optional(), +}).strict() as z.ZodType + +const teamTaskViewSchema = z.object({ + id: teamTaskIdSchema, + revision: positiveSafeInteger, + subject: z.string(), + description: z.string(), + status: z.enum(['pending', 'in_progress', 'completed', 'deleted']), + blockedBy: z.array(teamTaskIdSchema), + writeScopes: z.array(z.string()), + ownerName: z.string().optional(), + ready: z.boolean(), + writeScopeWarnings: z.array(z.string()), +}).strict() as z.ZodType + +const teamProjectionSchema = z.object({ + members: z.array(teamMemberProjectionSchema), + tasks: z.array(teamTaskViewSchema), + failure: z.string().optional(), +}).strict() as z.ZodType + +/** Client views keyed by the member and task collections they were derived from. */ +const teamProjectionViews = new WeakMap>() + +function buildTeamProjection(state: TeamProjectionState): TeamProjection { + const rootId = brandString(state.id) + const members: TeamMemberProjection[] = [{ id: rootId, name: 'lead', role: 'lead', phase: 'active' }] + for (const member of state.members) { + members.push({ + id: member.id, + name: member.name, + role: 'teammate', + phase: member.phase, + ...member.error === undefined ? {} : { error: member.error }, + }) + } + return { + members, + tasks: state.tasks + .filter(task => task.status !== 'deleted') + .map(task => projectTaskView(state, task)), + ...state.failure === undefined ? {} : { failure: state.failure }, + } +} + +/** + * Durable client view of one Team state. Mailbox-only state changes reuse the + * previous view reference, so the live drive publishes nothing for them. + * A failure is terminal: later events retain the failed state reference and + * do not republish its view. + * @param state - current Team state. + * @returns the roster and non-deleted task board, plus any projection failure. + */ +export function teamProjectionView(state: TeamProjectionState): TeamProjection { + if (state.failure !== undefined) return buildTeamProjection(state) + let byTasks = teamProjectionViews.get(state.members) + if (byTasks === undefined) { + byTasks = new WeakMap() + teamProjectionViews.set(state.members, byTasks) + } + let view = byTasks.get(state.tasks) + if (view === undefined) { + view = buildTeamProjection(state) + byTasks.set(state.tasks, view) + } + return view +} + +/** Team projection selected by the projected Session identity; the wire view carries durable roster and task state only. */ export const teamProjectionDefinition = { key: 'agentTeam', stateVersion: 4, stateSchema: teamProjectionEntrySchema, init: header => emptyTeamState(header.id), - apply: (state, event) => { - applyProjectionEvent(state, event) - return state - }, + apply: applyProjectionEvent, + wire: { viewSchema: teamProjectionSchema, view: teamProjectionView }, } satisfies ProjectionDefinition<'agentTeam', TeamProjectionState> diff --git a/packages/experimental/agent-team/src/task-board.ts b/packages/experimental/agent-team/src/task-board.ts index cc7064ceb6..4b6132526a 100644 --- a/packages/experimental/agent-team/src/task-board.ts +++ b/packages/experimental/agent-team/src/task-board.ts @@ -15,13 +15,9 @@ import type { TeamTaskView, UpdateTeamTaskRequest, } from './types.ts' +import { projectTaskView, taskReady } from './task-view.ts' import { requiredText, writeScope } from './validation.ts' -/** Whether two normalized file or directory prefixes overlap on path components. */ -function scopesOverlap(left: string, right: string): boolean { - return left === right || left.startsWith(`${right}/`) || right.startsWith(`${left}/`) -} - const TASK_GRAPH_ERROR_CODES: Record = { missing: 'TEAM_TASK_NOT_FOUND', duplicate: 'TEAM_INVALID_ARGUMENT', @@ -68,7 +64,7 @@ export class TeamTaskBoard { } this.assertTaskGraph(state, task) await this.journal.appendAndFlush(root, 'team/task', { version: 2, teamId: TeamId(root.id), task }) - return this.taskView(root, state, task) + return projectTaskView(state, task) }) } @@ -83,7 +79,7 @@ export class TeamTaskBoard { const state = this.journal.state(root) const task = state.tasks.find(candidate => candidate.id === id) if (task === undefined) throw new TeamError(`team task "${id}" not found`, 'TEAM_TASK_NOT_FOUND') - return this.taskView(root, state, task) + return projectTaskView(state, task) } /** @@ -96,7 +92,7 @@ export class TeamTaskBoard { const state = this.journal.state(root) return state.tasks .filter(task => task.status !== 'deleted') - .map(task => this.taskView(root, state, task)) + .map(task => projectTaskView(state, task)) } /** @@ -134,7 +130,7 @@ export class TeamTaskBoard { if (current.ownerId !== undefined && current.ownerId !== caller.id) { throw new TeamError(`team task "${current.id}" is owned by another member`, 'TEAM_TASK_ALREADY_CLAIMED') } - if (current.status !== 'pending' || !this.taskReady(state, current)) { + if (current.status !== 'pending' || !taskReady(state, current)) { throw new TeamError(`team task "${current.id}" is not ready to claim`, 'TEAM_TASK_BLOCKED') } next = { ...current, status: 'in_progress', ownerId: caller.id } @@ -185,7 +181,7 @@ export class TeamTaskBoard { next = this.withoutOwner({ ...current, status: 'pending' }) break } - if (!this.taskReady(state, current)) throw new TeamError(`team task "${current.id}" is blocked`, 'TEAM_TASK_BLOCKED') + if (!taskReady(state, current)) throw new TeamError(`team task "${current.id}" is blocked`, 'TEAM_TASK_BLOCKED') const assignee = resolveActiveMember(root, state, request.owner) next = { ...current, status: 'in_progress', ownerId: assignee.id } break @@ -210,7 +206,7 @@ export class TeamTaskBoard { } this.assertTaskGraph(state, task) await this.journal.appendAndFlush(root, 'team/task', { version: 2, teamId: TeamId(root.id), task }) - return this.taskView(root, state, task) + return projectTaskView(state, task) }) } @@ -251,47 +247,9 @@ export class TeamTaskBoard { } } - /** Whether all current blockers completed. */ - private taskReady(state: TeamState, task: TeamTaskSnapshot): boolean { - return task.blockedBy.every(id => state.tasks.find(candidate => candidate.id === id)?.status === 'completed') - } - /** Remove an optional owner field under exactOptionalPropertyTypes. */ private withoutOwner(task: TeamTaskSnapshot): TeamTaskSnapshot { const { ownerId: _ownerId, ...without } = task return without } - - /** - * Build one task view with owner name, readiness, and advisory write overlaps. - * A committing caller may pass its pre-append state because `task` supplies the - * new value explicitly; owner names, blocker readiness, and other task scopes - * do not change when that snapshot is appended. - */ - private taskView(root: Agent, state: TeamState, task: TeamTaskSnapshot): TeamTaskView { - const ownerName = task.ownerId === undefined - ? undefined - : task.ownerId === root.id - ? 'lead' - : state.members.find(member => member.id === task.ownerId)?.name - const warnings = new Set() - for (const other of state.tasks) { - if (other.id === task.id || other.status !== 'in_progress') continue - if (task.writeScopes.some(left => other.writeScopes.some(right => scopesOverlap(left, right)))) { - warnings.add(`write scopes overlap with ${other.id}`) - } - } - return { - id: task.id, - revision: task.revision, - subject: task.subject, - description: task.description, - status: task.status, - blockedBy: structuredClone(task.blockedBy), - writeScopes: structuredClone(task.writeScopes), - ...ownerName === undefined ? {} : { ownerName }, - ready: task.status === 'pending' && this.taskReady(state, task), - writeScopeWarnings: [...warnings], - } - } } diff --git a/packages/experimental/agent-team/src/task-view.ts b/packages/experimental/agent-team/src/task-view.ts new file mode 100644 index 0000000000..569ff38261 --- /dev/null +++ b/packages/experimental/agent-team/src/task-view.ts @@ -0,0 +1,62 @@ +/** Pure task-view derivation shared by the task board and the client projection. */ + +import { brandString } from '@deepseek-ai/dsh-brand' +import type { SessionId } from '@deepseek-ai/dsh-session' +import type { TeamState } from './projection.ts' +import type { TeamTaskSnapshot, TeamTaskView } from './types.ts' + +/** + * Whether two normalized file or directory prefixes overlap on path components. + * @param left - normalized write scope. + * @param right - normalized write scope. + * @returns whether either scope contains the other. + */ +function scopesOverlap(left: string, right: string): boolean { + return left === right || left.startsWith(`${right}/`) || right.startsWith(`${left}/`) +} + +/** + * Whether every current blocker of one task completed. + * @param state - Team state supplying sibling tasks. + * @param task - durable task snapshot to test. + * @returns whether the task has no incomplete blockers. + */ +export function taskReady(state: TeamState, task: TeamTaskSnapshot): boolean { + return task.blockedBy.every(id => state.tasks.find(candidate => candidate.id === id)?.status === 'completed') +} + +/** + * Derive one task view with owner name, readiness, and advisory write overlaps. + * A committing caller may pass its pre-append state because `task` supplies the + * new value explicitly; owner names, blocker readiness, and other task scopes + * do not change when that snapshot is appended. + * @param state - Team state supplying members and sibling tasks. + * @param task - durable task snapshot to view. + * @returns a detached task view. + */ +export function projectTaskView(state: TeamState, task: TeamTaskSnapshot): TeamTaskView { + const ownerName = task.ownerId === undefined + ? undefined + : task.ownerId === brandString(state.id) + ? 'lead' + : state.members.find(member => member.id === task.ownerId)?.name + const warnings = new Set() + for (const other of state.tasks) { + if (other.id === task.id || other.status !== 'in_progress') continue + if (task.writeScopes.some(left => other.writeScopes.some(right => scopesOverlap(left, right)))) { + warnings.add(`write scopes overlap with ${other.id}`) + } + } + return { + id: task.id, + revision: task.revision, + subject: task.subject, + description: task.description, + status: task.status, + blockedBy: [...task.blockedBy], + writeScopes: [...task.writeScopes], + ...ownerName === undefined ? {} : { ownerName }, + ready: task.status === 'pending' && taskReady(state, task), + writeScopeWarnings: [...warnings], + } +} diff --git a/packages/experimental/agent-team/src/types.ts b/packages/experimental/agent-team/src/types.ts index 77aa6fe620..9921886f6f 100644 --- a/packages/experimental/agent-team/src/types.ts +++ b/packages/experimental/agent-team/src/types.ts @@ -96,10 +96,32 @@ export interface TeamTaskView { readonly writeScopeWarnings: string[] } -/** Point-in-time roster and task-board projection returned to browser clients. */ -export interface TeamView { - readonly members: TeamMemberView[] +/** One durable roster row published through the `agentTeam` Session projection. */ +export interface TeamMemberProjection { + readonly id: SessionId + readonly name: string + readonly role: 'lead' | 'teammate' + /** Durable lifecycle; the Lead row is always `active`. Turn activity comes from Session status. */ + readonly phase: TeamMemberPhase + readonly error?: string +} + +/** + * Durable Team state published to browser clients through the Lead Session's + * `agentTeam` projection. `failure` names the first rejected persisted Team + * record; members and tasks then stay at the last valid state. + */ +export interface TeamProjection { + readonly members: TeamMemberProjection[] readonly tasks: TeamTaskView[] + readonly failure?: string +} + +declare module '@deepseek-ai/dsh-session-projection/types' { + interface SessionProjectionMap { + /** Durable roster and non-deleted task board of the Team rooted at the projected Session. */ + agentTeam: TeamProjection + } } /** One peer message retained until its target Session records it. */ diff --git a/packages/experimental/agent-team/tests/built-lib.e2e.ts b/packages/experimental/agent-team/tests/built-lib.e2e.ts index 4e2d762fd6..d171624d80 100644 --- a/packages/experimental/agent-team/tests/built-lib.e2e.ts +++ b/packages/experimental/agent-team/tests/built-lib.e2e.ts @@ -1,4 +1,4 @@ -/** Plain-Node smoke for the built Agent Teams service and Remote contribution. */ +/** Plain-Node smoke for the built Agent Teams service. */ import { execFile } from 'node:child_process' import { existsSync } from 'node:fs' @@ -13,21 +13,17 @@ const artifactUrl = (path: string): string => pathToFileURL(artifact(path)).href const requiredArtifacts = [ 'packages/experimental/agent-team/lib/index.js', - 'packages/experimental/agent-team/lib/typert.remote-client.js', ].every(path => existsSync(artifact(path))) describe.skipIf(!requiredArtifacts)('Agent Teams built LIB service', () => { - it('loads the Host service and its generated browser contribution under plain Node', async () => { + it('loads the Host service under plain Node', async () => { const urls = { host: artifactUrl('packages/experimental/agent-team/lib/index.js'), - remote: artifactUrl('packages/experimental/agent-team/lib/typert.remote-client.js'), } const script = ` const host = await import(${JSON.stringify(urls.host)}) - const remote = await import(${JSON.stringify(urls.remote)}) console.log(JSON.stringify({ className: host.default.name, - methods: remote.default.descriptors.map(descriptor => descriptor.id), })) ` @@ -35,13 +31,9 @@ describe.skipIf(!requiredArtifacts)('Agent Teams built LIB service', () => { expect(result.exitCode, `stderr:\n${result.stderr}`).toBe(0) const output = JSON.parse(result.stdout.trim().split('\n').at(-1) ?? '{}') as { className: string - methods: string[] } expect(output).toEqual({ className: 'TeamService', - methods: [ - '@deepseek-ai/dsh-experimental-agent-team#agentTeams/view', - ], }) }) }) diff --git a/packages/experimental/agent-team/tests/persistence.spec.ts b/packages/experimental/agent-team/tests/persistence.spec.ts index 5237fc0301..f7ee5d41cb 100644 --- a/packages/experimental/agent-team/tests/persistence.spec.ts +++ b/packages/experimental/agent-team/tests/persistence.spec.ts @@ -26,9 +26,9 @@ const contexts = new Set() /** Detached durable Team read through the same projection definition as the service. */ function durable(agent: Agent): { - members: TeamMemberSnapshot[] - tasks: TeamTaskSnapshot[] - pendingMessages: TeamMessageSnapshot[] + members: readonly TeamMemberSnapshot[] + tasks: readonly TeamTaskSnapshot[] + pendingMessages: readonly TeamMessageSnapshot[] } { let projected = teamProjectionDefinition.init(agent.session.header) for (const event of agent.session.snapshotEvents()) projected = teamProjectionDefinition.apply(projected, event) diff --git a/packages/experimental/agent-team/tests/projection-events.spec.ts b/packages/experimental/agent-team/tests/projection-events.spec.ts index 333f157eb0..2266b41483 100644 --- a/packages/experimental/agent-team/tests/projection-events.spec.ts +++ b/packages/experimental/agent-team/tests/projection-events.spec.ts @@ -4,7 +4,7 @@ import { Context } from '@deepseek-ai/cordis' import SessionStore, { SESSION_FORMAT_VERSION, SessionId, SessionLogOffset, SessionSeq } from '@deepseek-ai/dsh-session' import type { SessionEvent, SessionEventMap, SessionEventType } from '@deepseek-ai/dsh-session' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' -import { teamProjectionDefinition } from '../src/projection.ts' +import { teamProjectionDefinition, teamProjectionView } from '../src/projection.ts' import type { TeamProjectionState, TeamState } from '../src/projection.ts' import { TeamId, TeamMessageId, TeamTaskId } from '../src/types.ts' import type { TeamMemberSnapshot, TeamMessageSnapshot, TeamTaskSnapshot } from '../src/types.ts' @@ -424,4 +424,161 @@ describe('Agent Teams projection events', () => { } as unknown as SessionEvent expect(isEmptyState(projectTeam(ROOT, [inherited]))).toBe(true) }) + + describe('copy-on-write state and client view', () => { + const memberEvent = event('team/member', { version: 2, teamId: TEAM, member: member() }, SessionSeq(0)) + const taskEvent = event('team/task', { version: 2, teamId: TEAM, task: task() }, SessionSeq(1)) + const queuedEvent = event('team/message/queued', { version: 2, teamId: TEAM, message: message() }, SessionSeq(2)) + const deliveredEvent = event('team/message/delivered', { + version: 2, teamId: TEAM, messageId: TeamMessageId('message-1'), targetId: CHILD, + }, SessionSeq(3)) + + it('returns the same state for unrelated events and a new state that replaces only the touched collection', () => { + const initial = project(ROOT, []) + expect(teamProjectionDefinition.apply(initial, { type: 'turn/start', data: { turn: 1 }, seq: 0, time: 0 } as SessionEvent)).toBe(initial) + expect(teamProjectionDefinition.apply(initial, event('team/member', { + version: 2, teamId: TeamId('ancestor'), member: member(), + }, SessionSeq(0)))).toBe(initial) + + const withMember = teamProjectionDefinition.apply(initial, memberEvent) + expect(withMember).not.toBe(initial) + expect(initial.members).toEqual([]) + expect(withMember.members).toHaveLength(1) + expect(withMember.tasks).toBe(initial.tasks) + + const withTask = teamProjectionDefinition.apply(withMember, taskEvent) + expect(withTask.members).toBe(withMember.members) + expect(withTask.tasks).not.toBe(withMember.tasks) + expect(withMember.tasks).toEqual([]) + + const withMail = teamProjectionDefinition.apply(withTask, queuedEvent) + expect(withMail.members).toBe(withTask.members) + expect(withMail.tasks).toBe(withTask.tasks) + expect(withMail.messages).not.toBe(withTask.messages) + expect(withTask.messages).toEqual([]) + + const withReceipt = teamProjectionDefinition.apply(withMail, deliveredEvent) + expect(withReceipt.delivered).not.toBe(withMail.delivered) + expect(withMail.delivered).toEqual([]) + expect(teamProjectionDefinition.apply(withReceipt, { ...deliveredEvent, seq: SessionSeq(4) })).not.toBe(withReceipt) + }) + + it('enters failure through a new state that keeps the last valid collections', () => { + const valid = project(ROOT, [memberEvent, taskEvent]) + const failed = teamProjectionDefinition.apply(valid, event('team/task', { + version: 1 as 2, teamId: TEAM, task: task(), + }, SessionSeq(2))) + expect(failed).not.toBe(valid) + expect(valid.failure).toBeUndefined() + expect(failed.failure).toMatch(/unsupported Agent Teams event version 1/) + expect(failed.members).toBe(valid.members) + expect(failed.tasks).toBe(valid.tasks) + expect(teamProjectionDefinition.apply(failed, queuedEvent)).toBe(failed) + }) + + it('views durable roster and non-deleted derived tasks and reuses the view across mailbox-only changes', () => { + const activeMember = event('team/member', { version: 2, teamId: TEAM, member: member({ phase: 'active' }) }, SessionSeq(1)) + const claimed = event('team/task', { + version: 2, teamId: TEAM, task: task({ revision: 2, status: 'in_progress', ownerId: CHILD, writeScopes: ['src'] }), + }, SessionSeq(2)) + const blocked = event('team/task', { + version: 2, teamId: TEAM, task: task({ id: TeamTaskId('task-2'), blockedBy: [TeamTaskId('task-1')], writeScopes: ['src/web'] }), + }, SessionSeq(3)) + const leadOwned = event('team/task', { + version: 2, teamId: TEAM, task: task({ id: TeamTaskId('task-3'), revision: 1, status: 'in_progress', ownerId: ROOT }), + }, SessionSeq(4)) + const deleted = event('team/task', { + version: 2, teamId: TEAM, task: task({ id: TeamTaskId('task-3'), revision: 2, status: 'deleted', ownerId: ROOT }), + }, SessionSeq(5)) + const state = project(ROOT, [memberEvent, activeMember, taskEvent, claimed, blocked, leadOwned, deleted]) + const view = teamProjectionView(state) + expect(teamProjectionDefinition.wire.viewSchema.parse(view)).toEqual(view) + expect(view).toEqual({ + members: [ + { id: ROOT, name: 'lead', role: 'lead', phase: 'active' }, + { id: CHILD, name: 'worker-a', role: 'teammate', phase: 'active' }, + ], + tasks: [ + { + id: 'task-1', revision: 2, subject: 'subject', description: 'description', status: 'in_progress', + blockedBy: [], writeScopes: ['src'], ownerName: 'worker-a', ready: false, writeScopeWarnings: [], + }, + { + id: 'task-2', revision: 1, subject: 'subject', description: 'description', status: 'pending', + blockedBy: ['task-1'], writeScopes: ['src/web'], ready: false, writeScopeWarnings: ['write scopes overlap with task-1'], + }, + ], + }) + expect(view).not.toHaveProperty('failure') + expect(teamProjectionView(state)).toBe(view) + + const withMail = teamProjectionDefinition.apply(state, { ...queuedEvent, seq: SessionSeq(6) }) + expect(teamProjectionView(withMail)).toBe(view) + const withMember = teamProjectionDefinition.apply(withMail, event('team/member', { + version: 2, teamId: TEAM, member: member({ id: SessionId('child-b'), name: 'worker-b' }), + }, SessionSeq(7))) + const memberView = teamProjectionView(withMember) + expect(memberView).not.toBe(view) + expect(memberView.members.at(-1)).toEqual({ + id: 'child-b', name: 'worker-b', role: 'teammate', phase: 'provisioning', + }) + + const failedMember = event('team/member', { + version: 2, teamId: TEAM, member: member({ id: SessionId('child-b'), name: 'worker-b', phase: 'failed', error: 'provider failed' }), + }, SessionSeq(8)) + expect(teamProjectionView(teamProjectionDefinition.apply(withMember, failedMember)).members.at(-1)) + .toMatchObject({ phase: 'failed', error: 'provider failed' }) + }) + + it('publishes the failure with the last valid roster and tasks', () => { + const state = project(ROOT, [memberEvent, taskEvent, event('team/task', { + version: 1 as 2, teamId: TEAM, task: task(), + }, SessionSeq(2))]) + const view = teamProjectionView(state) + expect(view.failure).toMatch(/unsupported Agent Teams event version 1/) + expect(view.members).toHaveLength(2) + expect(view.tasks).toHaveLength(1) + expect(teamProjectionDefinition.wire.viewSchema.parse(view)).toEqual(view) + }) + + it('drives live agentTeam publications per Session through the projection registry', async () => { + const ctx = new Context() + onTestFinished(() => ctx.fiber.dispose()) + await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) + ctx.effect(() => ctx.sessionProjections.register(teamProjectionDefinition)) + const published: Array<{ session: SessionId; key: string; value: unknown }> = [] + ctx.sessionProjections.onChanged((session, key, value) => { + published.push({ session: session.id, key, value }) + }) + const lead = ctx.sessions.create(ROOT) + const other = ctx.sessions.create(SessionId('other-root')) + expect(ctx.sessionProjections.snapshot(lead).values['agentTeam']).toEqual({ + members: [{ id: ROOT, name: 'lead', role: 'lead', phase: 'active' }], + tasks: [], + }) + + lead.append('team/task', taskEvent.data) + expect(published).toHaveLength(1) + expect(published[0]).toMatchObject({ + session: ROOT, + key: 'agentTeam', + value: { tasks: [{ id: 'task-1', ready: true }] }, + }) + + lead.append('team/message/queued', queuedEvent.data) + lead.append('turn/start', { turn: 1 }) + expect(published).toHaveLength(1) + + other.append('team/task', { version: 2, teamId: TeamId(other.id), task: task() }) + expect(published).toHaveLength(2) + expect(published[1]).toMatchObject({ + session: other.id, + value: { members: [{ id: other.id, name: 'lead', role: 'lead', phase: 'active' }] }, + }) + expect(ctx.sessionProjections.snapshot(lead).values['agentTeam']).toMatchObject({ + tasks: [{ id: 'task-1' }], + }) + }) + }) }) diff --git a/packages/experimental/agent-team/tests/team.spec.ts b/packages/experimental/agent-team/tests/team.spec.ts index 87d63353ed..af939b0108 100644 --- a/packages/experimental/agent-team/tests/team.spec.ts +++ b/packages/experimental/agent-team/tests/team.spec.ts @@ -22,17 +22,19 @@ import { TestSessionQuery } from './test-session-query.ts' const SIGNAL = new AbortController().signal const roots: string[] = [] +const contexts: Context[] = [] -afterEach(() => { +afterEach(async () => { vi.useRealTimers() + for (const ctx of contexts.splice(0).reverse()) await ctx.fiber.dispose() for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) }) /** Detached durable Team read through the same projection definition as the service. */ function durable(agent: Agent): { - members: TeamMemberSnapshot[] - tasks: TeamTaskSnapshot[] - pendingMessages: TeamMessageSnapshot[] + members: readonly TeamMemberSnapshot[] + tasks: readonly TeamTaskSnapshot[] + pendingMessages: readonly TeamMessageSnapshot[] } { let projected = teamProjectionDefinition.init(agent.session.header) for (const event of agent.session.snapshotEvents()) projected = teamProjectionDefinition.apply(projected, event) @@ -60,6 +62,7 @@ async function setup( config: ConstructorParameters[1] = {}, ) { const ctx = new Context() + contexts.push(ctx) await mountAgentLoopTestDependencies(ctx) const storageRoot = mkdtempSync(join(tmpdir(), 'dsh-team-')) roots.push(storageRoot) @@ -167,6 +170,7 @@ describe('Team identity and provisioning', () => { it('supports direct-constructor defaults and recovers roots that already exist', async () => { const ctx = new Context() + contexts.push(ctx) await mountAgentLoopTestDependencies(ctx) const storageRoot = mkdtempSync(join(tmpdir(), 'dsh-team-direct-')) roots.push(storageRoot) @@ -873,24 +877,6 @@ describe('Team shared task DAG', () => { }) }) -describe('Team Remote API', () => { - it('reads tasks created and updated by Team agents', async () => { - const { ctx, lead } = await setup([]) - expect(ctx.agentTeams.typertRemote).toMatchObject({ serviceKey: 'agentTeams', namespace: 'agentTeams' }) - expect(ctx.agentTeams.remoteView(lead)).toEqual({ - members: [expect.objectContaining({ name: 'lead', role: 'lead', status: 'inactive' })], - tasks: [], - }) - const created = await ctx.agentTeams.createTask(lead, { - subject: 'Agent task', description: 'Created by the Team Lead', - }) - const updated = await ctx.agentTeams.updateTask(lead, { - taskId: created.id, expectedRevision: 1, action: 'claim', - }) - expect(ctx.agentTeams.remoteView(lead).tasks).toEqual([updated]) - }) -}) - describe('Team mailbox and waiting', () => { it('steers a message addressed to the Lead and checkpoints its receipt', async () => { const { ctx, lead } = await setup(['hang']) @@ -1364,6 +1350,7 @@ describe('Team mailbox and waiting', () => { it('waits for one change, supports cancellation, times out, and releases waiters on HMR disposal', async () => { const ctx = new Context() + contexts.push(ctx) await mountAgentLoopTestDependencies(ctx) const storageRoot = mkdtempSync(join(tmpdir(), 'dsh-team-wait-')) roots.push(storageRoot) diff --git a/packages/experimental/agent-team/tsconfig.json b/packages/experimental/agent-team/tsconfig.json index 862b4b5836..669a476e44 100644 --- a/packages/experimental/agent-team/tsconfig.json +++ b/packages/experimental/agent-team/tsconfig.json @@ -13,7 +13,6 @@ { "path": "../../llm/llm" }, { "path": "../../core/session" }, { "path": "../../core/agent" }, - { "path": "../../typert/protocol" }, { "path": "../../subagent/subagent" }, { "path": "../../session/session-persistence" }, { "path": "../../session/session-projection" }, diff --git a/packages/experimental/api-speech-to-text/README.i18n.yaml b/packages/experimental/api-speech-to-text/README.i18n.yaml index 54e9523ced..454b3e5dc8 100644 --- a/packages/experimental/api-speech-to-text/README.i18n.yaml +++ b/packages/experimental/api-speech-to-text/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/experimental/api-speech-to-text/README.md -README.md: ecc83c15b4217323c074733ae77259261598803c -README.zh.md: 46ac6a16c902c32f2effb23a98141b1afbdccd37 +README.md: 113c1f94459329051182ab2503b5220ffa9ad50b +README.zh.md: bc853bd00da978a42d56dcbefcd98109efc78491 diff --git a/packages/experimental/api-speech-to-text/README.md b/packages/experimental/api-speech-to-text/README.md index ecc83c15b4..113c1f9445 100644 --- a/packages/experimental/api-speech-to-text/README.md +++ b/packages/experimental/api-speech-to-text/README.md @@ -27,6 +27,8 @@ The `speech` Remote connects browser recordings to `ctx.speechToText`. It expose Compose with the speech Service Definition and Typert. `maxAudioBytes` and `maxDurationSeconds` limit accepted recordings. The browser UI mounts this package’s generated `/remote` contribution when enabled. +`prepare(providerId, { downloadSource })` forwards one advertised source to the provider; omission keeps its configured policy. The catalog carries `downloadSources` for the picker. The provider rejects unavailable choices and changes to an active task’s source. + ----- diff --git a/packages/experimental/api-speech-to-text/README.zh.md b/packages/experimental/api-speech-to-text/README.zh.md index 46ac6a16c9..bc853bd00d 100644 --- a/packages/experimental/api-speech-to-text/README.zh.md +++ b/packages/experimental/api-speech-to-text/README.zh.md @@ -27,6 +27,8 @@ kind: "package-reference" 与语音服务定义和 Typert 组合。`maxAudioBytes` 与 `maxDurationSeconds` 限制接收的录音。浏览器 UI 启用时挂载本包生成的 `/remote` 贡献。 +`prepare(providerId, { downloadSource })` 将公布的一个下载源转发给提供方,省略时保留其配置策略。目录通过 `downloadSources` 提供选择器选项。提供方拒绝不可用的源,以及对进行中任务的源变更。 + ----- diff --git a/packages/experimental/api-speech-to-text/package.json b/packages/experimental/api-speech-to-text/package.json index dc224aba5e..2b6235edb2 100644 --- a/packages/experimental/api-speech-to-text/package.json +++ b/packages/experimental/api-speech-to-text/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-experimental-api-speech-to-text", "description": "Authenticated experimental speech transcription for browser clients", - "version": "0.1.7-alpha.1", + "version": "0.1.7-alpha.2", "publishConfig": { "access": "public" }, @@ -44,17 +44,17 @@ ], "license": "MIT", "dependencies": { - "@deepseek-ai/schemastery": "workspace:^", + "@deepseek-ai/schemastery": "workspace:~", "zod": "^4.4.3" }, "peerDependencies": { - "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-experimental-speech-to-text": "workspace:^", - "@deepseek-ai/dsh-typert-protocol": "workspace:^" + "@deepseek-ai/cordis": "workspace:~", + "@deepseek-ai/dsh-experimental-speech-to-text": "workspace:*", + "@deepseek-ai/dsh-typert-protocol": "workspace:*" }, "devDependencies": { - "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-experimental-speech-to-text": "workspace:^", - "@deepseek-ai/dsh-typert-protocol": "workspace:^" + "@deepseek-ai/cordis": "workspace:~", + "@deepseek-ai/dsh-experimental-speech-to-text": "workspace:*", + "@deepseek-ai/dsh-typert-protocol": "workspace:*" } } diff --git a/packages/experimental/api-speech-to-text/src/index.ts b/packages/experimental/api-speech-to-text/src/index.ts index ce53084e4b..4d19d7c5af 100644 --- a/packages/experimental/api-speech-to-text/src/index.ts +++ b/packages/experimental/api-speech-to-text/src/index.ts @@ -3,7 +3,7 @@ import { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' import { Remote, RemoteError, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' import type {} from '@deepseek-ai/dsh-experimental-speech-to-text' -import type { SpeechProviderId, SpeechSelectionPatch, Transcript } from '@deepseek-ai/dsh-experimental-speech-to-text/types' +import type { SpeechPreparationOptions, SpeechProviderId, SpeechSelectionPatch, Transcript } from '@deepseek-ai/dsh-experimental-speech-to-text/types' import type { SpeechCatalog, TranscriptionRequest } from './types.ts' import { validateWave } from '@deepseek-ai/dsh-experimental-speech-to-text/wave' @@ -66,9 +66,10 @@ export default class SpeechController extends TypertRemoteService { /** * Start or join one Host-owned preparation task. * @param providerId - selected recognizer. + * @param options - task-local source selection validated by the provider. */ @Remote - prepare(providerId: SpeechProviderId): void { this.ctx.speechToText.prepare(providerId) } + prepare(providerId: SpeechProviderId, options?: SpeechPreparationOptions): void { this.ctx.speechToText.prepare(providerId, options) } /** * Explicitly cancel resource preparation. diff --git a/packages/experimental/api-speech-to-text/tests/controller.spec.ts b/packages/experimental/api-speech-to-text/tests/controller.spec.ts index 0cf59f5449..caa2145ea2 100644 --- a/packages/experimental/api-speech-to-text/tests/controller.spec.ts +++ b/packages/experimental/api-speech-to-text/tests/controller.spec.ts @@ -39,6 +39,20 @@ it('lists limits without inference and forwards explicit provider and language', expect(recognize).toHaveBeenLastCalledWith(expect.objectContaining({ language: 'auto' }), expect.any(AbortSignal)) }) +it('forwards an explicit download source to the registered preparation owner', async () => { + const ctx = new Context(); roots.push(ctx) + const speech = new SpeechToText(ctx, SpeechToText.Config({ defaultProvider: id })) + const prepare = vi.fn() + speech.register({ info: { id, name: 'local', location: 'host-local', languages: ['auto'], downloadSources: ['https://hf-mirror.com'] }, + preparation: { snapshot: () => ({ phase: 'unprepared' }), subscribe: () => () => {}, prepare, cancel: async () => {} }, + transcribe: async () => ({ text: '', audioSeconds: 0, inferenceSeconds: 0 }), + }) + const api = new SpeechController(ctx, { maxAudioBytes: 32044, maxDurationSeconds: 1 }) + expect(api.catalog().providers[0]?.downloadSources).toEqual(['https://hf-mirror.com']) + api.prepare(id, { downloadSource: 'https://hf-mirror.com' }) + expect(prepare).toHaveBeenCalledWith({ downloadSource: 'https://hf-mirror.com' }) +}) + it('rejects oversized, noncanonical, malformed and overlong audio without inference', async () => { const { api, recognize } = fixture() for (const audioBase64 of ['x'.repeat(50000), 'AAAA?', '', 'QQ==', recording() + '\n']) { diff --git a/packages/experimental/auto-review/package.json b/packages/experimental/auto-review/package.json index d29d47aa09..c6cbbd994a 100644 --- a/packages/experimental/auto-review/package.json +++ b/packages/experimental/auto-review/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-experimental-auto-review", "description": "Per-tool LLM authorization review for the DeepSeek Harness Auto permission preset", - "version": "0.1.7-alpha.1", + "version": "0.1.7-alpha.2", "repository": { "type": "git", "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", @@ -28,46 +28,46 @@ ], "license": "MIT", "peerDependencies": { - "@deepseek-ai/dsh-agent": "workspace:^", - "@deepseek-ai/dsh-agent-instructions": "workspace:^", - "@deepseek-ai/dsh-llm": "workspace:^", - "@deepseek-ai/dsh-permission-presets": "workspace:^", - "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/dsh-tools": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/dsh-agent": "workspace:*", + "@deepseek-ai/dsh-agent-instructions": "workspace:*", + "@deepseek-ai/dsh-llm": "workspace:*", + "@deepseek-ai/dsh-permission-presets": "workspace:*", + "@deepseek-ai/dsh-session": "workspace:*", + "@deepseek-ai/dsh-tools": "workspace:*", + "@deepseek-ai/cordis": "workspace:~" }, "dependencies": { - "@deepseek-ai/dsh-util-values": "workspace:^" + "@deepseek-ai/dsh-util-values": "workspace:*" }, "devDependencies": { - "@deepseek-ai/dsh-agent": "workspace:^", - "@deepseek-ai/dsh-agent-instructions": "workspace:^", - "@deepseek-ai/dsh-agent-loop": "workspace:^", - "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^", - "@deepseek-ai/dsh-bash-sandbox": "workspace:^", - "@deepseek-ai/dsh-compaction": "workspace:^", - "@deepseek-ai/dsh-fs-local": "workspace:^", - "@deepseek-ai/dsh-fs-observation-policy": "workspace:^", - "@deepseek-ai/dsh-fs-sandbox": "workspace:^", - "@deepseek-ai/dsh-llm": "workspace:^", - "@deepseek-ai/dsh-llm-deepseek": "workspace:^", - "@deepseek-ai/dsh-permission-presets": "workspace:^", - "@deepseek-ai/dsh-ptc-runtime-node": "workspace:^", - "@deepseek-ai/dsh-sandbox": "workspace:^", - "@deepseek-ai/dsh-sandbox-policy": "workspace:^", - "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/dsh-session-projection": "workspace:^", - "@deepseek-ai/dsh-shell": "workspace:^", - "@deepseek-ai/dsh-shell-env": "workspace:^", - "@deepseek-ai/dsh-subagent": "workspace:^", - "@deepseek-ai/dsh-subprocess-local": "workspace:^", - "@deepseek-ai/dsh-system-prompt": "workspace:^", - "@deepseek-ai/dsh-tool-bash": "workspace:^", - "@deepseek-ai/dsh-tool-fs": "workspace:^", - "@deepseek-ai/dsh-tool-subagent": "workspace:^", - "@deepseek-ai/dsh-tools": "workspace:^", - "@deepseek-ai/dsh-user-approval": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/dsh-agent": "workspace:*", + "@deepseek-ai/dsh-agent-instructions": "workspace:*", + "@deepseek-ai/dsh-agent-loop": "workspace:*", + "@deepseek-ai/dsh-agent-loop-testkit": "workspace:*", + "@deepseek-ai/dsh-bash-sandbox": "workspace:*", + "@deepseek-ai/dsh-compaction": "workspace:*", + "@deepseek-ai/dsh-fs-local": "workspace:*", + "@deepseek-ai/dsh-fs-observation-policy": "workspace:*", + "@deepseek-ai/dsh-fs-sandbox": "workspace:*", + "@deepseek-ai/dsh-llm": "workspace:*", + "@deepseek-ai/dsh-llm-deepseek": "workspace:*", + "@deepseek-ai/dsh-permission-presets": "workspace:*", + "@deepseek-ai/dsh-ptc-runtime-node": "workspace:*", + "@deepseek-ai/dsh-sandbox": "workspace:*", + "@deepseek-ai/dsh-sandbox-policy": "workspace:*", + "@deepseek-ai/dsh-session": "workspace:*", + "@deepseek-ai/dsh-session-projection": "workspace:*", + "@deepseek-ai/dsh-shell": "workspace:*", + "@deepseek-ai/dsh-shell-env": "workspace:*", + "@deepseek-ai/dsh-subagent": "workspace:*", + "@deepseek-ai/dsh-subprocess-local": "workspace:*", + "@deepseek-ai/dsh-system-prompt": "workspace:*", + "@deepseek-ai/dsh-tool-bash": "workspace:*", + "@deepseek-ai/dsh-tool-fs": "workspace:*", + "@deepseek-ai/dsh-tool-subagent": "workspace:*", + "@deepseek-ai/dsh-tools": "workspace:*", + "@deepseek-ai/dsh-user-approval": "workspace:*", + "@deepseek-ai/cordis": "workspace:~" }, "publishConfig": { "access": "public" diff --git a/packages/experimental/browser-use-chrome-devtools-mcp/package.json b/packages/experimental/browser-use-chrome-devtools-mcp/package.json index 03a619848e..73317d1ead 100644 --- a/packages/experimental/browser-use-chrome-devtools-mcp/package.json +++ b/packages/experimental/browser-use-chrome-devtools-mcp/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-experimental-browser-use-chrome-devtools-mcp", "description": "Experimental per-Session Chromium browser tools through chrome-devtools-mcp", - "version": "0.1.7-alpha.1", + "version": "0.1.7-alpha.2", "publishConfig": { "access": "public" }, @@ -27,27 +27,27 @@ ], "license": "MIT", "dependencies": { - "@deepseek-ai/dsh-experimental-browser-use-runtime": "workspace:^", + "@deepseek-ai/dsh-experimental-browser-use-runtime": "workspace:*", "chrome-devtools-mcp": "1.9.0" }, "peerDependencies": { - "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-browser-use": "workspace:^", - "@deepseek-ai/dsh-agent": "workspace:^", - "@deepseek-ai/dsh-tools": "workspace:^", - "@deepseek-ai/dsh-system-prompt": "workspace:^" + "@deepseek-ai/cordis": "workspace:~", + "@deepseek-ai/dsh-browser-use": "workspace:*", + "@deepseek-ai/dsh-agent": "workspace:*", + "@deepseek-ai/dsh-tools": "workspace:*", + "@deepseek-ai/dsh-system-prompt": "workspace:*" }, "devDependencies": { - "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/cordis-plugin-loader": "workspace:^", - "@deepseek-ai/cordis-plugin-include": "workspace:^", - "@deepseek-ai/dsh-browser-use": "workspace:^", - "@deepseek-ai/dsh-agent": "workspace:^", - "@deepseek-ai/dsh-agent-loop": "workspace:^", - "@deepseek-ai/dsh-llm": "workspace:^", - "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/dsh-tools": "workspace:^", - "@deepseek-ai/dsh-system-prompt": "workspace:^", - "@deepseek-ai/dsh-session-projection": "workspace:^" + "@deepseek-ai/cordis": "workspace:~", + "@deepseek-ai/cordis-plugin-loader": "workspace:~", + "@deepseek-ai/cordis-plugin-include": "workspace:~", + "@deepseek-ai/dsh-browser-use": "workspace:*", + "@deepseek-ai/dsh-agent": "workspace:*", + "@deepseek-ai/dsh-agent-loop": "workspace:*", + "@deepseek-ai/dsh-llm": "workspace:*", + "@deepseek-ai/dsh-session": "workspace:*", + "@deepseek-ai/dsh-tools": "workspace:*", + "@deepseek-ai/dsh-system-prompt": "workspace:*", + "@deepseek-ai/dsh-session-projection": "workspace:*" } } diff --git a/packages/experimental/browser-use-playwright-mcp/package.json b/packages/experimental/browser-use-playwright-mcp/package.json index 8ebd6c3776..963c8cbdf3 100644 --- a/packages/experimental/browser-use-playwright-mcp/package.json +++ b/packages/experimental/browser-use-playwright-mcp/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-experimental-browser-use-playwright-mcp", "description": "Experimental per-Session Chromium browser tools through @playwright/mcp", - "version": "0.1.7-alpha.1", + "version": "0.1.7-alpha.2", "publishConfig": { "access": "public" }, @@ -27,27 +27,27 @@ ], "license": "MIT", "dependencies": { - "@deepseek-ai/dsh-experimental-browser-use-runtime": "workspace:^", + "@deepseek-ai/dsh-experimental-browser-use-runtime": "workspace:*", "@playwright/mcp": "0.0.80" }, "peerDependencies": { - "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-browser-use": "workspace:^", - "@deepseek-ai/dsh-agent": "workspace:^", - "@deepseek-ai/dsh-tools": "workspace:^", - "@deepseek-ai/dsh-system-prompt": "workspace:^" + "@deepseek-ai/cordis": "workspace:~", + "@deepseek-ai/dsh-browser-use": "workspace:*", + "@deepseek-ai/dsh-agent": "workspace:*", + "@deepseek-ai/dsh-tools": "workspace:*", + "@deepseek-ai/dsh-system-prompt": "workspace:*" }, "devDependencies": { - "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/cordis-plugin-loader": "workspace:^", - "@deepseek-ai/cordis-plugin-include": "workspace:^", - "@deepseek-ai/dsh-browser-use": "workspace:^", - "@deepseek-ai/dsh-agent": "workspace:^", - "@deepseek-ai/dsh-agent-loop": "workspace:^", - "@deepseek-ai/dsh-llm": "workspace:^", - "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/dsh-tools": "workspace:^", - "@deepseek-ai/dsh-system-prompt": "workspace:^", - "@deepseek-ai/dsh-session-projection": "workspace:^" + "@deepseek-ai/cordis": "workspace:~", + "@deepseek-ai/cordis-plugin-loader": "workspace:~", + "@deepseek-ai/cordis-plugin-include": "workspace:~", + "@deepseek-ai/dsh-browser-use": "workspace:*", + "@deepseek-ai/dsh-agent": "workspace:*", + "@deepseek-ai/dsh-agent-loop": "workspace:*", + "@deepseek-ai/dsh-llm": "workspace:*", + "@deepseek-ai/dsh-session": "workspace:*", + "@deepseek-ai/dsh-tools": "workspace:*", + "@deepseek-ai/dsh-system-prompt": "workspace:*", + "@deepseek-ai/dsh-session-projection": "workspace:*" } } diff --git a/packages/experimental/browser-use-runtime/package.json b/packages/experimental/browser-use-runtime/package.json index 68998b1a4e..2628535f4f 100644 --- a/packages/experimental/browser-use-runtime/package.json +++ b/packages/experimental/browser-use-runtime/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-experimental-browser-use-runtime", "description": "Session-owned browser resource lifecycles and MCP integration for experimental providers", - "version": "0.1.7-alpha.1", + "version": "0.1.7-alpha.2", "publishConfig": { "access": "public" }, @@ -32,35 +32,35 @@ ], "license": "MIT", "peerDependencies": { - "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-agent": "workspace:^", - "@deepseek-ai/dsh-browser-use": "workspace:^", - "@deepseek-ai/dsh-mcp-client": "workspace:^", - "@deepseek-ai/dsh-scope": "workspace:^", - "@deepseek-ai/dsh-tools": "workspace:^", - "@deepseek-ai/dsh-system-prompt": "workspace:^" + "@deepseek-ai/cordis": "workspace:~", + "@deepseek-ai/dsh-agent": "workspace:*", + "@deepseek-ai/dsh-browser-use": "workspace:*", + "@deepseek-ai/dsh-mcp-client": "workspace:*", + "@deepseek-ai/dsh-scope": "workspace:*", + "@deepseek-ai/dsh-tools": "workspace:*", + "@deepseek-ai/dsh-system-prompt": "workspace:*" }, "dependencies": { - "@deepseek-ai/schemastery": "workspace:^" + "@deepseek-ai/schemastery": "workspace:~" }, "devDependencies": { - "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-agent": "workspace:^", - "@deepseek-ai/dsh-browser-use": "workspace:^", - "@deepseek-ai/dsh-mcp-client": "workspace:^", - "@deepseek-ai/dsh-scope": "workspace:^", - "@deepseek-ai/dsh-tools": "workspace:^", - "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^", - "@deepseek-ai/dsh-agent-loop": "workspace:^", - "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/dsh-session-projection": "workspace:^", - "@deepseek-ai/dsh-system-prompt": "workspace:^", - "@deepseek-ai/dsh-mcp-resources": "workspace:^", - "@deepseek-ai/dsh-ptc-runtime": "workspace:^", - "@deepseek-ai/dsh-llm": "workspace:^", - "@deepseek-ai/cordis-plugin-loader": "workspace:^", - "@deepseek-ai/cordis-plugin-include": "workspace:^", - "@deepseek-ai/dsh-attachment-local": "workspace:^", - "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^" + "@deepseek-ai/cordis": "workspace:~", + "@deepseek-ai/dsh-agent": "workspace:*", + "@deepseek-ai/dsh-browser-use": "workspace:*", + "@deepseek-ai/dsh-mcp-client": "workspace:*", + "@deepseek-ai/dsh-scope": "workspace:*", + "@deepseek-ai/dsh-tools": "workspace:*", + "@deepseek-ai/dsh-agent-loop-testkit": "workspace:*", + "@deepseek-ai/dsh-agent-loop": "workspace:*", + "@deepseek-ai/dsh-session": "workspace:*", + "@deepseek-ai/dsh-session-projection": "workspace:*", + "@deepseek-ai/dsh-system-prompt": "workspace:*", + "@deepseek-ai/dsh-mcp-resources": "workspace:*", + "@deepseek-ai/dsh-ptc-runtime": "workspace:*", + "@deepseek-ai/dsh-llm": "workspace:*", + "@deepseek-ai/cordis-plugin-loader": "workspace:~", + "@deepseek-ai/cordis-plugin-include": "workspace:~", + "@deepseek-ai/dsh-attachment-local": "workspace:*", + "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:*" } } diff --git a/packages/experimental/browser-use-runtime/tests/shared-host-runtimes.spec.ts b/packages/experimental/browser-use-runtime/tests/shared-host-runtimes.spec.ts index 06267f03f3..cf575acf38 100644 --- a/packages/experimental/browser-use-runtime/tests/shared-host-runtimes.spec.ts +++ b/packages/experimental/browser-use-runtime/tests/shared-host-runtimes.spec.ts @@ -28,8 +28,8 @@ describe('profile installs share the installation host runtime instances', () => it('declares every identity-bearing host runtime as a peer, never a dependency', async () => { const manifest = JSON.parse(await readFile(manifestPath, 'utf8')) as Manifest for (const name of SHARED_HOST_RUNTIMES) { - expect(manifest.peerDependencies?.[name], `${name} must be a peerDependency`).toBe('workspace:^') - expect(manifest.devDependencies?.[name], `${name} must also be a devDependency`).toBe('workspace:^') + expect(manifest.peerDependencies?.[name], `${name} must be a peerDependency`).toBe('workspace:*') + expect(manifest.devDependencies?.[name], `${name} must also be a devDependency`).toBe('workspace:*') expect(manifest.dependencies?.[name], `${name} must not be a dependency`).toBeUndefined() } }) diff --git a/packages/experimental/browser-use-stagehand-native/package.json b/packages/experimental/browser-use-stagehand-native/package.json index 9c6a106167..cd9880acbb 100644 --- a/packages/experimental/browser-use-stagehand-native/package.json +++ b/packages/experimental/browser-use-stagehand-native/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-experimental-browser-use-stagehand-native", "description": "Experimental Stagehand browser tools with separately configured native models", - "version": "0.1.7-alpha.1", + "version": "0.1.7-alpha.2", "publishConfig": { "access": "public" }, @@ -32,37 +32,37 @@ ], "license": "MIT", "peerDependencies": { - "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-agent": "workspace:^", - "@deepseek-ai/dsh-browser-use": "workspace:^", - "@deepseek-ai/dsh-system-prompt": "workspace:^", - "@deepseek-ai/dsh-tools": "workspace:^" + "@deepseek-ai/cordis": "workspace:~", + "@deepseek-ai/dsh-agent": "workspace:*", + "@deepseek-ai/dsh-browser-use": "workspace:*", + "@deepseek-ai/dsh-system-prompt": "workspace:*", + "@deepseek-ai/dsh-tools": "workspace:*" }, "dependencies": { "@browserbasehq/stagehand": "4.1.0", - "@deepseek-ai/dsh-experimental-browser-use-runtime": "workspace:^", - "@deepseek-ai/dsh-mcp-client": "workspace:^", - "@deepseek-ai/dsh-util-values": "workspace:^", - "@deepseek-ai/schemastery": "workspace:^", + "@deepseek-ai/dsh-experimental-browser-use-runtime": "workspace:*", + "@deepseek-ai/dsh-mcp-client": "workspace:*", + "@deepseek-ai/dsh-util-values": "workspace:*", + "@deepseek-ai/schemastery": "workspace:~", "zod": "^4.4.3", - "@deepseek-ai/dsh-subprocess": "workspace:^", + "@deepseek-ai/dsh-subprocess": "workspace:*", "@puppeteer/browsers": "3.2.2" }, "devDependencies": { - "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/cordis-plugin-include": "workspace:^", - "@deepseek-ai/cordis-plugin-loader": "workspace:^", - "@deepseek-ai/dsh-agent": "workspace:^", - "@deepseek-ai/dsh-agent-loop": "workspace:^", - "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^", - "@deepseek-ai/dsh-attachment": "workspace:^", - "@deepseek-ai/dsh-attachment-local": "workspace:^", - "@deepseek-ai/dsh-browser-use": "workspace:^", - "@deepseek-ai/dsh-llm": "workspace:^", - "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/dsh-session-projection": "workspace:^", - "@deepseek-ai/dsh-system-prompt": "workspace:^", - "@deepseek-ai/dsh-tools": "workspace:^", + "@deepseek-ai/cordis": "workspace:~", + "@deepseek-ai/cordis-plugin-include": "workspace:~", + "@deepseek-ai/cordis-plugin-loader": "workspace:~", + "@deepseek-ai/dsh-agent": "workspace:*", + "@deepseek-ai/dsh-agent-loop": "workspace:*", + "@deepseek-ai/dsh-agent-loop-testkit": "workspace:*", + "@deepseek-ai/dsh-attachment": "workspace:*", + "@deepseek-ai/dsh-attachment-local": "workspace:*", + "@deepseek-ai/dsh-browser-use": "workspace:*", + "@deepseek-ai/dsh-llm": "workspace:*", + "@deepseek-ai/dsh-session": "workspace:*", + "@deepseek-ai/dsh-session-projection": "workspace:*", + "@deepseek-ai/dsh-system-prompt": "workspace:*", + "@deepseek-ai/dsh-tools": "workspace:*", "tsx": "^4.19.2" } } diff --git a/packages/experimental/client-ui-agent-team/README.i18n.yaml b/packages/experimental/client-ui-agent-team/README.i18n.yaml index 5965f254f5..3524cb25a3 100644 --- a/packages/experimental/client-ui-agent-team/README.i18n.yaml +++ b/packages/experimental/client-ui-agent-team/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/experimental/client-ui-agent-team/README.md -README.md: 253c243a680a989988ea65e553c14eff9a699155 -README.zh.md: 143c855b8b8a45a7d3777700aefa6a378401f20d +README.md: 17990a84ebe398b632e2e6224674efe09f401aa9 +README.zh.md: 90bed11f2886429f39d2e6b73d251df63256dab4 diff --git a/packages/experimental/client-ui-agent-team/README.md b/packages/experimental/client-ui-agent-team/README.md index 253c243a68..17990a84eb 100644 --- a/packages/experimental/client-ui-agent-team/README.md +++ b/packages/experimental/client-ui-agent-team/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -This package adds an Agent Teams action to the Web conversation header, where a user can inspect the current roster, inspect the shared task board, and navigate into a teammate's conversation. It reads authoritative Team state through the generated `ctx.remote.agentTeams` contribution and keeps ordinary child-history navigation on the stable addressed-subagent path. Choose it through the published experimental Agent Teams bundle. The browser projection does not extend the stable API Proxy, store Team state, or register model-facing input. +This package adds an Agent Teams action to the Web conversation header, where a user can inspect the current roster, inspect the shared task board, and navigate into a teammate's conversation. It reads the Lead Session's `agentTeam` projection from the shared Session store, where Host projection frames keep it current without a refresh control, and keeps ordinary child-history navigation on the stable addressed-subagent path. Choose it through the published experimental Agent Teams bundle. The browser projection does not extend the stable API Proxy, store Team state, or register model-facing input. ## Table of Contents @@ -29,13 +29,15 @@ Enable this package through [`@deepseek-ai/dsh-experimental-agent-team-profile`] ### Inspect and navigate the roster -Opening the panel calls `agentTeams/view`. Roster rows show durable names, turn availability, model, and diagnostics. Provisioning and running members use the shared ongoing loader, inactive members use idle, and failed members use error. Selecting a healthy teammate opens the ordinary `{ parentSessionId, childSessionId, mode: 'continuable' }` address from its Lead and roster identity without refreshing or checking the parent catalog. The Host validates the parent, child, and mode when history opens. History and later human prompts continue through the stable addressed-subagent conversation path; this package adds no Team-specific address field. +The panel shows the Lead Session's roster and task board from the shared Session store. Task and roster updates appear while the panel stays open. Opening the panel performs no projection requests. The panel shows a loading notice while the conversation or Session list is loading, and an unavailable notice when no Team value is present afterward. + +Roster rows show durable names and phases. Provisioning and running members use the shared ongoing loader, inactive members use a person icon, and failed members use error. Live Session status supplies running activity; the shared `modelSelection` projection supplies a model when available. The current conversation carries a Current chat tag and cannot be selected. Selecting the Lead from a teammate conversation opens the Lead Session directly. Selecting an active teammate opens its ordinary continuable child address. The Host validates the parent, child, and mode when history opens; later human prompts use the same addressed-subagent conversation. ### Inspect the task board Ready pending tasks use idle, blocked pending tasks use warning, in-progress tasks use ongoing, and completed tasks use done. -The read-only task board shows task identity, owner, blockers, readiness, advisory write scopes, and overlap warnings. Team agents create and update tasks through their tools; the panel provides no task mutation controls. +The read-only task board shows task identity, owner, blockers, readiness, advisory write scopes, and overlap warnings. Descriptions longer than two lines have an expand toggle. Section headings show member and task counts; an empty board shows a short description, and a lone member with no tasks uses a single-column panel. Team agents create and update tasks through their tools; the panel provides no task mutation controls. When the projection reports a rejected persisted Team record, the panel shows that failure above the last valid roster and tasks. ----- @@ -45,14 +47,14 @@ The read-only task board shows task identity, owner, blockers, readiness, adviso
Implementation internals — click to expand -The Client export mounts the generated `ctx.remote.agentTeams` contribution from [`@deepseek-ai/dsh-experimental-agent-team/remote`](../agent-team/README.md), then registers its locale dictionaries and one conversation-header slot through Cordis effects. Disposing the plugin fiber removes both registrations. +The Client export registers its locale dictionaries and one conversation-header slot through Cordis effects; it mounts no Remote namespace. Disposing the plugin fiber removes both registrations. -The panel renders outside the conversation container and stays within the viewport. Opening moves focus into the panel; Escape or Close returns focus to its trigger. Clicking outside or moving focus outside the panel and trigger closes it without moving focus back. Opening or refreshing the panel reads the complete Team view. Overlapping refreshes keep the newest response, and responses for a previous conversation are ignored. +The panel renders outside the conversation container and stays within the viewport. Member cards use the shared elevation stroke for their outlines in resting, selected, and hover states. Hovering the trigger opens the panel after 150ms; leaving both trigger and panel closes it after a 120ms grace period. Clicking the trigger pins the panel and moves focus into it. Outside clicks and Escape dismiss the panel; Escape returns focus to the trigger only when focus was inside the panel. In a narrow header, the trigger becomes an icon and opens only on click. The component derives every row from the `useSessions`, `useSessionStatus`, and `useSession` seats: the Lead identity comes from the current Session's subagent address, the Team view from `projectionsBySession[lead].values.agentTeam`, member activity from Session status with the list summary as fallback, and the model from `projectionsBySession[member].values.modelSelection.next`. Each roster row selects its own running state. The only injected callback opens a roster Session using the current and target Session ids. Switching conversations closes the panel and clears a navigation failure. | File | Role | |---|---| -| [`src/client/mount.ts`](src/client/mount.ts) | Generated Remote, locale, navigation, and slot registrations | -| [`src/client/TeamAction.tsx`](src/client/TeamAction.tsx) | Roster and task-board interaction state | +| [`src/client/mount.ts`](src/client/mount.ts) | Locale, navigation, and slot registrations | +| [`src/client/TeamAction.tsx`](src/client/TeamAction.tsx) | Projection-derived roster and task board with panel interaction state | | [`src/client/locales.ts`](src/client/locales.ts) | English and Chinese panel copy | | [`src/index.ts`](src/index.ts) | Inert Host entry | @@ -64,7 +66,7 @@ The panel renders outside the conversation container and stays within the viewpo ## Further Exploration - [Agent Teams bundle](../agent-team-profile/README.md) — the published opt-in bundle that mounts this Client plugin. -- [Agent Teams service](../agent-team/README.md) — authoritative roster, task, and Remote behavior. +- [Agent Teams service](../agent-team/README.md) — authoritative roster, task, and projection behavior. - [Conversation UI](../../client/ui-conversation/README.md) — the stable header slot and addressed-subagent navigation surface. - [Experimental packages](../README.md) — incubation status and publication policy. @@ -83,7 +85,9 @@ No direct effect; the Team tools and ordinary conversation submission own any la -- **Snapshot refresh** — the panel refreshes on open and explicit refresh; it has no live event subscription or mailbox timeline. +- **No mailbox timeline** — the projection view carries roster and tasks only; peer messages are not shown. +- **Late plugin activation** — after enabling Agent Teams in an already-open conversation, reload the page to receive its Team projection. +- **Model availability** — a model appears only when the shared store has a durable selection or request for that member. Missing cold-cache values stay absent until normal Session loading or a live update supplies them. - **Ordinary child continuation** — a human message sent after navigation uses the stable addressed-subagent prompt path, not the Team peer mailbox. - **No lifecycle or workspace controls** — the panel cannot spawn, rename, delete, or interrupt teammates, and write scopes remain advisory metadata. @@ -97,4 +101,4 @@ None.
-**Runtime invariant:** No companion is published. RPC is authoritative and the package owns only one disposable slot registration. +**Runtime invariant:** No companion is published. The Host projection is authoritative and the package owns only one disposable slot registration. diff --git a/packages/experimental/client-ui-agent-team/README.zh.md b/packages/experimental/client-ui-agent-team/README.zh.md index 143c855b8b..90bed11f28 100644 --- a/packages/experimental/client-ui-agent-team/README.zh.md +++ b/packages/experimental/client-ui-agent-team/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -本包向 Web 会话页头添加 Agent Teams action,让用户检查当前 roster、查看共享任务板并导航到 teammate 会话。它通过生成的 `ctx.remote.agentTeams` contribution 读取权威 Team 状态,并让普通 child history 导航继续使用稳定的 addressed-subagent 路径。通过公开发布的实验性 Agent Teams bundle 选择本包。这个浏览器 projection 不扩展稳定 API Proxy、不存储 Team 状态,也不注册面向模型的输入。 +本包向 Web 会话页头添加 Agent Teams action,让用户检查当前 roster、查看共享任务板并导航到 teammate 会话。它从共享 Session store 读取 Lead Session 的 `agentTeam` 投影,Host 投影 frame 使其保持最新而无需刷新控件,并让普通 child history 导航继续使用稳定的 addressed-subagent 路径。通过公开发布的实验性 Agent Teams bundle 选择本包。这个浏览器 projection 不扩展稳定 API Proxy、不存储 Team 状态,也不注册面向模型的输入。 ## 目录 @@ -29,13 +29,15 @@ kind: "package-reference" ### 检查并导航 roster -打开 panel 会调用 `agentTeams/view`。Roster row 展示持久 name、轮次可用状态、model 与 diagnostics。provisioning 和 running 成员使用共享 ongoing loading,inactive 成员使用 idle 灰点,failed 成员使用 error 红点。选择健康 teammate 时,系统直接根据其 Lead 与 roster 身份打开普通的 `{ parentSessionId, childSessionId, mode: 'continuable' }` address,不刷新或检查 parent catalog。Host 在打开历史时校验 parent、child 与 mode。History 与后续人类提示词继续使用稳定 addressed-subagent 会话路径;本包不会添加 Team 专用 address 字段。 +面板从共享 Session store 展示 Lead Session 的 roster 与任务板。面板保持打开时,任务和成员更新会直接出现。打开面板不发起投影请求。会话或 Session 列表正在加载时,面板显示加载提示;加载结束后仍无 Team 值时,显示不可用提示。 + +Roster 行展示持久名称与阶段。provisioning 和 running 成员使用共享 ongoing loading,inactive 成员使用人物图标,failed 成员使用 error 红点。实时 Session 状态提供运行活动;共享 `modelSelection` 投影在可用时提供模型。当前会话带有“当前会话”标签且不可选择。在 teammate 会话中选择 Lead 会直接打开 Lead Session。选择 active teammate 会打开其普通 continuable 子会话地址。Host 在打开历史时校验 parent、child 与 mode;后续人类提示词使用同一 addressed-subagent 会话。 ### 查看任务板 可开始的 pending 任务使用 idle 灰点,被依赖阻塞的 pending 任务使用 warning 橙点,in-progress 任务使用 ongoing loading,completed 任务使用 done 绿点。 -只读任务板展示任务标识、负责人、依赖、就绪状态、提示性写入范围与重叠警告。Team agent 通过工具创建和更新任务;面板不提供任务修改控件。 +只读任务板展示任务标识、负责人、依赖、就绪状态、提示性写入范围与重叠警告。超过两行的描述提供展开按钮。分区标题显示成员与任务数量;空任务板显示简短描述,只有一名成员且无任务时采用单列面板。Team agent 通过工具创建和更新任务;面板不提供任务修改控件。当投影报告某条持久 Team 记录被拒绝时,面板在最后有效的 roster 与任务上方显示该失败。 ----- @@ -45,14 +47,14 @@ kind: "package-reference"
实现细节——点击展开 -Client export 挂载来自 [`@deepseek-ai/dsh-experimental-agent-team/remote`](../agent-team/README.zh.md) 的生成的 `ctx.remote.agentTeams` contribution,然后通过 Cordis effect 注册 locale dictionary 与一个 conversation-header slot。Dispose plugin fiber 会移除这两项 registration。 +Client export 通过 Cordis effect 注册 locale dictionary 与一个 conversation-header slot;它不挂载任何 Remote namespace。Dispose plugin fiber 会移除这两项 registration。 -面板渲染在会话容器外,并保持在视口范围内。打开时焦点移入面板;按 Escape 或选择关闭按钮时,焦点返回触发按钮。点击外部或将焦点移出面板与触发按钮时,面板关闭,但不会将焦点移回。打开或刷新面板会读取完整 Team view。并行刷新只保留最新响应,属于上一个会话的响应会被忽略。 +面板渲染在会话容器外,并保持在视口范围内。成员卡片在静止、选中和悬停状态下均使用共享 elevation 描边绘制轮廓。悬停触发按钮 150ms 后打开面板;指针离开触发按钮和面板后,经过 120ms 宽限关闭。点击触发按钮会固定面板并将焦点移入其中。点击外部或按 Escape 可关闭面板;仅当焦点原本位于面板内时,Escape 才将焦点返回触发按钮。页头较窄时触发按钮折叠为图标,只响应点击打开。组件从 `useSessions`、`useSessionStatus` 与 `useSession` 座位派生每一行:Lead 身份来自当前 Session 的 subagent address,Team 视图来自 `projectionsBySession[lead].values.agentTeam`,成员活动来自 Session 状态并以列表摘要为后备,model 来自 `projectionsBySession[member].values.modelSelection.next`。每个 roster 行只选择自己的运行状态。唯一的注入回调通过当前与目标 Session 的 id 打开 roster Session。切换会话会关闭面板并清除导航失败。 | 文件 | 职责 | |---|---| -| [`src/client/mount.ts`](src/client/mount.ts) | 生成的 Remote、locale、导航与 slot registration | -| [`src/client/TeamAction.tsx`](src/client/TeamAction.tsx) | Roster 与任务板交互状态 | +| [`src/client/mount.ts`](src/client/mount.ts) | locale、导航与 slot registration | +| [`src/client/TeamAction.tsx`](src/client/TeamAction.tsx) | 由投影派生的 roster 与任务板及面板交互状态 | | [`src/client/locales.ts`](src/client/locales.ts) | 中英文 panel 文案 | | [`src/index.ts`](src/index.ts) | 不执行行为的 Host entry | @@ -64,7 +66,7 @@ Client export 挂载来自 [`@deepseek-ai/dsh-experimental-agent-team/remote`](. ## 进一步探索 - [Agent Teams bundle](../agent-team-profile/README.zh.md)——挂载本 Client plugin 的公开 opt-in bundle。 -- [Agent Teams service](../agent-team/README.zh.md)——权威 roster、task 与 Remote 行为。 +- [Agent Teams service](../agent-team/README.zh.md)——权威 roster、task 与投影行为。 - [会话 UI](../../client/ui-conversation/README.zh.md)——稳定 header slot 与 addressed-subagent 导航表层。 - [实验性包](../README.zh.md)——孵化状态与发布规则。 @@ -83,7 +85,9 @@ Client export 挂载来自 [`@deepseek-ai/dsh-experimental-agent-team/remote`](. -- **Snapshot refresh**——panel 会在打开和显式 refresh 时刷新;它没有实时事件订阅或 mailbox timeline。 +- **没有 mailbox timeline**——投影视图只承载 roster 与任务;不显示 peer 消息。 +- **晚启用插件** — 在已经打开的会话中启用 Agent Teams 后,需要刷新页面才能接收其 Team 投影。 +- **模型可用性** — 仅当共享 store 包含该成员的持久选择或请求时才显示模型。冷缓存中缺少的值会保持缺失,直到正常 Session 加载或实时更新提供它们。 - **普通 child continuation**——导航后发送的人类消息使用稳定 addressed-subagent 提示词路径,而不是 Team peer mailbox。 - **没有 lifecycle 或 workspace control**——panel 不能 spawn、rename、delete 或 interrupt teammate,write scope 仍只是提示性 metadata。 @@ -97,4 +101,4 @@ Client export 挂载来自 [`@deepseek-ai/dsh-experimental-agent-team/remote`](.
-**运行时不变式:** 不发布伴生入口。RPC 是权威来源,本包只持有一个可释放的 slot 注册。 +**运行时不变式:** 不发布伴生入口。Host 投影是权威来源,本包只持有一个可释放的 slot 注册。 diff --git a/packages/experimental/client-ui-agent-team/package.json b/packages/experimental/client-ui-agent-team/package.json index 645eebb8ab..ef6657b6d7 100644 --- a/packages/experimental/client-ui-agent-team/package.json +++ b/packages/experimental/client-ui-agent-team/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-experimental-client-ui-agent-team", "description": "Web Agent Teams roster, task board, and teammate navigation", - "version": "0.1.7-alpha.1", + "version": "0.1.7-alpha.2", "publishConfig": { "access": "public" }, @@ -29,7 +29,6 @@ "dsh": { "client": { "inject": [ - "@deepseek-ai/dsh-api-remotes", "@deepseek-ai/dsh-api-session-controller", "@deepseek-ai/dsh-client-locale", "@deepseek-ai/dsh-client-ui-conversation", @@ -45,38 +44,35 @@ }, "license": "MIT", "peerDependencies": { - "@deepseek-ai/dsh-api-remotes": "workspace:^", - "@deepseek-ai/dsh-api-session-controller": "workspace:^", - "@deepseek-ai/dsh-client-locale": "workspace:^", - "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", - "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", - "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", - "@deepseek-ai/dsh-client-ui-session": "workspace:^", - "@deepseek-ai/dsh-client-ui-slots": "workspace:^", - "@deepseek-ai/dsh-client-ui-workspace": "workspace:^", - "@deepseek-ai/dsh-experimental-agent-team": "workspace:^", - "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/dsh-typert-protocol": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/dsh-api-session-controller": "workspace:*", + "@deepseek-ai/dsh-client-locale": "workspace:*", + "@deepseek-ai/dsh-client-ui-conversation": "workspace:*", + "@deepseek-ai/dsh-client-ui-primitives": "workspace:*", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:*", + "@deepseek-ai/dsh-client-ui-session": "workspace:*", + "@deepseek-ai/dsh-client-ui-slots": "workspace:*", + "@deepseek-ai/dsh-client-ui-workspace": "workspace:*", + "@deepseek-ai/dsh-experimental-agent-team": "workspace:*", + "@deepseek-ai/dsh-session": "workspace:*", + "@deepseek-ai/cordis": "workspace:~" }, "devDependencies": { - "@deepseek-ai/dsh-api-remotes": "workspace:^", - "@deepseek-ai/dsh-api-session-controller": "workspace:^", - "@deepseek-ai/dsh-client-locale": "workspace:^", - "@deepseek-ai/dsh-client-test-runtime": "workspace:^", - "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", - "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", - "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", - "@deepseek-ai/dsh-client-ui-session": "workspace:^", - "@deepseek-ai/dsh-client-ui-slots": "workspace:^", - "@deepseek-ai/dsh-client-ui-workspace": "workspace:^", - "@deepseek-ai/dsh-experimental-agent-team": "workspace:^", - "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/dsh-typert-protocol": "workspace:^", + "@deepseek-ai/dsh-api-session-controller": "workspace:*", + "@deepseek-ai/dsh-client-locale": "workspace:*", + "@deepseek-ai/dsh-client-store": "workspace:*", + "@deepseek-ai/dsh-client-test-runtime": "workspace:*", + "@deepseek-ai/dsh-client-ui-conversation": "workspace:*", + "@deepseek-ai/dsh-client-ui-primitives": "workspace:*", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:*", + "@deepseek-ai/dsh-client-ui-session": "workspace:*", + "@deepseek-ai/dsh-client-ui-slots": "workspace:*", + "@deepseek-ai/dsh-client-ui-workspace": "workspace:*", + "@deepseek-ai/dsh-experimental-agent-team": "workspace:*", + "@deepseek-ai/dsh-session": "workspace:*", "@testing-library/react": "^16.1.0", "@types/react": "~18.3.1", "@types/react-dom": "~18.3.0", - "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/cordis": "workspace:~", "react": "^18.2.0", "react-dom": "^18.2.0" }, diff --git a/packages/experimental/client-ui-agent-team/src/client/TeamAction.module.css b/packages/experimental/client-ui-agent-team/src/client/TeamAction.module.css index 29d35ccaf0..ffd2cd01ec 100644 --- a/packages/experimental/client-ui-agent-team/src/client/TeamAction.module.css +++ b/packages/experimental/client-ui-agent-team/src/client/TeamAction.module.css @@ -2,90 +2,130 @@ position: relative; } -.trigger, -.iconButton { - border: 0; - border-radius: 6px; - background: transparent; - color: var(--dsw-alias-label-secondary); - cursor: pointer; -} - .trigger { display: inline-flex; align-items: center; gap: 5px; min-height: 28px; padding: 3px 7px; + border: 0; + border-radius: 6px; + background: transparent; + color: var(--dsw-alias-label-tertiary); font-size: 12px; + cursor: pointer; } .trigger:hover, -.iconButton:hover { - background: var(--dsw-alias-interactive-bg-hover); +.trigger:focus-visible { + color: var(--dsw-alias-label-primary); +} + +/* Second sacrifice after the preset label: past this title-row width the + trigger keeps only its icon. TeamAction reads the collapsed state off this + rule to switch from hover-open to click-only. */ +@container (max-width: 480px) { + .triggerLabel { + display: none; + } } .count { - min-width: 16px; - border-radius: 8px; - background: var(--dsw-alias-fill-l2); + color: var(--dsw-alias-label-caption); + font-size: 12px; + font-weight: 400; font-variant-numeric: tabular-nums; - text-align: center; + line-height: 16px; } .panel { --dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2); --dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2); + display: flex; + flex-direction: column; position: fixed; - z-index: 110; + z-index: 100; box-sizing: border-box; - width: min(560px, calc(100vw - 32px)); + width: min(500px, 100vw - 32px); max-height: min(680px, calc(100vh - 32px)); - padding: 10px; - overflow: auto; + /* Horizontal insets live on the toolbar and scroll body, not the panel, so + member-card hover shadows stay inside the body's clip box; the thumb hugs + the panel edge (2px) while the body's stable gutter and trailing padding + rebuild the 16px content inset. */ + padding: 8px 2px 0; + overflow: hidden; border: 0; border-radius: 12px; - background: var(--dsw-specific-menu); - backdrop-filter: var(--dsw-menu-backdrop-filter); --dsw-elevation-stroke-color: var(--dsw-alias-border-l1); box-shadow: var(--dsw-elevation-prominent); } -.toolbar, +/* The menu skin lives on a pseudo-element: backdrop-filter on the panel + itself would turn it into the containing block for fixed descendants, so + the member Tooltip bubbles would be re-anchored into the panel and clipped + by its hidden overflow. */ +.panel::before { + content: ''; + position: absolute; + z-index: -1; + inset: 0; + border-radius: 12px; + background: var(--dsw-specific-menu); + backdrop-filter: var(--dsw-menu-backdrop-filter); +} + +/* A lone member with no shared tasks fits one card; the full panel width + would sit mostly empty, so the roster drops to one column. */ +.panelCompact { + width: min(320px, 100vw - 32px); +} + +.panelCompact .roster { + grid-template-columns: minmax(0, 1fr); +} + +.body { + --dsh-scrollbar-track-margin: 8px; + + flex: 1 1 auto; + min-height: 0; + padding: 0 9px 16px 14px; + overflow-y: auto; + scrollbar-gutter: stable; +} + .taskTitle { display: flex; align-items: center; gap: 8px; } -.toolbar { - min-height: 28px; -} - -.spacer { - flex: 1; -} - -.iconButton { - display: inline-flex; - align-items: center; - justify-content: center; - width: 26px; - height: 26px; +.taskTitle strong { + font-size: 13px; + font-weight: 500; } .panel h3 { - margin: 12px 0 6px; - color: var(--dsw-alias-label-secondary); - font-size: 12px; + display: flex; + align-items: center; + gap: 8px; + margin: 16px 0 8px 4px; + color: var(--dsw-alias-label-primary); + font-size: 13px; font-weight: 500; } +/* Without a toolbar the first heading sits 16px under the panel edge: 8px + panel padding plus this trimmed margin. */ +.body section:first-of-type h3 { + margin-top: 8px; +} + .roster { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 6px; + gap: 8px; } .member { @@ -93,10 +133,12 @@ align-items: flex-start; gap: 8px; min-width: 0; - padding: 8px; - border: 0.5px solid var(--dsw-alias-border-l1); + padding: 10px 12px; + border: 0; + --dsw-elevation-stroke-color: var(--dsw-alias-border-l2); + box-shadow: var(--dsw-elevation-stroke); border-radius: 8px; - background: transparent; + background: var(--dsw-alias-bg-layer-2); color: var(--dsw-alias-label-primary); text-align: left; cursor: pointer; @@ -106,12 +148,78 @@ cursor: default; } +/* The Current tag and card share a tint; the inset ring adds emphasis + without changing the card's dimensions. */ +.memberCurrent { + --dsw-elevation-stroke-color: color-mix(in srgb, var(--dsw-alias-state-business-primary) 40%, transparent); + box-shadow: var(--dsw-elevation-stroke), inset 0 0 0 1px color-mix(in srgb, var(--dsw-alias-state-business-primary) 40%, transparent); +} + +.member:not(:disabled):hover, +.member:not(:disabled):focus-visible { + --dsw-elevation-stroke-color: var(--dsw-alias-border-l3); + box-shadow: var(--dsw-elevation-panel); +} + +/* Seat height 1lh tracks the member name's first text line, so any dot + diameter centers on that line instead of the row's top edge. */ +.memberDot { + display: inline-flex; + align-items: center; + flex: none; + height: 1lh; +} + +.inactiveIcon { + color: var(--dsw-alias-label-tertiary); +} + .memberText { display: flex; flex-direction: column; min-width: 0; } +.memberName { + display: inline-flex; + align-items: center; + gap: 5px; + min-width: 0; +} + +.memberNameText, +.memberText small { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + +/* Model details stay behind a hover/focus reveal to keep the resting card to + its name and activity. */ +.memberModel { + display: none; +} + +.member:hover .memberModel, +.member:focus-visible .memberModel { + display: inline; +} + +/* The member card is denser than the surfaces the standard capsule ships for, + so the current-conversation tag drops one step in size. The 999 shrink + factor collapses the tag (block display re-enables text-overflow) before + the member name gives up any width. */ +.currentTag { + display: inline-block; + flex: 0 999 auto; + min-width: 0; + padding: 0 4px; + overflow: hidden; + font-size: 10px; + line-height: 15px; + text-overflow: ellipsis; +} + .memberText small, .meta { color: var(--dsw-alias-label-tertiary); @@ -130,11 +238,19 @@ gap: 7px; } +/* Empty task board: a plain description in place of the section heading, + flush with the body's bottom padding. */ +.emptyNotice { + margin: 16px 0 0 4px; + color: var(--dsw-alias-label-tertiary); + font-size: 12px; +} + .task { - padding: 9px; - border: 0.5px solid var(--dsw-alias-border-l1); + padding: 11px 13px; + border: 0.5px solid var(--dsw-alias-border-l2); border-radius: 9px; - background: var(--dsw-alias-bg-base); + background: var(--dsw-alias-bg-layer-2); } .taskState { @@ -150,13 +266,54 @@ margin: 5px 0; color: var(--dsw-alias-label-secondary); font-size: 12px; + line-height: 18px; white-space: pre-wrap; } +.clampedDescription { + display: -webkit-box; + overflow: hidden; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; +} + +.expandToggle { + display: inline-flex; + align-items: center; + gap: 2px; + /* Floated ahead of the meta spans: it keeps the first line's top-right + corner, so wrapped meta lines continue below it instead of pushing the + toggle under content it expands. */ + float: right; + margin-left: 10px; + padding: 0; + border: 0; + background: transparent; + color: var(--dsw-alias-label-tertiary); + font-size: 11px; + line-height: 20px; + cursor: pointer; +} + +.expandToggle:hover, +.expandToggle:focus-visible { + color: var(--dsw-alias-label-primary); +} + +.expandToggle svg { + transition: transform 120ms ease; +} + +.expandToggleOpen { + transform: rotate(180deg); +} + .meta { - display: flex; - flex-wrap: wrap; - gap: 4px 10px; + line-height: 20px; +} + +.meta > span { + margin-right: 10px; } .notice, diff --git a/packages/experimental/client-ui-agent-team/src/client/TeamAction.tsx b/packages/experimental/client-ui-agent-team/src/client/TeamAction.tsx index bce5aa56e0..b418f20336 100644 --- a/packages/experimental/client-ui-agent-team/src/client/TeamAction.tsx +++ b/packages/experimental/client-ui-agent-team/src/client/TeamAction.tsx @@ -1,38 +1,35 @@ -import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react' +import { useEffect, useLayoutEffect, useRef, useState } from 'react' import { createPortal } from 'react-dom' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { - TeamMemberView as TeamRosterMember, + TeamMemberProjection, TeamTaskView as TeamTask, - TeamView, } from '@deepseek-ai/dsh-experimental-agent-team/client' -import type { RemoteResult } from '@deepseek-ai/dsh-api-remotes/client' +import type {} from '@deepseek-ai/dsh-api-session-controller/client' import { - IconCloseOutlineRegular, IconRefreshOutlineRegular, IconUserOutlineRegular, StateDot, + IconChevronDownOutlineRegular, + IconUserOutlineRegular, IconUsersOutlineRegular, StateDot, Tag, Tooltip, useAnchoredPosition, useDismissOnOutsidePointer, type StateDotState, } from '@deepseek-ai/dsh-client-ui-primitives' import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots' import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' import { NS, type TeamKey } from './locales.ts' import css from './TeamAction.module.css' -/** Generated Remote result consumed directly by the Team UI. */ -export type TeamActionResult = RemoteResult - /** Business actions injected by the browser plugin. */ export interface TeamActionInjected { - load: (sessionId: SessionId) => Promise> - openTeammate: (sessionId: SessionId, member: TeamRosterMember) => void + /** Open a roster Session from the current conversation. */ + openTeammate: (sessionId: SessionId, childSessionId: SessionId) => void } +/** Durable lifecycle overlaid with the member Session's live turn activity. */ +type MemberStatus = 'running' | 'inactive' | 'provisioning' | 'failed' + /** Full props of the Team conversation-header action. */ export type TeamActionProps = PropsRuntime<'conversation.session.header.actions'> & TeamActionInjected & PropsLocale -function failureText(error: { readonly code: string; readonly message: string }): string { - return `${error.message} (${error.code})` -} - function statusKey(status: TeamTask['status']): TeamKey { switch (status) { case 'pending': return 'status.pending' @@ -43,7 +40,7 @@ function statusKey(status: TeamTask['status']): TeamKey { } } -function memberStatusKey(status: TeamRosterMember['status']): TeamKey { +function memberStatusKey(status: MemberStatus): TeamKey { switch (status) { case 'running': return 'memberStatus.running' case 'inactive': return 'memberStatus.inactive' @@ -52,11 +49,10 @@ function memberStatusKey(status: TeamRosterMember['status']): TeamKey { } } -function memberDotState(status: TeamRosterMember['status']): StateDotState { +function memberDotState(status: Exclude): StateDotState { switch (status) { case 'running': case 'provisioning': return 'ongoing' - case 'inactive': return 'idle' case 'failed': return 'error' } } @@ -71,174 +67,289 @@ function taskDotState(task: TeamTask): StateDotState { } } +type TeamMemberRowProps = Pick & { + member: TeamMemberProjection + memberCount: number + onError: (message: string) => void +} + +function TeamMemberRow({ + member, memberCount, sessionId, useSessions, useSessionStatus, openTeammate, onError, t, +}: TeamMemberRowProps) { + const model = useSessions(state => state.projectionsBySession[member.id]?.values.modelSelection?.next?.model) + const running = useSessionStatus(state => state.get(member.id)?.running) + const summaryRunning = useSessions(state => state.byId[member.id]?.running) + const status: MemberStatus = member.phase === 'active' + ? (running ?? summaryRunning) === true ? 'running' : 'inactive' + : member.phase + const isCurrent = member.id === sessionId + const highlightCurrent = isCurrent && memberCount > 1 + const inert = isCurrent || status === 'failed' || status === 'provisioning' + + return ( + + + + ) +} + +/** Task card with a two-line description clamp expanded from a toggle in the meta row. */ +function TaskCard({ task, t }: { task: TeamTask; t: TranslateNS }) { + const [expanded, setExpanded] = useState(false) + const [clamped, setClamped] = useState(false) + const textRef = useRef(null) + useLayoutEffect(() => { + if (expanded) return + const paragraph = textRef.current + /* v8 ignore next -- the paragraph mounts in the same commit as the effect. */ + if (paragraph === null) return + const measure = (): void => { setClamped(paragraph.scrollHeight > paragraph.clientHeight + 1) } + measure() + if (typeof ResizeObserver === 'undefined') return + const observer = new ResizeObserver(measure) + observer.observe(paragraph) + return () => { observer.disconnect() } + }, [task.description, expanded]) + return ( +
+
+ {task.subject} + + + {t(statusKey(task.status))} + +
+

{task.description}

+
+ {(clamped || expanded) && ( + + )} + {task.id} + {t('owner')}: {task.ownerName ?? t('unowned')} + {task.status === 'pending' && {task.ready ? t('ready') : t('blocked')}} + {task.blockedBy.length > 0 && {t('blockedBy')}: {task.blockedBy.join(', ')}} + {task.writeScopes.length > 0 && {t('writeScopes')}: {task.writeScopes.join(', ')}} + {task.writeScopeWarnings.map(warning => {warning})} +
+
+ ) +} + /** Render the Team roster and read-only task board. */ export function TeamAction({ - sessionId, load, openTeammate, t, + sessionId, useSession, useSessions, useSessionStatus, openTeammate, t, }: TeamActionProps) { const [open, setOpen] = useState(false) - const [loading, setLoading] = useState(false) - const [view, setView] = useState(null) const [error, setError] = useState(null) const rootRef = useRef(null) const triggerRef = useRef(null) + const triggerLabelRef = useRef(null) const panelRef = useRef(null) const position = useAnchoredPosition({ open, anchorRef: triggerRef, panelRef, gap: 5, margin: 16, }) const positioned = position !== null - useDismissOnOutsidePointer(rootRef, open, setOpen, panelRef) - const sessionRef = useRef(sessionId) - const refreshGeneration = useRef(0) - sessionRef.current = sessionId + const leadSessionId = useSession(snapshot => snapshot.subagent?.address.parentSessionId) ?? sessionId + const team = useSessions(state => state.projectionsBySession[leadSessionId]?.values.agentTeam) + const opening = useSession(snapshot => snapshot.openState === 'loading') + const listing = useSessions(state => state.phase === 'pending') + const hoverTimer = useRef | undefined>(undefined) + const pinnedRef = useRef(false) + + const cancelHoverChange = (): void => { + clearTimeout(hoverTimer.current) + hoverTimer.current = undefined + } useEffect(() => { - refreshGeneration.current += 1 + cancelHoverChange() + pinnedRef.current = false setOpen(false) - setLoading(false) - setView(null) setError(null) }, [sessionId]) + useEffect(() => cancelHoverChange, []) + useLayoutEffect(() => { - if (open && positioned) panelRef.current?.focus() + if (open && positioned && pinnedRef.current) panelRef.current?.focus() }, [open, positioned]) - const close = (): void => { - setOpen(false) - triggerRef.current?.focus() + const changeOpen = (next: boolean): void => { + cancelHoverChange() + if (!next) pinnedRef.current = false + setOpen(next) } - const refresh = useCallback(async (): Promise => { - const requestedSession = sessionId - const generation = ++refreshGeneration.current - setLoading(true) - const result = await load(requestedSession) - if (sessionRef.current !== requestedSession || refreshGeneration.current !== generation) return - setLoading(false) - if (result.ok) { - setView(result.value) - setError(null) - } else { - setError(failureText(result.error)) - } - }, [load, sessionId]) + const scheduleHoverOpen = (): void => { + cancelHoverChange() + if (open) return + const label = triggerLabelRef.current + /* v8 ignore next -- the label mounts with the trigger that received the hover. */ + if (label === null) return + // Icon-only trigger (label collapsed by the header container query): + // hover-open would surprise on such a small target, so only click opens. + if (getComputedStyle(label).display === 'none') return + hoverTimer.current = setTimeout(() => { + hoverTimer.current = undefined + changeOpen(true) + }, 150) + } - const teammates = view?.members.filter(member => member.role === 'teammate') ?? [] + const scheduleHoverClose = (): void => { + cancelHoverChange() + if (pinnedRef.current) return + hoverTimer.current = setTimeout(() => { + hoverTimer.current = undefined + changeOpen(false) + }, 120) + } + + useDismissOnOutsidePointer(rootRef, open, changeOpen, panelRef) + + useEffect(() => { + if (!open) return + const dismiss = (event: KeyboardEvent): void => { + if (event.key !== 'Escape') return + event.preventDefault() + cancelHoverChange() + pinnedRef.current = false + setOpen(false) + if (panelRef.current?.contains(document.activeElement)) triggerRef.current?.focus() + } + document.addEventListener('keydown', dismiss) + return () => { document.removeEventListener('keydown', dismiss) } + }, [open]) + + const compact = team !== undefined && team.members.length === 1 && team.tasks.length === 0 return ( -
{ - if (event.key !== 'Escape' || !open) return - event.preventDefault() - close() - }} onBlur={(event) => { - const target = event.relatedTarget - if (target instanceof Node && !event.currentTarget.contains(target) && !panelRef.current?.contains(target)) { - setOpen(false) - } - }}> +
{open && createPortal(
-
- {t('trigger')} - - {loading && view !== null && ( - +
+ {error !== null && ( +
{error}
+ )} + {team === undefined && ( +
+ + {t(opening || listing ? 'loading' : 'unavailable')} +
+ )} + {team !== undefined && ( + <> + {team.failure !== undefined && ( +
{t('failure', { message: team.failure })}
+ )} +
+

+ {t('roster')} + {team.members.length > 1 && {team.members.length}} +

+
+ {team.members.map(member => ( + + ))} +
+
+
+ {team.tasks.length === 0 + ?

{t('empty')}

+ : ( + <> +

{t('tasks')}{team.tasks.length}

+
+ {team.tasks.map(task => )} +
+ + )} +
+ )} - -
- {error !== null && ( -
{error}
- )} - {loading && view === null && ( -
{t('loading')}
- )} - {view !== null && ( - <> -
-

{t('roster')}

-
- {view.members.map(member => ( - - ))} -
-
-
-

{t('tasks')}

- {view.tasks.length === 0 &&
{t('empty')}
} -
- {view.tasks.map(task => ( -
-
- {task.subject} - - - {t(statusKey(task.status))} - -
-

{task.description}

-
- {task.id} - {t('owner')}: {task.ownerName ?? t('unowned')} - {task.status === 'pending' && {task.ready ? t('ready') : t('blocked')}} - {task.blockedBy.length > 0 && {t('blockedBy')}: {task.blockedBy.join(', ')}} - {task.writeScopes.length > 0 && {t('writeScopes')}: {task.writeScopes.join(', ')}} - {task.writeScopeWarnings.map(warning => {warning})} -
-
- ))} -
-
- - )}
, document.body, )} diff --git a/packages/experimental/client-ui-agent-team/src/client/index.ts b/packages/experimental/client-ui-agent-team/src/client/index.ts index 5e2093105e..a4896c82af 100644 --- a/packages/experimental/client-ui-agent-team/src/client/index.ts +++ b/packages/experimental/client-ui-agent-team/src/client/index.ts @@ -1,14 +1,16 @@ -/** Browser entry binding the generated Team Remote artifact to its Client UI. */ +/** Browser entry registering the Agent Teams conversation-header action. */ -import agentTeamsRemote from '@deepseek-ai/dsh-experimental-agent-team/remote' import type { Context as ClientContext } from '@deepseek-ai/cordis' -import { mountAgentTeamUi } from './mount.ts' +import { registerAgentTeamUi } from './mount.ts' export { inject } from './mount.ts' -export type { TeamActionInjected, TeamActionProps, TeamActionResult } from './TeamAction.tsx' +export type { TeamActionInjected, TeamActionProps } from './TeamAction.tsx' export type { TeamKey } from './locales.ts' -/** Mount the generated Team Remote contribution and its browser UI. */ -export async function apply(ctx: ClientContext): Promise<() => Promise> { - return await mountAgentTeamUi(ctx, agentTeamsRemote) +/** + * Register the Team locale dictionaries and header action on the Client Context. + * @param ctx - Client Context with the declared `inject` services available. + */ +export function apply(ctx: ClientContext): void { + registerAgentTeamUi(ctx) } diff --git a/packages/experimental/client-ui-agent-team/src/client/locales.ts b/packages/experimental/client-ui-agent-team/src/client/locales.ts index 9cef05d89c..fe4b853095 100644 --- a/packages/experimental/client-ui-agent-team/src/client/locales.ts +++ b/packages/experimental/client-ui-agent-team/src/client/locales.ts @@ -5,21 +5,24 @@ export const NS = 'agent-team' /** Simplified Chinese dictionary and key source. */ export const zh = { - trigger: 'Agent Team', - refresh: '刷新 Team', - close: '关闭', - loading: '正在加载 Team…', - empty: '还没有共享任务', + trigger: '智能体团队', + loading: '正在加载团队…', + unavailable: 'Team 暂不可用', + failure: '团队持久记录无效:{message}', + empty: '暂无共享任务,可以通过对话创建', roster: '成员', tasks: '共享任务', model: '模型', - open: '打开 teammate 会话', + open: '打开成员会话', + current: '当前会话', owner: 'Owner', unowned: '未分配', blockedBy: '依赖', writeScopes: '写入范围', ready: '可开始', blocked: '被依赖阻塞', + 'task.expand': '展开', + 'task.collapse': '收起', 'memberStatus.running': '运行中', 'memberStatus.inactive': '未运行', 'memberStatus.provisioning': '准备中', @@ -35,20 +38,23 @@ export type TeamKey = keyof typeof zh /** English dictionary checked against the Chinese key set. */ export const en = { trigger: 'Agent Team', - refresh: 'Refresh Team', - close: 'Close', loading: 'Loading Team…', - empty: 'No shared tasks yet', + unavailable: 'Team is unavailable', + failure: 'Invalid persisted Team record: {message}', + empty: 'No shared tasks yet. Create them through the conversation.', roster: 'Members', tasks: 'Shared tasks', model: 'Model', - open: 'Open teammate conversation', + open: 'Open member conversation', + current: 'Current chat', owner: 'Owner', unowned: 'Unowned', blockedBy: 'Blocked by', writeScopes: 'Write scopes', ready: 'Ready', blocked: 'Blocked by dependencies', + 'task.expand': 'Show more', + 'task.collapse': 'Show less', 'memberStatus.running': 'Running', 'memberStatus.inactive': 'Inactive', 'memberStatus.provisioning': 'Provisioning', diff --git a/packages/experimental/client-ui-agent-team/src/client/mount.ts b/packages/experimental/client-ui-agent-team/src/client/mount.ts index ad9648e322..94a369df04 100644 --- a/packages/experimental/client-ui-agent-team/src/client/mount.ts +++ b/packages/experimental/client-ui-agent-team/src/client/mount.ts @@ -1,22 +1,13 @@ -/** Source-safe Agent Teams browser registration and Remote mount lifecycle. */ +/** Source-safe Agent Teams browser registration. */ -import type { - TeamMemberView as TeamRosterMember, - TeamView, -} from '@deepseek-ai/dsh-experimental-agent-team/client' -import type {} from '@deepseek-ai/dsh-experimental-agent-team/remote' import type { Context as ClientContext } from '@deepseek-ai/cordis' -import type {} from '@deepseek-ai/dsh-api-remotes/client' import type {} from '@deepseek-ai/dsh-api-session-controller/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' import type {} from '@deepseek-ai/dsh-client-locale/client' import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' import type {} from '@deepseek-ai/dsh-client-ui-workspace/client' -import type { TypertRemoteContribution } from '@deepseek-ai/dsh-typert-protocol' -import { - TeamAction, type TeamActionInjected, type TeamActionResult, -} from './TeamAction.tsx' +import { TeamAction, type TeamActionInjected } from './TeamAction.tsx' import { en, NS, zh, type TeamKey } from './locales.ts' declare module '@deepseek-ai/dsh-client-ui-slots' { @@ -26,10 +17,16 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { } } -/** Required browser services for RPC, navigation, slots, and localized copy. */ -export const inject = ['sessions', 'uiWorkspace', 'remote', 'slots', 'locale'] +/** Required browser services for navigation, slots, and localized copy. */ +export const inject = ['sessions', 'uiWorkspace', 'slots', 'locale'] -function registerUi(ctx: ClientContext): void { +/** + * Register the Team locale dictionaries and the conversation-header action. + * The panel reads the Lead Session's `agentTeam` projection from the shared + * Session store; this registration performs no Team RPC. + * @param ctx - Client Context carrying the injected navigation, locale, slot, and Session services. + */ +export function registerAgentTeamUi(ctx: ClientContext): void { ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'client-ui-agent-team: dictionaries') const sessions = ctx.sessions const leadSessionId = (sessionId: SessionId): SessionId => { @@ -38,16 +35,16 @@ function registerUi(ctx: ClientContext): void { } const actions: TeamActionInjected = { - async load(sessionId): Promise> { - return await ctx.remote.agentTeams.view(leadSessionId(sessionId)) - }, - openTeammate(sessionId: SessionId, member: TeamRosterMember): void { - if (member.role !== 'teammate') return + openTeammate(sessionId: SessionId, childSessionId: SessionId): void { const parentSessionId = leadSessionId(sessionId) if ((sessions.retainInfo(sessionId).getSnapshot().retainedBy.mainView ?? 0) === 0) return + if (childSessionId === parentSessionId) { + ctx.uiWorkspace.openSession(parentSessionId) + return + } ctx.uiWorkspace.openSession({ parentSessionId, - childSessionId: member.id, + childSessionId, mode: 'continuable', }) }, @@ -58,34 +55,9 @@ function registerUi(ctx: ClientContext): void { () => ctx.slots.register({ name: 'conversation.session.header.actions', id: 'agent-team', - order: 20, + order: -20, locale: NS, inject: () => actions, }, TeamAction), ) } - -/** - * Mount one generated Team Remote contribution, then register its browser UI. - * @param ctx - Client Context carrying navigation, locale, slot, and Remote services. - * @param contribution - generated Team descriptors selected by the browser entry. - * @returns disposer for both the UI registrations and Remote namespace. - */ -export async function mountAgentTeamUi( - ctx: ClientContext, - contribution: TypertRemoteContribution, -): Promise<() => Promise> { - const disposeRemote = await ctx.remote.$mount(contribution) - const ui = ctx.inject(['sessions', 'uiWorkspace', 'remote.agentTeams', 'slots', 'locale'], registerUi) - try { - await ui - } catch (error) { - await ui.dispose() - await disposeRemote() - throw error - } - return async () => { - await ui.dispose() - await disposeRemote() - } -} diff --git a/packages/experimental/client-ui-agent-team/tests/browser-plugin.client.spec.ts b/packages/experimental/client-ui-agent-team/tests/browser-plugin.client.spec.ts index a153ec2870..23c5844747 100644 --- a/packages/experimental/client-ui-agent-team/tests/browser-plugin.client.spec.ts +++ b/packages/experimental/client-ui-agent-team/tests/browser-plugin.client.spec.ts @@ -1,78 +1,20 @@ -import { Context, Service } from '@deepseek-ai/cordis' -import { describe, expect, it, vi } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import { describe, expect, it } from 'vitest' import type { SessionId } from '@deepseek-ai/dsh-session/types' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' -import type { TeamMemberView as TeamRosterMember } from '@deepseek-ai/dsh-experimental-agent-team/client' -import type {} from '@deepseek-ai/dsh-experimental-agent-team/remote' -import { RemoteError } from '@deepseek-ai/dsh-client-test-runtime' -import type { TypertRemoteContribution } from '@deepseek-ai/dsh-typert-protocol' import { TeamAction, type TeamActionInjected } from '../src/client/TeamAction.tsx' -import { inject, mountAgentTeamUi } from '../src/client/mount.ts' +import { apply, inject } from '../src/client/index.ts' import { apply as nodeApply } from '../src/index.ts' const SESSION = 'team-session' as SessionId const CHILD = 'team-child' as SessionId -const REMOTE: TypertRemoteContribution = { - package: '@deepseek-ai/dsh-experimental-agent-team', - descriptors: [], -} -async function bench(options: { - addressed?: boolean - registrationFailure?: boolean - remoteFailure?: boolean - catalog?: 'missing' | 'empty' -} = {}) { +async function bench(options: { addressed?: boolean } = {}) { const ctx = new Context() - const calls: { method: string; args: unknown[] }[] = [] - const task = { - id: 'task-1', - revision: 1, subject: 'Task', description: 'Description', status: 'pending' as const, - blockedBy: [], writeScopes: [], ready: true, writeScopeWarnings: [], - } - class RemoteService extends Service { - readonly disposeMount = vi.fn(() => Promise.resolve()) - readonly mount = vi.fn((_contribution: unknown) => Promise.resolve(this.disposeMount)) - - constructor(serviceCtx: Context) { - super(serviceCtx, 'remote') - } - - $mount(contribution: unknown): Promise<() => Promise> { - return this.mount(contribution) - } - } - const remote = new RemoteService(ctx) - const failure = { - ok: false as const, - error: new RemoteError('gateway/internal', 'offline', {}), - } - const view = { - members: [{ - id: SESSION, name: 'lead', role: 'lead' as const, status: 'inactive' as const, diagnostics: [], - }], tasks: [task], - } - ctx.provide('remote.agentTeams', { - view: (...args: unknown[]) => { - calls.push({ method: 'agentTeams/view', args }) - return Promise.resolve(options.remoteFailure - ? failure - : { ok: true as const, value: view }) - }, - }) const navigation: unknown[] = [] let mainSessionId = options.addressed === true ? CHILD : SESSION - const projectionsBySession = options.catalog === 'missing' - ? {} - : { - [SESSION]: { state: 'ready' as const, error: null, values: { subagentCatalog: options.catalog === 'empty' ? [] : [{ createdAt: 1, id: CHILD, - mode: 'continuable' as const, - label: 'worker' as const, - }] } }, - } ctx.provide('sessions', { - list: { getSnapshot: () => ({ projectionsBySession }) }, binding: (id: SessionId) => options.addressed === true && id === CHILD ? { session: { getSnapshot: () => ({ subagent: { @@ -106,153 +48,78 @@ async function bench(options: { name: 'root', children: { 'conversation.session.header.actions': { kind: 'list', scope: 'session' } }, } as never, () => null) - if (options.registrationFailure === true) { - vi.spyOn(ctx.slots, 'inject').mockImplementationOnce(() => { throw new Error('slot registration failed') }) - } - const fiber = options.registrationFailure === true - ? ctx.plugin({ apply() {} }) - : ctx.plugin({ inject: [...inject], apply: clientCtx => mountAgentTeamUi(clientCtx, REMOTE) }) - const activation: Promise = options.registrationFailure === true - ? mountAgentTeamUi(ctx, REMOTE).catch((error: unknown) => error) - : fiber.await() - if (options.registrationFailure !== true) { - await activation - } else { - await fiber.await() - } + const fiber = ctx.plugin({ inject: [...inject], apply }) + await fiber.await() const entry = () => ctx.slots.entries('conversation.session.header.actions') .find(candidate => candidate.component === TeamAction) + const actions = (): TeamActionInjected => { + const injected = entry()!.inject!() + const { openTeammate } = injected + if (typeof openTeammate !== 'function') { + throw new Error('Team header action lacks its injected callbacks') + } + return { + openTeammate: openTeammate as TeamActionInjected['openTeammate'], + } + } return { ctx, fiber, - activation, - calls, navigation, - remote, entry, + actions, collapseHeader, select: (sessionId: SessionId) => { mainSessionId = sessionId }, } } describe('ui-team browser plugin', () => { - it('registers one disposable header action with a read-only RPC-backed task board', async () => { + it('registers one disposable header action without a Team Remote namespace', async () => { const b = await bench() - expect(inject).toEqual(['sessions', 'uiWorkspace', 'remote', 'slots', 'locale']) + expect(inject).toEqual(['sessions', 'uiWorkspace', 'slots', 'locale']) expect(b.entry()).toMatchObject({ - options: { id: 'agent-team', order: 20 }, + options: { id: 'agent-team', order: -20 }, locale: 'agent-team', }) - expect(b.remote.mount).toHaveBeenCalledOnce() - expect(b.remote.mount).toHaveBeenCalledWith(REMOTE) - const actions = (b.entry()!.inject as unknown as () => TeamActionInjected)() - expect((await actions.load(SESSION)).ok).toBe(true) - expect(b.calls).toEqual([{ method: 'agentTeams/view', args: [SESSION] }]) + const t = b.ctx.locale.bind('agent-team') + expect(t('trigger')).toBe('Agent Team') - actions.openTeammate(SESSION, { - id: SESSION, - name: 'lead', - role: 'lead', - status: 'inactive', - diagnostics: [], - }) expect(b.navigation).toEqual([]) await b.fiber.dispose() expect(b.entry()).toBeUndefined() - expect(b.remote.disposeMount).toHaveBeenCalledOnce() + expect(t('empty')).toBe('empty') }) - it('unmounts the Remote contribution when later Client registration fails', async () => { - const b = await bench({ registrationFailure: true }) - await expect(b.activation).resolves.toMatchObject({ message: 'slot registration failed' }) - expect(b.remote.mount).toHaveBeenCalledOnce() - expect(b.remote.disposeMount).toHaveBeenCalledOnce() - }) - - it('returns Remote carrier failures unchanged', async () => { - const view = await bench({ remoteFailure: true }) - const viewActions = (view.entry()!.inject as unknown as () => TeamActionInjected)() - await expect(viewActions.load(SESSION)).resolves.toMatchObject({ - ok: false, - error: { code: 'gateway/internal', message: 'offline' }, - }) - - }) - - it('opens a continuable teammate address without refreshing the parent catalog', async () => { + it('opens a continuable teammate address without touching the parent catalog', async () => { const b = await bench() - const actions = (b.entry()!.inject as unknown as () => TeamActionInjected)() - const member: TeamRosterMember = { - id: CHILD, - name: 'worker', - role: 'teammate', - status: 'inactive', - diagnostics: [], - } - actions.openTeammate(SESSION, member) + b.actions().openTeammate(SESSION, CHILD) expect(b.navigation).toEqual([ - ['open', { - parentSessionId: SESSION, - childSessionId: CHILD, - mode: 'continuable', - }], + ['open', { parentSessionId: SESSION, childSessionId: CHILD, mode: 'continuable' }], ]) }) - it('routes Team actions from an addressed teammate conversation back through its Lead', async () => { + it('routes teammate navigation from an addressed teammate conversation back through its Lead', async () => { const b = await bench({ addressed: true }) - const actions = (b.entry()!.inject as unknown as () => TeamActionInjected)() - await actions.load(CHILD) - actions.openTeammate(CHILD, { - id: CHILD, - name: 'worker', - role: 'teammate', - status: 'inactive', - diagnostics: [], - }) - expect(b.calls[0]).toEqual({ method: 'agentTeams/view', args: [SESSION] }) + b.actions().openTeammate(CHILD, CHILD) expect(b.navigation).toEqual([ - ['open', { - parentSessionId: SESSION, - childSessionId: CHILD, - mode: 'continuable', - }], + ['open', { parentSessionId: SESSION, childSessionId: CHILD, mode: 'continuable' }], ]) }) + it('opens the Lead from an addressed teammate conversation', async () => { + const b = await bench({ addressed: true }) + b.actions().openTeammate(CHILD, SESSION) + expect(b.navigation).toEqual([['open', SESSION]]) + }) + it('does not open a teammate from a conversation outside the main view', async () => { const b = await bench() - const actions = (b.entry()!.inject as unknown as () => TeamActionInjected)() b.select('other-session' as SessionId) - actions.openTeammate(SESSION, { - id: CHILD, - name: 'worker', - role: 'teammate', - status: 'inactive', - diagnostics: [], - }) + b.actions().openTeammate(SESSION, CHILD) expect(b.navigation).toEqual([]) }) - it('opens a teammate when the parent catalog is missing or empty', async () => { - for (const catalog of ['missing', 'empty'] as const) { - const b = await bench({ catalog }) - const actions = (b.entry()!.inject as unknown as () => TeamActionInjected)() - actions.openTeammate(SESSION, { - id: CHILD, - name: 'worker', - role: 'teammate', - status: 'inactive', - diagnostics: [], - }) - expect(b.navigation).toEqual([ - ['open', { parentSessionId: SESSION, childSessionId: CHILD, mode: 'continuable' }], - ]) - await b.fiber.dispose() - } - }) - it('re-registers after the conversation header slot is collapsed and declared again', async () => { const b = await bench() expect(b.entry()).toBeDefined() diff --git a/packages/experimental/client-ui-agent-team/tests/team-action.client.spec.tsx b/packages/experimental/client-ui-agent-team/tests/team-action.client.spec.tsx index 82a90c7e02..11f57e6cc1 100644 --- a/packages/experimental/client-ui-agent-team/tests/team-action.client.spec.tsx +++ b/packages/experimental/client-ui-agent-team/tests/team-action.client.spec.tsx @@ -1,22 +1,31 @@ // @vitest-environment jsdom +import { Profiler } from 'react' import { afterEach, describe, expect, it, vi } from 'vitest' -import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { - TeamTaskId, TeamTaskView as TeamTask, TeamView, + TeamMemberProjection, TeamProjection, TeamTaskId, TeamTaskView as TeamTask, } from '@deepseek-ai/dsh-experimental-agent-team/client' -import { makeTranslate, RemoteError } from '@deepseek-ai/dsh-client-test-runtime' +import type { SessionListState, SessionSnapshot, SessionSummary, UseProjection } from '@deepseek-ai/dsh-api-session-controller/client' +import type { SessionStatusSnapshot } from '@deepseek-ai/dsh-client-ui-session/client' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' +import { bindSnapshotSelector, makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' -import { - TeamAction, type TeamActionInjected, type TeamActionProps, type TeamActionResult, -} from '../src/client/TeamAction.tsx' +import { TeamAction, type TeamActionInjected, type TeamActionProps } from '../src/client/TeamAction.tsx' import { zh } from '../src/client/locales.ts' -afterEach(cleanup) +afterEach(() => { + cleanup() + vi.useRealTimers() + vi.restoreAllMocks() + vi.unstubAllGlobals() +}) const SESSION = 'lead' as SessionId +const WORKER = 'worker-id' as SessionId const TASK_1 = 'task-1' as TeamTaskId +const TASK_2 = 'task-2' as TeamTaskId const task: TeamTask = { id: TASK_1, revision: 1, @@ -29,214 +38,307 @@ const task: TeamTask = { ready: false, writeScopeWarnings: ['write scopes overlap with task-2'], } -const view: TeamView = { - members: [ - { id: SESSION, name: 'lead', role: 'lead', status: 'inactive', model: 'model-a', diagnostics: [] }, - { - id: 'worker-id' as SessionId, - name: 'worker', - role: 'teammate', - status: 'inactive', - model: 'model-a', - diagnostics: [], - }, - ], - tasks: [task], +const lead: TeamMemberProjection = { id: SESSION, name: 'lead', role: 'lead', phase: 'active' } +const worker: TeamMemberProjection = { + id: WORKER, name: 'worker', role: 'teammate', phase: 'active', +} +const team: TeamProjection = { members: [lead, worker], tasks: [task] } + +type Projections = SessionListState['projectionsBySession'] + +function summary(id: SessionId, running: boolean): SessionSummary { + return { id, displayTitle: id, running, retainedBy: {}, blank: false, updatedAt: 0 } } -function remoteFailure(message: string): TeamActionResult { - return { ok: false, error: new RemoteError('gateway/internal', message, {}) } -} - -function props(actions: TeamActionInjected, sessionId: SessionId = SESSION): TeamActionProps { - return { +function bench(options: { + projections?: Projections + sessionId?: SessionId + parentSessionId?: SessionId + openState?: SessionSnapshot['openState'] + statuses?: SessionStatusSnapshot + running?: Record +} = {}) { + const sessionId = options.sessionId ?? SESSION + const byId: Record = {} + for (const [id, running] of Object.entries(options.running ?? {}) as [SessionId, boolean][]) byId[id] = summary(id, running) + const sessions = createSnapshotStore({ + ids: Object.keys(byId) as SessionId[], byId, phase: 'ready', + projectionsBySession: options.projections ?? { [SESSION]: { state: 'ready', error: null, values: { agentTeam: team } } }, + }) + const statuses = createSnapshotStore(options.statuses ?? new Map()) + const session = createSnapshotStore({ sessionId, - ...actions, + pendingSubmissions: [], + running: false, + subagent: options.parentSessionId === undefined + ? null + : { address: { parentSessionId: options.parentSessionId, childSessionId: sessionId, mode: 'continuable' } }, + removed: false, + openState: options.openState ?? 'open', + openError: null, + hasMore: false, + loadingOlder: false, + promptError: null, + blank: false, + lastAgentError: null, + promptAttempted: false, + awaitingFirstTurn: false, + }) + const useSessions = bindSnapshotSelector(sessions) + const injected: TeamActionInjected = { openTeammate: vi.fn() } + const props: TeamActionProps = { + sessionId, + useSession: bindSnapshotSelector(session), + useProjection: ((key: string, select?: (value: unknown) => unknown) => { + const value = useSessions(state => state.projectionsBySession[sessionId]?.values[ + key as keyof SessionListState['projectionsBySession'][SessionId]['values'] + ]) + return select === undefined ? value : select(value) + }) as UseProjection, + useSessions, + useSessionStatus: bindSnapshotSelector(statuses), + ...injected, t: makeTranslate(zh, commonZh), - } as unknown as TeamActionProps + } as TeamActionProps + return { props, injected, sessions, statuses, session } } -function actions(overrides: Partial = {}): TeamActionInjected { - return { - load: () => Promise.resolve({ ok: true, value: view }), - openTeammate: () => {}, - ...overrides, - } +function openPanel(): void { + fireEvent.click(screen.getByRole('button', { name: /智能体团队/u })) +} + +function setProjectionSnapshot( + sessions: ReturnType['sessions'], + sessionId: SessionId, + snapshot: Projections[SessionId], +): void { + act(() => { + const current = sessions.getSnapshot() + sessions.set({ ...current, projectionsBySession: { ...current.projectionsBySession, [sessionId]: snapshot } }) + }) +} + +function setProjection(sessions: ReturnType['sessions'], sessionId: SessionId, value: TeamProjection): void { + setProjectionSnapshot(sessions, sessionId, { state: 'ready', error: null, values: { agentTeam: value } }) } describe('TeamAction', () => { - it('ignores a stale Team load after the conversation switches sessions', async () => { - const nextSession = 'next-lead' as SessionId - const firstLoad = Promise.withResolvers<{ ok: true; value: TeamView }>() - const nextView: TeamView = { - ...view, - members: [{ id: nextSession, name: 'lead', role: 'lead', status: 'inactive', diagnostics: [] }], - tasks: [{ ...task, id: 'task-next' as TeamTaskId, subject: 'Next session task' }], - } - const load = vi.fn((sessionId: SessionId) => sessionId === SESSION - ? firstLoad.promise - : Promise.resolve({ ok: true as const, value: nextView })) - const injected = actions({ load }) - const rendered = render() - fireEvent.click(screen.getByRole('button', { name: /Agent Team/u })) - await waitFor(() => { expect(load).toHaveBeenCalledWith(SESSION) }) - - rendered.rerender() - fireEvent.click(screen.getByRole('button', { name: /Agent Team/u })) - expect(await screen.findByText('Next session task')).toBeTruthy() - firstLoad.resolve({ ok: true, value: view }) - await Promise.resolve() - - await waitFor(() => { - expect(screen.getByText('Next session task')).toBeTruthy() - expect(screen.queryByText('Implement runtime')).toBeNull() - }) - }) - - it('loads roster/task diagnostics on open and navigates a healthy teammate', async () => { - const openTeammate = vi.fn() - render() - fireEvent.click(screen.getByRole('button', { name: /Agent Team/u })) - const worker = await screen.findByRole('button', { name: /worker/u }) + it('renders the Lead projection and applies later projection frames without any user action', async () => { + const b = bench() + render() + expect(screen.getByRole('button', { name: /智能体团队/u }).textContent).toBe(zh.trigger) + openPanel() + expect(await screen.findByText('Implement runtime')).toBeTruthy() expect(screen.getByText('write scopes overlap with task-2')).toBeTruthy() - fireEvent.click(worker) - await waitFor(() => { expect(openTeammate).toHaveBeenCalledWith(SESSION, view.members[1]) }) + expect(screen.queryByRole('button', { name: /刷新|Refresh/u })).toBeNull() + + setProjection(b.sessions, SESSION, { + members: [lead, worker, { id: 'worker-b' as SessionId, name: 'worker-b', role: 'teammate', phase: 'provisioning' }], + tasks: [task, { ...task, id: TASK_2, subject: 'Pushed task', status: 'pending', ready: true, writeScopeWarnings: [] }], + }) + expect(screen.getByText('Pushed task')).toBeTruthy() + expect(screen.getByRole('button', { name: /worker-b/u })).toHaveProperty('disabled', true) + expect(screen.getByRole('heading', { name: '成员3' })).toBeTruthy() }) - it('keeps only the newest overlapping refresh for one session', async () => { - const older = Promise.withResolvers>() - const newer = Promise.withResolvers>() - const newestView = { - ...view, - tasks: [{ ...task, id: 'newest-task' as TeamTaskId, subject: 'Newest task' }], - } - const load = vi.fn() - .mockResolvedValueOnce({ ok: true, value: view }) - .mockImplementationOnce(() => older.promise) - .mockImplementationOnce(() => newer.promise) - render() - fireEvent.click(screen.getByRole('button', { name: /Agent Team/u })) - await screen.findByText('Implement runtime') + it('overlays live Session status and the durable model selection on roster rows', () => { + const statuses: SessionStatusSnapshot = new Map([[WORKER, { running: true, pendingInteraction: undefined, completionUnread: false }]]) + const b = bench({ + statuses, + running: { [SESSION]: true }, + projections: { + [SESSION]: { + state: 'ready', error: null, + values: { agentTeam: team, modelSelection: { lastUsed: null, next: { provider: 'p', model: 'lead-model' } } }, + }, + [WORKER]: { + state: 'ready', error: null, + values: { modelSelection: { lastUsed: { provider: 'p', model: 'worker-model' }, next: { provider: 'p', model: 'worker-model' } } }, + }, + }, + }) + render() + openPanel() + expect(screen.getByRole('button', { name: new RegExp(`lead.*${zh['memberStatus.running']}.*lead-model`, 'u') })).toBeTruthy() + const row = screen.getByRole('button', { name: new RegExp(`worker.*${zh['memberStatus.running']}.*worker-model`, 'u') }) + expect(row.querySelector('[data-state="ongoing"]')).not.toBeNull() - const refresh = screen.getByRole('button', { name: zh.refresh }) - fireEvent.click(refresh) - expect(screen.getByRole('status', { name: zh.loading })).toBeTruthy() - fireEvent.click(refresh) - newer.resolve({ ok: true, value: newestView }) - expect(await screen.findByText('Newest task')).toBeTruthy() - older.resolve({ ok: true, value: view }) - await Promise.resolve() + act(() => { b.statuses.set(new Map([[WORKER, { running: false, pendingInteraction: undefined, completionUnread: false }]])) }) + expect(screen.getByRole('button', { name: /^worker.*未运行/u })).toBeTruthy() - expect(screen.getByText('Newest task')).toBeTruthy() - expect(screen.queryByText('Implement runtime')).toBeNull() + act(() => { + b.statuses.set(new Map()) + b.sessions.update((draft) => { draft.byId[WORKER] = summary(WORKER, true) }) + }) + expect(screen.getByRole('button', { name: /^worker.*运行中/u })).toBeTruthy() }) - it('renders roster/task state variants and contains navigation, refresh, and close actions', async () => { + it('reads the Lead projection from an addressed teammate conversation', () => { + const b = bench({ sessionId: WORKER, parentSessionId: SESSION }) + render() + openPanel() + expect(screen.getByText('Implement runtime')).toBeTruthy() + expect(screen.getByRole('button', { name: /^worker/u }).disabled).toBe(true) + fireEvent.click(screen.getByRole('button', { name: /^lead/u })) + expect(b.injected.openTeammate).toHaveBeenCalledWith(WORKER, SESSION) + }) + + it.each([false, true])('accepts shared baselines and late capability updates (teammate page: %s)', (addressed) => { + const b = bench({ + ...(addressed ? { sessionId: WORKER, parentSessionId: SESSION } : {}), + projections: {}, openState: 'loading', + }) + render() + openPanel() + expect(screen.getByRole('status').textContent).toBe(zh.loading) + act(() => { b.session.set({ ...b.session.getSnapshot(), openState: 'open' }) }) + expect(screen.getByRole('status').textContent).toBe(zh.unavailable) + setProjectionSnapshot(b.sessions, SESSION, { state: 'idle', error: null, values: { agentTeam: team } }) + expect(screen.getByText('Implement runtime')).toBeTruthy() + expect(screen.queryByRole('status')).toBeNull() + setProjectionSnapshot(b.sessions, SESSION, { state: 'idle', error: null, values: {} }) + expect(screen.getByRole('status').textContent).toBe(zh.unavailable) + setProjection(b.sessions, SESSION, { members: [lead], tasks: [] }) + expect(screen.getByText(zh.empty)).toBeTruthy() + }) + + it('waits for the shared Session list and accepts cached projections without an explicit read', () => { + const b = bench({ projections: {} }) + b.sessions.update((draft) => { draft.phase = 'pending' }) + render() + openPanel() + expect(screen.getByRole('status').textContent).toBe(zh.loading) + act(() => { b.sessions.set({ + ...b.sessions.getSnapshot(), phase: 'ready', + projectionsBySession: { [SESSION]: { state: 'idle', error: null, values: { agentTeam: team } } }, + }) }) + expect(screen.getByText('Implement runtime')).toBeTruthy() + expect(screen.queryByRole('status')).toBeNull() + }) + + it.each([false, true])('ignores unrelated Session updates (teammate page: %s)', (addressed) => { + const b = bench(addressed ? { sessionId: WORKER, parentSessionId: SESSION } : {}) + const onRender = vi.fn() + render() + openPanel() + onRender.mockClear() + + act(() => { + const current = b.sessions.getSnapshot() + const projectionsBySession = Object.fromEntries(Object.entries(current.projectionsBySession) + .map(([id, snapshot]) => [id, { ...snapshot }])) + b.sessions.set({ + ...current, + byId: { ...current.byId, ['unrelated' as SessionId]: summary('unrelated' as SessionId, true) }, + projectionsBySession: { + ...projectionsBySession, + ['unrelated' as SessionId]: { state: 'ready', error: null, values: { agentTeam: { members: [], tasks: [] } } }, + }, + }) + b.statuses.set(new Map([['unrelated' as SessionId, { running: true, pendingInteraction: undefined, completionUnread: false }]])) + }) + expect(onRender).not.toHaveBeenCalled() + + setProjectionSnapshot(b.sessions, WORKER, { + state: 'ready', error: null, + values: { modelSelection: { lastUsed: null, next: { provider: 'p', model: 'updated-worker-model' } } }, + }) + expect(screen.getByRole('button', { name: /worker.*updated-worker-model/u })).toBeTruthy() + setProjection(b.sessions, SESSION, { ...team, tasks: [{ ...task, subject: 'Updated parent task' }] }) + expect(screen.getByText('Updated parent task')).toBeTruthy() + }) + + it('shows capability absence after a successful read instead of loading forever', () => { + const b = bench({ projections: { [SESSION]: { state: 'ready', error: null, values: {} } } }) + render() + openPanel() + expect(screen.queryByText(zh.loading)).toBeNull() + expect(screen.getByRole('status').textContent).toBe('Team 暂不可用') + }) + + it('surfaces a Team projection failure beside the last valid state', () => { + const b = bench({ + projections: { [SESSION]: { state: 'ready', error: null, values: { agentTeam: { ...team, failure: 'revision is not contiguous' } } } }, + }) + render() + openPanel() + expect(screen.getByRole('alert').textContent).toBe('团队持久记录无效:revision is not contiguous') + expect(screen.getByText('Implement runtime')).toBeTruthy() + }) + + it('renders roster/task state variants and reports navigation failures', () => { const { ownerName: _ownerName, ...unownedTask } = task - const richView: TeamView = { - ...view, - members: [ - view.members[0]!, - { ...view.members[1]!, status: 'running' }, - { - id: 'failed-id' as SessionId, - name: 'failed-worker', - role: 'teammate', - status: 'failed', - diagnostics: ['provider failed'], + const b = bench({ + projections: { + [SESSION]: { + state: 'ready', error: null, + values: { + agentTeam: { + members: [ + lead, + worker, + { id: 'failed-id' as SessionId, name: 'failed-worker', role: 'teammate', phase: 'failed', error: 'provider failed' }, + { id: 'provisioning-id' as SessionId, name: 'provisioning-worker', role: 'teammate', phase: 'provisioning' }, + ], + tasks: [ + { ...unownedTask, id: 'ready-task' as TeamTaskId, status: 'pending', ready: true }, + { ...unownedTask, id: 'blocked-task' as TeamTaskId, status: 'pending', ready: false, blockedBy: [TASK_1] }, + { ...task, id: 'completed-task' as TeamTaskId, status: 'completed', ownerName: 'worker' }, + ], + }, + }, }, - { - id: 'provisioning-id' as SessionId, - name: 'provisioning-worker', - role: 'teammate', - status: 'provisioning', - diagnostics: [], - }, - ], - tasks: [ - { ...unownedTask, id: 'ready-task' as TeamTaskId, status: 'pending', ready: true }, - { ...unownedTask, id: 'blocked-task' as TeamTaskId, status: 'pending', ready: false, blockedBy: [TASK_1] }, - { ...task, id: 'completed-task' as TeamTaskId, status: 'completed' }, - ], - } - const load = vi.fn(() => Promise.resolve({ ok: true as const, value: richView })) - const openTeammate = vi.fn(() => { throw new Error('navigation failed') }) - render() - fireEvent.click(screen.getByRole('button', { name: /Agent Team/u })) - expect(await screen.findByText('provider failed')).toBeTruthy() + }, + }) + b.injected.openTeammate = vi.fn(() => { throw new Error('navigation failed') }) + render() + openPanel() + expect(screen.getByText('provider failed')).toBeTruthy() expect(screen.getByText(zh.ready)).toBeTruthy() expect(screen.getByText(zh.blocked)).toBeTruthy() + expect(screen.getAllByText('Owner: 未分配')).toHaveLength(2) + expect(screen.getByText('Owner: worker')).toBeTruthy() const failedMember = screen.getByRole('button', { name: /failed-worker/u }) const provisioningMember = screen.getByRole('button', { name: /provisioning-worker/u }) expect(failedMember.disabled).toBe(true) expect(failedMember.querySelector('[data-state="error"]')).not.toBeNull() expect(provisioningMember.disabled).toBe(true) expect(provisioningMember.querySelector('[data-state="ongoing"]')).not.toBeNull() + expect(screen.getByRole('button', { name: /^lead/u }).disabled).toBe(true) const tasks = [...document.querySelectorAll('article')] expect(tasks.map(card => card.querySelector('[data-state]')?.getAttribute('data-state'))) .toEqual(['idle', 'warning', 'done']) + for (const card of tasks) expect(card.querySelector('button, input, select, textarea')).toBeNull() - fireEvent.click(screen.getByRole('button', { name: /^worker运行中/u })) - expect(await screen.findByText('Error: navigation failed')).toBeTruthy() - fireEvent.click(screen.getByRole('button', { name: zh.refresh })) - await waitFor(() => { expect(load).toHaveBeenCalledTimes(2) }) - fireEvent.click(screen.getByRole('button', { name: /Agent Team/u })) + fireEvent.click(screen.getByRole('button', { name: /^worker/u })) + expect(screen.getByRole('alert').textContent).toBe('Error: navigation failed') + fireEvent.keyDown(screen.getByRole('dialog'), { key: 'Escape' }) expect(screen.queryByRole('dialog')).toBeNull() - fireEvent.click(screen.getByRole('button', { name: /Agent Team/u })) - await screen.findByRole('dialog') - fireEvent.click(screen.getByRole('button', { name: zh.close })) + expect(document.activeElement).toBe(screen.getByRole('button', { name: /智能体团队/u })) + }) + + it('closes the panel and clears a navigation failure when the conversation switches sessions', () => { + const b = bench() + b.injected.openTeammate = vi.fn(() => { throw new Error('navigation failed') }) + const rendered = render() + openPanel() + fireEvent.click(screen.getByRole('button', { name: /^worker/u })) + expect(screen.getByRole('alert')).toBeTruthy() + + const next = bench({ sessionId: 'next-lead' as SessionId, projections: {}, openState: 'loading' }) + rendered.rerender() expect(screen.queryByRole('dialog')).toBeNull() - expect(document.activeElement).toBe(screen.getByRole('button', { name: /Agent Team/u })) + openPanel() + expect(screen.queryByRole('alert')).toBeNull() + expect(screen.getByRole('status').textContent).toBe(zh.loading) }) - it('shows load failures', async () => { - render( Promise.resolve(remoteFailure('load failed')), - }))} />) - fireEvent.click(screen.getByRole('button', { name: /Agent Team/u })) - expect(await screen.findByRole('alert')).toHaveProperty('textContent', 'load failed (gateway/internal)') - }) - - it('shows an empty task board without a create action', async () => { - render( Promise.resolve({ ok: true, value: { members: [], tasks: [] } }), - }))} />) - fireEvent.click(screen.getByRole('button', { name: /Agent Team/u })) - expect(await screen.findByText(zh.empty)).toBeTruthy() - expect(screen.queryByRole('button', { name: /新建任务/u })).toBeNull() - expect(screen.queryByRole('textbox')).toBeNull() - }) - - it('displays task ownership and status without mutation controls', async () => { - const { ownerName: _ownerName, ...unowned } = task - const tasks: TeamTask[] = [ - { ...unowned, status: 'pending', ready: true }, - { ...task, id: 'task-2' as TeamTaskId }, - { ...task, id: 'task-3' as TeamTaskId, status: 'completed', ownerName: 'worker' }, - ] - render( Promise.resolve({ ok: true, value: { ...view, tasks } }), - }))} />) - fireEvent.click(screen.getByRole('button', { name: /Agent Team/u })) - await screen.findAllByRole('article') - expect(screen.getByText('Owner: 未分配')).toBeTruthy() - expect(screen.getByText('Owner: lead')).toBeTruthy() - expect(screen.getByText('Owner: worker')).toBeTruthy() - expect(screen.getByText(zh['status.pending'])).toBeTruthy() - expect(screen.getByText(zh['status.in_progress'])).toBeTruthy() - expect(screen.getByText(zh['status.completed'])).toBeTruthy() - expect(screen.queryByRole('button', { name: /新建任务|编辑|完成|重开|删除/u })).toBeNull() - expect(screen.queryByRole('combobox')).toBeNull() - expect(screen.queryByRole('textbox')).toBeNull() - for (const card of screen.getAllByRole('article')) { - expect(card.querySelector('button, input, select, textarea')).toBeNull() - } - }) - it('keeps panel interactions open and dismisses on outside pointer or Escape', async () => { - const rendered = render() - const trigger = screen.getByRole('button', { name: /Agent Team/u }) + it('keeps panel interactions open and dismisses on outside pointer or Escape', () => { + const b = bench() + const rendered = render() + const trigger = screen.getByRole('button', { name: /智能体团队/u }) fireEvent.click(trigger) - const panel = await screen.findByRole('dialog') + const panel = screen.getByRole('dialog') expect(rendered.container.contains(panel)).toBe(false) expect(document.activeElement).toBe(panel) fireEvent.pointerDown(panel) @@ -246,29 +348,218 @@ describe('TeamAction', () => { fireEvent.pointerDown(document.body) expect(screen.queryByRole('dialog')).toBeNull() fireEvent.click(trigger) - await screen.findByText('Implement runtime') - fireEvent.keyDown(screen.getByRole('button', { name: zh.refresh }), { key: 'Enter' }) + fireEvent.keyDown(screen.getByRole('button', { name: /^worker/u }), { key: 'Enter' }) expect(screen.queryByRole('dialog')).not.toBeNull() - fireEvent.keyDown(screen.getByRole('button', { name: zh.refresh }), { key: 'Escape' }) + fireEvent.keyDown(screen.getByRole('button', { name: /^worker/u }), { key: 'Escape' }) expect(screen.queryByRole('dialog')).toBeNull() expect(document.activeElement).toBe(trigger) fireEvent.keyDown(trigger, { key: 'Escape' }) expect(screen.queryByRole('dialog')).toBeNull() }) - it('keeps focus within the trigger or panel and closes when focus moves elsewhere', async () => { - render(<>) - const trigger = screen.getByRole('button', { name: /Agent Team/u }) + it.each([true, false])('clamps a long task description behind an expand toggle (ResizeObserver: %s)', async (resizeObserver) => { + if (!resizeObserver) vi.stubGlobal('ResizeObserver', undefined) + const scrollHeight = vi.spyOn(Element.prototype, 'scrollHeight', 'get').mockReturnValue(120) + const clientHeight = vi.spyOn(Element.prototype, 'clientHeight', 'get').mockReturnValue(60) + try { + render() + fireEvent.click(screen.getByRole('button', { name: /智能体团队/u })) + const expand = await screen.findByRole('button', { name: zh['task.expand'] }) + const description = screen.getByText('Build the Team runtime') + expect(expand.getAttribute('aria-expanded')).toBe('false') + expect(description.className).not.toBe('') + + fireEvent.click(expand) + const collapse = screen.getByRole('button', { name: zh['task.collapse'] }) + expect(collapse.getAttribute('aria-expanded')).toBe('true') + expect(screen.getByText('Build the Team runtime').className).toBe('') + + fireEvent.click(collapse) + expect(screen.getByRole('button', { name: zh['task.expand'] })).toBeTruthy() + } finally { + scrollHeight.mockRestore() + clientHeight.mockRestore() + } + }) + + it('opens on hover and preserves the trigger-to-panel crossing grace', async () => { + vi.useFakeTimers() + const advance = async (duration: number): Promise => { + await act(async () => { await vi.advanceTimersByTimeAsync(duration) }) + } + const b = bench() + const rendered = render() + const trigger = screen.getByRole('button', { name: /智能体团队/u }) + const root = trigger.parentElement! + + fireEvent.mouseEnter(trigger) + await advance(149) + expect(screen.queryByRole('dialog')).toBeNull() + await advance(1) + const panel = screen.getByRole('dialog') + + fireEvent.mouseEnter(trigger) + expect(screen.getByRole('dialog')).toBe(panel) + + fireEvent.mouseLeave(root) + fireEvent.mouseEnter(panel) + await advance(120) + expect(screen.getByRole('dialog')).toBe(panel) + + fireEvent.mouseLeave(panel) + await advance(119) + expect(screen.getByRole('dialog')).toBe(panel) + await advance(1) + expect(screen.queryByRole('dialog')).toBeNull() + + fireEvent.mouseEnter(trigger) + fireEvent.mouseLeave(root, { relatedTarget: document.body }) + rendered.unmount() + await advance(150) + }) + + it('pins a click-opened panel through hover-out until explicit dismissal', async () => { + vi.useFakeTimers() + const advance = async (duration: number): Promise => { + await act(async () => { await vi.advanceTimersByTimeAsync(duration) }) + } + render() + const trigger = screen.getByRole('button', { name: /智能体团队/u }) + const root = trigger.parentElement! + fireEvent.click(trigger) - const panel = await screen.findByRole('dialog') - fireEvent.blur(panel, { relatedTarget: screen.getByRole('button', { name: zh.refresh }) }) + const panel = screen.getByRole('dialog') + fireEvent.mouseEnter(trigger) + fireEvent.mouseLeave(root) + await advance(300) expect(screen.getByRole('dialog')).toBe(panel) - fireEvent.blur(panel, { relatedTarget: trigger }) + fireEvent.mouseEnter(panel) + fireEvent.mouseLeave(panel) + await advance(300) expect(screen.getByRole('dialog')).toBe(panel) - fireEvent.blur(panel, { relatedTarget: null }) - expect(screen.getByRole('dialog')).toBe(panel) - fireEvent.blur(panel, { relatedTarget: screen.getByRole('button', { name: 'Outside' }) }) + fireEvent.pointerDown(document.body) + expect(screen.queryByRole('dialog')).toBeNull() + + fireEvent.mouseEnter(trigger) + await advance(150) + const hovered = screen.getByRole('dialog') + fireEvent.click(trigger) + fireEvent.mouseLeave(root) + await advance(300) + expect(screen.getByRole('dialog')).toBe(hovered) + fireEvent.keyDown(trigger, { key: 'Escape' }) + expect(screen.queryByRole('dialog')).toBeNull() + fireEvent.mouseEnter(trigger) + fireEvent.mouseLeave(root) + await advance(300) expect(screen.queryByRole('dialog')).toBeNull() }) + it('opens by click only while the trigger is collapsed to its icon', async () => { + vi.useFakeTimers() + const advance = async (duration: number): Promise => { + await act(async () => { await vi.advanceTimersByTimeAsync(duration) }) + } + render() + const trigger = screen.getByRole('button', { name: /智能体团队/u }) + const label = screen.getByText(zh.trigger) + const computedStyle = window.getComputedStyle.bind(window) + vi.spyOn(window, 'getComputedStyle').mockImplementation(element => + element === label ? { display: 'none' } as CSSStyleDeclaration : computedStyle(element)) + + fireEvent.mouseEnter(trigger) + await advance(300) + expect(screen.queryByRole('dialog')).toBeNull() + + fireEvent.click(trigger) + expect(screen.getByRole('dialog')).toBeTruthy() + }) + + it('keeps projection updates live without polling while pinned', async () => { + vi.useFakeTimers() + const b = bench() + render() + openPanel() + await act(async () => { await vi.advanceTimersByTimeAsync(60_000) }) + setProjection(b.sessions, SESSION, { ...team, tasks: [{ ...task, subject: 'Pushed while pinned' }] }) + expect(screen.getByText('Pushed while pinned')).toBeTruthy() + }) + + it('keeps an accessible trigger name when its label is collapsed', () => { + render() + screen.getByText(zh.trigger).style.display = 'none' + expect(screen.getByRole('button', { name: zh.trigger })).toBeTruthy() + }) + + it('dismisses a hovered panel with Escape without stealing composer focus', async () => { + vi.useFakeTimers() + render(<>