From 68e43b173e89079cdda2940677cb42ffe011515e Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Tue, 22 Sep 2026 01:55:03 +0800 Subject: [PATCH 01/28] fix(native-command): open Windows paths through the shell's resolution The settings sheet's Open configuration file action opened nothing on Windows when the user's default lived only in the Explorer per-user record (FileExts\\\\UserChoiceLatest), which Explorer honors and the association resolver inside the host process does not. Invoke-Item also exits 0 when it finds no application, so the Remote answered { opened: true } and the sheet rendered no error. Windows opens now hand the path to explorer.exe as one argv element - the resolution a double-click performs - and revealNativePath shares that invocation rule. The shared helper accepts Explorer's delegate exit 1 for an open and a reveal alike; every other failure and every cancellation still rejects. The PowerShell command string and powershellLiteral are gone: the path crosses the process boundary as argv, with no escaping step. Fixes #4428 --- ...ws-open-through-shell-resolution.i18n.yaml | 6 +++ ...2-windows-open-through-shell-resolution.md | 41 ++++++++++++++ ...indows-open-through-shell-resolution.zh.md | 41 ++++++++++++++ ...-25-promote-open-anywhere-plugin.i18n.yaml | 4 +- ...2026-08-25-promote-open-anywhere-plugin.md | 2 +- ...6-08-25-promote-open-anywhere-plugin.zh.md | 2 +- packages/host/open-in-app/src/resolver.ts | 4 +- .../host/open-in-app/tests/resolver.spec.ts | 6 +-- packages/util/native-command/README.i18n.yaml | 4 +- packages/util/native-command/README.md | 2 +- packages/util/native-command/README.zh.md | 2 +- .../util/native-command/src/path-opener.ts | 53 ++++++++++++------- .../native-command/tests/path-opener.spec.ts | 53 +++++++++++-------- 13 files changed, 165 insertions(+), 55 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-09-22-windows-open-through-shell-resolution.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-09-22-windows-open-through-shell-resolution.md create mode 100644 .agents/notes/implemented/bug-fix/2026-09-22-windows-open-through-shell-resolution.zh.md 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..2cf3e341c8 --- /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: d4bc1f1ff9dfbff4528403bcd276058845837998 +2026-09-22-windows-open-through-shell-resolution.zh.md: 29518901624402c4ebe7fd925eb3e7fcae1ec204 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..d4bc1f1ff9 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-22-windows-open-through-shell-resolution.md @@ -0,0 +1,41 @@ +# Agent Note: Windows opens use the shell's own application resolution + +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. 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 now crosses the process boundary as an argv element, so no escaping step exists to get wrong. + +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. + +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 runner inherits the host environment and creates no detached process. + +## Verification + +`path-opener.spec.ts` pins the argv Explorer receives for both intents, the accepted delegate exit code 1 for an open and a reveal, the preserved failure and cancellation, and the absence of any escaping step. `resolver.spec.ts` pins the open-in-app route's Windows command. Native desktop verification stays on Windows, per the package's test policy; file and directory opens were observed on Windows 11 build 26340, where the associated application started and a folder window appeared for each target. 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..2951890162 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-22-windows-open-through-shell-resolution.zh.md @@ -0,0 +1,41 @@ +# Agent Note: Windows 打开走 shell 自身的默认应用解析 + +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。资源管理器执行的是双击所执行的默认应用解析,因此由 shell 的答案选择应用——包括只记录在较新的按用户记录里的默认值;而完全没有处理程序的机器会得到资源管理器自己的「你要如何打开这个文件?」选择框,而不是毫无反应。 + +`runExplorer` 为两种操作持有唯一一条 Explorer 调用规则:退出码 1 是 Explorer 把请求转交给已在运行的桌面进程后返回的交接码,它照常兑现调用方的 Promise;其余失败和取消仍然报错。该帮助函数取代了 PowerShell 命令字符串,同时删掉了 `powershellLiteral` 及其单引号翻倍逻辑:路径现在以 argv 元素跨进程边界,不存在可能写错的转义步骤。 + +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 的选择框。此处不校验窗口是否真的出现。 + +目录走同一条路径。[open-in-app 记录](../feature/2026-08-25-promote-open-anywhere-plugin.zh.md)中被否决的方案是 detached 且经过凭据清洗的 `explorer.exe ` 派生进程,那不是本交接的做法:运行器继承宿主环境,也不创建 detached 进程。 + +## 验证 + +`path-opener.spec.ts` 固定两种意图交给 Explorer 的 argv、打开与选中都可接受的交接退出码 1、仍然报错的普通失败与取消,以及不存在任何转义步骤。`resolver.spec.ts` 固定 open-in-app 路由在 Windows 上的命令。按该包测试策略,原生桌面验证留在 Windows;在 Windows 11 build 26340 上已观察到文件与目录打开分别启动了关联应用和文件夹窗口。 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/packages/host/open-in-app/src/resolver.ts b/packages/host/open-in-app/src/resolver.ts index 62c6f8e4bc..970118e22a 100644 --- a/packages/host/open-in-app/src/resolver.ts +++ b/packages/host/open-in-app/src/resolver.ts @@ -680,8 +680,8 @@ function isMissingExecutable(error: unknown): boolean { * Open one directory through the OS shell's open verb under the launch watch * window: the opener command completing inside the window decides the * outcome, and an opener still running when it closes counts as launched and - * keeps running (a cold `powershell.exe` start can outlive the window; its - * late settlement is swallowed because the request already answered). + * keeps running (a cold shell opener can outlive the window; its late + * settlement is swallowed because the request already answered). */ function runShellOpen( path: string, watchMs: number, internals: ResolvedInternals, diff --git a/packages/host/open-in-app/tests/resolver.spec.ts b/packages/host/open-in-app/tests/resolver.spec.ts index 7ac003b055..cf4b8c79f8 100644 --- a/packages/host/open-in-app/tests/resolver.spec.ts +++ b/packages/host/open-in-app/tests/resolver.spec.ts @@ -581,15 +581,15 @@ describe('launchResolved', () => { }, }), )).resolves.toBe('launched') - // The opener is the shipped Invoke-Item channel; the detached spawner never runs. + // The opener is the shipped Explorer channel; the detached spawner never runs. expect(spawns).toEqual([]) expect(commands).toEqual([ - ['powershell.exe', '-NoProfile', '-Command', "Invoke-Item -LiteralPath 'C:\\w\\dir'"], + ['explorer.exe', 'C:\\w\\dir'], ]) }) it('counts a shell-open opener that outlives the watch window as launched, and a fast failure as failed', async () => { - // A cold powershell start can outlive the window: still-running counts launched. + // A cold shell opener can outlive the window: still-running counts launched. await expect(launchResolved( { launch: { kind: 'shell-open' } }, '/w/dir', 25, bare({ platform: 'darwin', run: () => new Promise(() => {}) }), diff --git a/packages/util/native-command/README.i18n.yaml b/packages/util/native-command/README.i18n.yaml index bcd08be397..5010c1e803 100644 --- a/packages/util/native-command/README.i18n.yaml +++ b/packages/util/native-command/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/util/native-command/README.md -README.md: 23df6d784aae1a5a911b556a3d70bae7971bd566 -README.zh.md: e8548777191d1433188712c42797d4a902314f64 +README.md: cd090d69ae790ae6a1ccbe70f4a767246c3b4981 +README.zh.md: 17ff391fd29cef4909f415505a73aaca3b901f14 diff --git a/packages/util/native-command/README.md b/packages/util/native-command/README.md index 23df6d784a..cd090d69ae 100644 --- a/packages/util/native-command/README.md +++ b/packages/util/native-command/README.md @@ -45,7 +45,7 @@ The `NativeCommandRunner` type is the injectable command boundary for host integ ### Opening a Host path -`openNativePath(path, signal)` hands a path to the default application and prefers the named default browser for HTML and SVG where the platform can identify one. `openNativeAssociatedPath(path, signal)` always uses the file-type association, without a browser override. `openNativeTextFile(path, signal)` selects text-editor intent; on macOS it uses `open -t`. WSL paths are translated with `wslpath -w` before the Windows desktop receives them. `canOpenNativePath()` reports whether the current Host plausibly has a desktop target. +`openNativePath(path, signal)` hands a path to the default application and prefers the named default browser for HTML and SVG where the platform can identify one. `openNativeAssociatedPath(path, signal)` always uses the file-type association, without a browser override. `openNativeTextFile(path, signal)` selects text-editor intent; on macOS it uses `open -t`. Windows hands every intent to Explorer, so the shell's own default-application resolution — the one a double-click uses — picks the application. WSL paths are translated with `wslpath -w` before the Windows desktop receives them. `canOpenNativePath()` reports whether the current Host plausibly has a desktop target. `revealNativePath(path, signal)` selects the file in Finder or Explorer, including WSL path translation, and opens its parent directory through `xdg-open` on desktop Linux. `nativeFileManager()` identifies that action for Host-derived UI labels; desktop availability remains a separate `canOpenNativePath()` check. Callers must authorize the absolute file path before invoking either operation. Platform dispatch is covered by injected-runner tests; native desktop verification belongs to the corresponding platform. Explorer receives an encoded file URI as a separate argument. Its exit code 1 is accepted as a delegated handoff; cancellation, missing executables, and other exit codes still reject. This acknowledgement does not prove that a desktop window selected the file. diff --git a/packages/util/native-command/README.zh.md b/packages/util/native-command/README.zh.md index e854877719..17ff391fd2 100644 --- a/packages/util/native-command/README.zh.md +++ b/packages/util/native-command/README.zh.md @@ -45,7 +45,7 @@ const { stdout, stderr } = await runNativeCommand('osascript', ['-e', script], s ### 打开 Host 路径 -`openNativePath(path, signal)` 将路径交给默认应用;平台能够确定默认浏览器时,HTML 与 SVG 会优先交给该浏览器。`openNativeAssociatedPath(path, signal)` 始终使用文件类型关联,不覆盖为浏览器。`openNativeTextFile(path, signal)` 选择文本编辑器意图;macOS 使用 `open -t`。WSL 路径先通过 `wslpath -w` 转换,再交给 Windows 桌面。`canOpenNativePath()` 报告当前 Host 是否可能具备桌面目标。 +`openNativePath(path, signal)` 将路径交给默认应用;平台能够确定默认浏览器时,HTML 与 SVG 会优先交给该浏览器。`openNativeAssociatedPath(path, signal)` 始终使用文件类型关联,不覆盖为浏览器。`openNativeTextFile(path, signal)` 选择文本编辑器意图;macOS 使用 `open -t`。Windows 将每种意图都交给 Explorer,由 shell 自身的默认应用解析(即双击所走的那条)选择应用。WSL 路径先通过 `wslpath -w` 转换,再交给 Windows 桌面。`canOpenNativePath()` 报告当前 Host 是否可能具备桌面目标。 `revealNativePath(path, signal)` 在 Finder 或文件资源管理器中选中文件,包含 WSL 路径转换;在桌面 Linux 上通过 `xdg-open` 打开上层目录。`nativeFileManager()` 标识该操作,供 UI 根据 Host 选择文案;桌面是否可用仍由独立的 `canOpenNativePath()` 检查决定。调用方必须先授权绝对文件路径,再执行操作。平台分派由注入运行器的测试覆盖;原生桌面验证由对应平台负责。 Explorer 接收独立参数中的编码文件 URI。退出码 1 按已转交请求处理;取消、找不到可执行文件和其他退出码仍然报错。该确认不能证明桌面窗口已选中文件。 diff --git a/packages/util/native-command/src/path-opener.ts b/packages/util/native-command/src/path-opener.ts index 618efa31b1..a84da5cb65 100644 --- a/packages/util/native-command/src/path-opener.ts +++ b/packages/util/native-command/src/path-opener.ts @@ -5,7 +5,10 @@ * The default intent prefers the default browser for documents it renders when * the platform can name one, then falls back to the default application. WSL * translates every path for the Windows desktop instead of assuming a Linux - * GUI. The text-editor intent never consults the browser. + * GUI. The text-editor intent never consults the browser. Windows hands every + * intent to Explorer: the shell's own default-application resolution, the one + * a double-click uses, selects the application, while a process that resolves + * the association itself reads a narrower record and reports none. * @module @deepseek-ai/dsh-native-command/path-opener */ @@ -82,11 +85,6 @@ async function openInBrowser( /** Native path-open intent; macOS distinguishes text editing from file association. */ type PathOpenIntent = 'default' | 'association' | 'text-editor' -/** PowerShell single-quoted literal (doubles embedded quotes). */ -function powershellLiteral(path: string): string { - return `'${path.replace(/'/g, "''")}'` -} - /** Whether one environment marker is set to a non-empty value. */ function present(value: string | undefined): boolean { return value !== undefined && value !== '' @@ -99,13 +97,36 @@ function isWsl(internals: PathOpenerInternals): boolean { return (internals.osRelease ?? osRelease()).toLowerCase().includes('microsoft') } -/** Open one Windows-resolvable path through its registered desktop application. */ +/** + * Hand one target to Explorer, accepting its delegated-handoff exit code. + * + * Explorer exits 1 after handing the request to the desktop process already + * running, so exit 1 means the shell took it. Every other failure still + * rejects, and cancellation wins over a delegate's exit 1. + * @param args - Explorer argv: the target path alone to open it, or `/select,` to reveal it. + * @param signal - caller lifetime; abort terminates the command. + * @param run - shell-free command runner. + * @throws The runner's failure unless it is Explorer's delegate exit 1. + */ +async function runExplorer(args: readonly string[], signal: AbortSignal, run: PathOpenerRunner): Promise { + try { + await run('explorer.exe', args, signal) + } catch (error: unknown) { + signal.throwIfAborted() + // Explorer can exit 1 after delegating to the existing desktop process. + if (!(error instanceof Error) || !('code' in error) || error.code !== 1) throw error + } +} + +/** + * Open one Windows-resolvable path through Explorer, the shell that owns the + * default-application resolution a double-click uses. + * @param path - Windows-resolvable path; passed as one argv element, never a command string. + * @param signal - caller lifetime; abort terminates the command. + * @param run - shell-free command runner. + */ async function openWindowsPath(path: string, signal: AbortSignal, run: PathOpenerRunner): Promise { - await run('powershell.exe', [ - '-NoProfile', - '-Command', - `Invoke-Item -LiteralPath ${powershellLiteral(path)}`, - ], signal) + await runExplorer([path], signal, run) } /** Translate a WSL path before handing it to the Windows desktop. */ @@ -259,13 +280,7 @@ export async function revealNativePath( } // Explorer parses commas itself; a file URI preserves commas and whitespace in the path. const target = pathToFileURL(windowsPath, { windows: true }).href.replaceAll(',', '%2C') - try { - await run('explorer.exe', ['/select,', target], signal) - } catch (error) { - signal.throwIfAborted() - // Explorer can exit 1 after delegating to the existing desktop process. - if (!(error instanceof Error) || !('code' in error) || error.code !== 1) throw error - } + await runExplorer(['/select,', target], signal, run) return } if (manager === 'directory') { diff --git a/packages/util/native-command/tests/path-opener.spec.ts b/packages/util/native-command/tests/path-opener.spec.ts index c6f862f20d..e5821345e5 100644 --- a/packages/util/native-command/tests/path-opener.spec.ts +++ b/packages/util/native-command/tests/path-opener.spec.ts @@ -56,15 +56,7 @@ describe('native path opener', () => { }) expect(run.mock.calls).toEqual([ ['wslpath', ['-w', '/home/test user/settings.yaml'], requestSignal], - [ - 'powershell.exe', - [ - '-NoProfile', - '-Command', - "Invoke-Item -LiteralPath '\\\\wsl.localhost\\Ubuntu\\home\\test user\\settings.yaml'", - ], - requestSignal, - ], + ['explorer.exe', ['\\\\wsl.localhost\\Ubuntu\\home\\test user\\settings.yaml'], requestSignal], ]) }) @@ -88,12 +80,14 @@ describe('native path opener', () => { expect(run).toHaveBeenCalledOnce() }) - it('opens with Windows Invoke-Item and escapes single quotes', async () => { + it('hands Windows one argv path with no command string to escape', async () => { const run = vi.fn(async () => ({ stdout: '', stderr: '' })) - await openNativePath("C:\\work\\o'reilly.txt", signal(), { platform: 'win32', run }) + // Quotes, commas, a percent, and an ampersand reach Explorer as a single + // argument: there is no PowerShell literal and no shell to interpret them. + await openNativePath("C:\\work\\o'reilly, & 100%.txt", signal(), { platform: 'win32', run }) expect(run).toHaveBeenCalledWith( - 'powershell.exe', - ['-NoProfile', '-Command', "Invoke-Item -LiteralPath 'C:\\work\\o''reilly.txt'"], + 'explorer.exe', + ["C:\\work\\o'reilly, & 100%.txt"], expect.any(AbortSignal), ) }) @@ -102,8 +96,8 @@ describe('native path opener', () => { const run = vi.fn(async () => ({ stdout: '', stderr: '' })) await openNativeTextFile('C:\\work\\settings.yaml', signal(), { platform: 'win32', run }) expect(run).toHaveBeenCalledWith( - 'powershell.exe', - ['-NoProfile', '-Command', "Invoke-Item -LiteralPath 'C:\\work\\settings.yaml'"], + 'explorer.exe', + ['C:\\work\\settings.yaml'], expect.any(AbortSignal), ) }) @@ -128,7 +122,7 @@ describe('native path opener', () => { osRelease: '6.8.0-generic', env: {}, run, }) const expected = process.platform === 'win32' - ? 'powershell.exe' + ? 'explorer.exe' : process.platform === 'linux' ? 'xdg-open' : 'open' @@ -260,7 +254,7 @@ describe('browser-renderable documents', () => { platform: 'win32', run: async (command, args) => { win.push([command, ...args]); return { stdout: '', stderr: '' } }, }) - expect(win[0]?.[0]).toBe('powershell.exe') + expect(win[0]?.[0]).toBe('explorer.exe') }) it('hands browser-renderable WSL paths to the Windows desktop', async () => { @@ -279,12 +273,7 @@ describe('browser-renderable documents', () => { }) expect(calls).toEqual([ ['wslpath', '-w', '/home/test/page.html'], - [ - 'powershell.exe', - '-NoProfile', - '-Command', - "Invoke-Item -LiteralPath 'C:\\workspace\\page.html'", - ], + ['explorer.exe', 'C:\\workspace\\page.html'], ]) }) }) @@ -405,6 +394,24 @@ it('preserves cancellation even when Explorer returns delegate exit 1', async () await expect(revealNativePath('C:\\file.txt', abort.signal, { platform: 'win32', run })).rejects.toBe(reason) }) +it.each(['win32', 'linux'] as const)('accepts the Explorer delegate exit 1 when opening on %s', async (platform) => { + // The settings sheet's open gesture must survive the same handoff reveal does. + const run = vi.fn(async (command) => { + if (command === 'wslpath') return { stdout: 'C:\\work\\settings.yaml', stderr: '' } + throw Object.assign(new Error('delegated'), { code: 1 }) + }) + await expect(openNativePath( + platform === 'win32' ? 'C:\\work\\settings.yaml' : '/mnt/c/work/settings.yaml', signal(), + { platform, env: { WSL_DISTRO_NAME: 'Ubuntu' }, run }, + )).resolves.toBeUndefined() +}) + +it('preserves a non-delegate Explorer failure when opening', async () => { + const failure = Object.assign(new Error('launch failed'), { code: 2 }) + const run = vi.fn().mockRejectedValue(failure) + await expect(openNativePath('C:\\file.txt', signal(), { platform: 'win32', run })).rejects.toBe(failure) +}) + it.each([ ['C:\\my files\\报告,#%.txt', 'file:///C:/my%20files/%E6%8A%A5%E5%91%8A%2C%23%25.txt'], ['\\\\server\\share\\a,b.txt', 'file://server/share/a%2Cb.txt'], From 44900be005c938e89781e7f7f1e3b959c487c7b6 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Tue, 22 Sep 2026 02:07:32 +0800 Subject: [PATCH 02/28] docs(native-command): name explorer.exe in the Windows open note The Agent Note title said 'the shell's own application resolution', which names a layer instead of the command the opener runs. --- ...2026-09-22-windows-open-through-shell-resolution.i18n.yaml | 4 ++-- .../2026-09-22-windows-open-through-shell-resolution.md | 2 +- .../2026-09-22-windows-open-through-shell-resolution.zh.md | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) 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 index 2cf3e341c8..952b69869a 100644 --- 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 @@ -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-09-22-windows-open-through-shell-resolution.md -2026-09-22-windows-open-through-shell-resolution.md: d4bc1f1ff9dfbff4528403bcd276058845837998 -2026-09-22-windows-open-through-shell-resolution.zh.md: 29518901624402c4ebe7fd925eb3e7fcae1ec204 +2026-09-22-windows-open-through-shell-resolution.md: 1b353f29fe7c170974e2244fe0218e7e9bdf6921 +2026-09-22-windows-open-through-shell-resolution.zh.md: 8421209fe3a0d4ac1ade867716d89b9d3e69078e 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 index d4bc1f1ff9..1b353f29fe 100644 --- 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 @@ -1,4 +1,4 @@ -# Agent Note: Windows opens use the shell's own application resolution +# Agent Note: Windows opens go through explorer.exe Status: implemented 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 index 2951890162..8421209fe3 100644 --- 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 @@ -1,4 +1,4 @@ -# Agent Note: Windows 打开走 shell 自身的默认应用解析 +# Agent Note: Windows 打开经 explorer.exe 完成 Status: implemented From 2426d398b5cf23d0e13598580bb6e5fe2cfd292d Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Mon, 21 Sep 2026 20:38:30 -0700 Subject: [PATCH 03/28] fix(ui-agent-preset): restore the read-only configuration viewer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The declarative-preset refactor (#4569) removed the registry's read Remote together with the YAML editor, and with it the 查看配置 card action that opened a preset's composition read-only. #4850 brought the Creator entry back and had to leave 查看配置 out because `agentPresets/read` was gone. Give the registry a read-only `read` Remote that renders a declaration's child plugin list from the in-memory definition in the Loader's own entry-list dialect, so `!!js` conditions read as declared, and put the card action back at the right end of each card footer: an icon that opens the YAML in a read-only dialog titled after the preset. Nothing accepts YAML back, and a broken preset stays readable because its diagnostic points into that YAML. Fixes #4895 --- apps/web/tests/agent-preset-authoring.e2e.ts | 27 ++++++- .../section.expected.md | 4 ++ docs/subsystems/core.i18n.yaml | 4 +- docs/subsystems/core.md | 6 ++ docs/subsystems/core.zh.md | 6 ++ .../client/ui-agent-preset/README.i18n.yaml | 4 +- packages/client/ui-agent-preset/README.md | 10 +-- packages/client/ui-agent-preset/README.zh.md | 10 +-- .../src/client/AgentPresetSection.module.css | 71 +++++++++++++++++++ .../src/client/AgentPresetSection.tsx | 38 +++++++--- .../ui-agent-preset/src/client/index.ts | 7 +- .../ui-agent-preset/src/client/locales.ts | 3 + .../src/client/section-store.ts | 35 +++++++-- .../tests/apply.client.spec.ts | 9 ++- .../tests/section-store.client.spec.ts | 22 +++++- .../tests/section.client.spec.tsx | 26 ++++++- .../extensions/tool-cordis/src/api-catalog.ts | 10 +++ .../agent-preset-registry/README.i18n.yaml | 4 +- .../preset/agent-preset-registry/README.md | 2 +- .../preset/agent-preset-registry/README.zh.md | 2 +- .../preset/agent-preset-registry/package.json | 5 +- .../preset/agent-preset-registry/src/index.ts | 23 +++++- .../preset/agent-preset-registry/src/types.ts | 12 ++++ .../tests/registry.spec.ts | 19 ++++- pnpm-lock.yaml | 6 ++ 25 files changed, 322 insertions(+), 43 deletions(-) diff --git a/apps/web/tests/agent-preset-authoring.e2e.ts b/apps/web/tests/agent-preset-authoring.e2e.ts index 4986424158..3a6058efa6 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/expected/agent-preset-authoring/section.expected.md b/apps/web/tests/expected/agent-preset-authoring/section.expected.md index 7bccb7e4c2..11f454119f 100644 --- a/apps/web/tests/expected/agent-preset-authoring/section.expected.md +++ b/apps/web/tests/expected/agent-preset-authoring/section.expected.md @@ -21,6 +21,7 @@ - text: 处理代码、文件和资料,适合大多数任务。Agent 会按需使用检索、编辑和终端等工具。 - 'button "模式说明: 标准模式"': 模式说明 - 'button "如何使用: 标准模式"': 如何使用 + - 'button "查看配置: 标准模式"': 查看配置 - listitem: - 'button "设为新任务默认: PTC 模式"': - text: PTC 模式 内置 @@ -28,6 +29,7 @@ - text: 包含标准模式的所有能力,更适合批量调用工具,并对结果进行筛选、整理、去重、统计或汇总的任务。 - 'button "模式说明: PTC 模式"': 模式说明 - 'button "如何使用: PTC 模式"': 如何使用 + - 'button "查看配置: PTC 模式"': 查看配置 - listitem: - 'button "设为新任务默认: 极简模式"': - text: 极简模式 内置 @@ -35,6 +37,7 @@ - text: Agent 仅使用终端工具完成任务,适合测试和对比其基础表现。 - 'button "模式说明: 极简模式"': 模式说明 - 'button "如何使用: 极简模式"': 如何使用 + - 'button "查看配置: 极简模式"': 查看配置 - listitem: - 'button "设为新任务默认: 创造模式"': - text: 创造模式 内置 @@ -42,5 +45,6 @@ - text: 用对话定制 DSH:让 Agent 编写插件,添加新功能或界面;也能组合工具和提示词,创建自己的模式。 - 'button "模式说明: 创造模式"': 模式说明 - 'button "如何使用: 创造模式"': 如何使用 + - 'button "查看配置: 创造模式"': 查看配置 - heading "自定义" [level=3] - button "让 Agent 帮我创建预设模式" diff --git a/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/packages/client/ui-agent-preset/README.i18n.yaml b/packages/client/ui-agent-preset/README.i18n.yaml index 961b1712a0..fde3410d83 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: 3f50449078f66643025fe235ee45ce63689d65b7 +README.zh.md: 8753520ecc97f06eeea0d52ea4e782a7e76c38e8 diff --git a/packages/client/ui-agent-preset/README.md b/packages/client/ui-agent-preset/README.md index 176957e326..3f50449078 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. 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..8753520ecc 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。本页不编辑任何内容:创造入口启动一个创造模式任务,以 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/src/client/AgentPresetSection.module.css b/packages/client/ui-agent-preset/src/client/AgentPresetSection.module.css index 33827b02c4..f801fe90cd 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..c7e6ee3dc4 100644 --- a/packages/client/ui-agent-preset/src/client/AgentPresetSection.tsx +++ b/packages/client/ui-agent-preset/src/client/AgentPresetSection.tsx @@ -1,7 +1,9 @@ -/** 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' @@ -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) @@ -65,6 +72,9 @@ export function AgentPresetSection({ page: PresetGuidePage } | null>(null) useEffect(() => { void load() }, [load]) + 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 +148,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 +170,10 @@ export function AgentPresetSection({ })} {guide === null ? null : { setGuide(null) }} />} + {t('close')}}> + {viewed === null ? null :
{viewed.content}
} +
} 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..0577e07443 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: @@ -89,6 +91,7 @@ export const zh: Record = { nav: 'Agent 预设', setDefault: '设为新任务默认', + view: '查看配置', presetStandardName: '标准模式', presetStandardDescription: '处理代码、文件和资料,适合大多数任务。Agent 会按需使用检索、编辑和终端等工具。', 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..9f4a8ecc97 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,13 +21,15 @@ 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 constructor(private readonly ctx: Context) {} @@ -39,6 +50,22 @@ 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 failure lands in `error` and leaves the viewer closed. + */ + async view(id: string): Promise { + this.set({ error: null }) + try { + const result = await this.ctx.remote.agentPresets.read(id) + if (!result.ok) throw new Error(result.error.message) + const { name, content } = result.value + this.set({ view: { id, title: name ?? id, content } }) + } catch (error) { this.set({ error: message(error) }) } + } + /** Close the viewer. */ + closeView(): void { 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..2441ea606a 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,25 @@ 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' }) + controller.closeView() + 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('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..44461a0cbe 100644 --- a/packages/client/ui-agent-preset/tests/section.client.spec.tsx +++ b/packages/client/ui-agent-preset/tests/section.client.spec.tsx @@ -13,8 +13,9 @@ function unusedHook(): never { } function view(partial: Partial = {}, startCreatorDraft?: () => void, developerTools = true) { 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 }), @@ -87,6 +88,27 @@ 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('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']) diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index f8edccaf67..9abb48eb32 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -159,6 +159,12 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ parameters: [{ name: 'id', description: 'Explicit preset or the current default.' }], returns: 'Current metadata, including failure when activation failed.', }, + { + signature: '@Remote(\'read\') readDocument(agentPreset: string): Promise', + description: 'Read one declaration\'s child plugin list as YAML, for viewing only.', + parameters: [{ name: 'agentPreset', description: 'Preset identity.' }], + returns: 'The declared composition beside its published metadata.', + }, { signature: 'async mount(ctx: Context, id?: string): Promise', description: 'Bind an unpublished Agent to the current preset revision.', @@ -4182,6 +4188,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'AgentPresetCompositionRow', declaration: 'export interface AgentPresetCompositionRow {\n readonly entryId: string | null;\n readonly moduleName: string;\n readonly enabled: CompositionRowEnablement;\n readonly condition?: string;\n readonly fiberState?: FiberState;\n}', }, + { + name: 'AgentPresetDocument', + declaration: 'export interface AgentPresetDocument {\n readonly agentPreset: string;\n readonly content: string;\n readonly name?: string;\n readonly description?: string;\n}', + }, { name: 'AgentPresetRoster', declaration: 'export interface AgentPresetRoster {\n readonly presets: readonly AgentPresetRow[];\n readonly modeSelectionEnabled: boolean;\n}', diff --git a/packages/preset/agent-preset-registry/README.i18n.yaml b/packages/preset/agent-preset-registry/README.i18n.yaml index 93f84a50f9..a7792a0e26 100644 --- a/packages/preset/agent-preset-registry/README.i18n.yaml +++ b/packages/preset/agent-preset-registry/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/preset/agent-preset-registry/README.md -README.md: 7a1ee3e733fc54194515dbf093e53af0c9698e40 -README.zh.md: 11206a3f9237cbca237220a81fd98ec5eea1083e +README.md: 44b01be1ceea5936668f091fb410137f3a139a88 +README.zh.md: a0c75fa50ba781ffc394e04502c922684beda387 diff --git a/packages/preset/agent-preset-registry/README.md b/packages/preset/agent-preset-registry/README.md index 7a1ee3e733..44b01be1ce 100644 --- a/packages/preset/agent-preset-registry/README.md +++ b/packages/preset/agent-preset-registry/README.md @@ -45,7 +45,7 @@ Choose an Agent’s tools, prompt sections and skills through declarative preset The Web definitions come from the `dsh-web-app` bundle. Definitions are ordinary plugin rows; the registry neither scans directories nor accepts preset paths. The `selectedDefault` and `modeSelectionEnabled` volatile fields of the `agent-preset-registry` entry retain the user default and the chooser visibility; hiding the chooser uses the deployment `default`. -The registry writes no declarations. A new preset or an override of a shipped one is a bundle patch: an `insert` of a `@deepseek-ai/dsh-agent-preset` row, or a patch keyed by that row’s id, installed into the profile with `plugin_manager`; Creator mode authors such bundles in conversation. +The registry writes no declarations. The `read` Remote renders one declaration’s child list back as entry-list YAML (`!!js` conditions included) so a client can show what a preset composes; nothing accepts YAML back. A new preset or an override of a shipped one is a bundle patch: an `insert` of a `@deepseek-ai/dsh-agent-preset` row, or a patch keyed by that row’s id, installed into the profile with `plugin_manager`; Creator mode authors such bundles in conversation. ## Understand the implementation diff --git a/packages/preset/agent-preset-registry/README.zh.md b/packages/preset/agent-preset-registry/README.zh.md index 11206a3f92..a0c75fa50b 100644 --- a/packages/preset/agent-preset-registry/README.zh.md +++ b/packages/preset/agent-preset-registry/README.zh.md @@ -45,7 +45,7 @@ kind: "package-reference" Web 内置定义来自 `dsh-web-app` bundle。定义使用普通插件行;注册表不扫描目录,也不接受 preset 路径。`agent-preset-registry` 条目的 volatile 字段 `selectedDefault` 与 `modeSelectionEnabled` 保留用户默认值和选择器可见性;隐藏选择器时使用部署 `default`。 -注册表不写入任何声明。新建 preset 或覆盖内置 preset 都是 bundle 补丁:插入一行 `@deepseek-ai/dsh-agent-preset`,或按该行 id 写覆盖补丁,再用 `plugin_manager` 安装到 profile;创造模式在对话中编写这类 bundle。 +注册表不写入任何声明。`read` Remote 把一条声明的子插件列表按 entry-list YAML 方言(含 `!!js` 条件)渲染回来,供客户端展示 preset 的组成;没有任何接口接受 YAML 写回。新建 preset 或覆盖内置 preset 都是 bundle 补丁:插入一行 `@deepseek-ai/dsh-agent-preset`,或按该行 id 写覆盖补丁,再用 `plugin_manager` 安装到 profile;创造模式在对话中编写这类 bundle。 ## 理解实现 diff --git a/packages/preset/agent-preset-registry/package.json b/packages/preset/agent-preset-registry/package.json index 71b0d8865a..f442537386 100644 --- a/packages/preset/agent-preset-registry/package.json +++ b/packages/preset/agent-preset-registry/package.json @@ -53,6 +53,7 @@ ], "license": "MIT", "peerDependencies": { + "@deepseek-ai/cordis-plugin-include": "workspace:^", "@deepseek-ai/cordis-plugin-loader": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", @@ -67,6 +68,7 @@ }, "dependencies": { "@deepseek-ai/schemastery": "workspace:^", + "js-yaml": "^4.2.0", "zod": "^4.4.3" }, "devDependencies": { @@ -91,6 +93,7 @@ "@deepseek-ai/dsh-sandbox": "workspace:^", "@deepseek-ai/dsh-sandbox-policy": "workspace:^", "@deepseek-ai/dsh-user-approval": "workspace:^", - "@deepseek-ai/dsh-agent-preset": "workspace:^" + "@deepseek-ai/dsh-agent-preset": "workspace:^", + "@types/js-yaml": "^4.0.9" } } diff --git a/packages/preset/agent-preset-registry/src/index.ts b/packages/preset/agent-preset-registry/src/index.ts index d8f54dddc3..f1366ff0d9 100644 --- a/packages/preset/agent-preset-registry/src/index.ts +++ b/packages/preset/agent-preset-registry/src/index.ts @@ -3,11 +3,13 @@ import { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' import { Remote, RemoteError, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' import { bindScopeParent, createScope, scopeOf, type Scope, type ScopeKey, type ScopeParentBinding } from '@deepseek-ai/dsh-scope' +import { entryListSchema } from '@deepseek-ai/cordis-plugin-include' +import { dump } from 'js-yaml' import type { Agent } from '@deepseek-ai/dsh-agent' // Type-only: the optional `settings` service this registry keeps off the generated pages. import type {} from '@deepseek-ai/dsh-settings' import type {} from '@deepseek-ai/dsh-tools' -import type { AgentPresetRoster } from './types.ts' +import type { AgentPresetDocument, AgentPresetRoster } from './types.ts' import { entryListProblem, type PresetDefinition } from './definition.ts' import type { AgentPreset, Config } from './preset.ts' import { agentPresetProjectionDefinition } from './session.ts' @@ -191,6 +193,25 @@ export class AgentPresetRegistry extends TypertRemoteService { return { id: wanted, ...(broken === undefined ? {} : { broken }) } } + /** 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 { + const record = this.definitions.get(agentPreset) + if (record === undefined) { + return Promise.reject(new RemoteError('agent-preset/not-found', `Unknown agent preset: ${agentPreset}`, + { agentPreset, available: [...this.definitions.keys()] })) + } + const { id, name, description, plugins } = record.config + // The Loader's own dialect, so `!!js` conditions read as declared rather than as expression objects. + const content = dump(plugins, { schema: entryListSchema, noRefs: true, lineWidth: -1 }) + return Promise.resolve({ + agentPreset: id, content, ...(name === undefined ? {} : { name }), ...(description === undefined ? {} : { description }), + }) + } + private async retain(id?: string): Promise { const wanted = id ?? this.defaultId while (true) { diff --git a/packages/preset/agent-preset-registry/src/types.ts b/packages/preset/agent-preset-registry/src/types.ts index 4a73dc09b9..eba2d3dce3 100644 --- a/packages/preset/agent-preset-registry/src/types.ts +++ b/packages/preset/agent-preset-registry/src/types.ts @@ -26,6 +26,18 @@ export interface AgentPresetRoster { readonly modeSelectionEnabled: boolean } +/** One preset's declared composition, rendered for reading. */ +export interface AgentPresetDocument { + /** The preset the composition belongs to. */ + readonly agentPreset: string + /** The declared child plugin list as entry-list YAML, `!!js` expressions included. */ + readonly content: string + /** Display name the preset published. */ + readonly name?: string + /** One sentence on what this preset is for. */ + readonly description?: string +} + declare module '@deepseek-ai/dsh-typert-protocol' { interface RemoteErrorDetailsMap { /** No declaration supplies the requested identity. */ diff --git a/packages/preset/agent-preset-registry/tests/registry.spec.ts b/packages/preset/agent-preset-registry/tests/registry.spec.ts index 778fa2f834..a5481db4be 100644 --- a/packages/preset/agent-preset-registry/tests/registry.spec.ts +++ b/packages/preset/agent-preset-registry/tests/registry.spec.ts @@ -2,7 +2,7 @@ import { afterEach, describe, expect, it } from 'vitest' import { createScope } from '@deepseek-ai/dsh-scope' import { assembleContextFor } from '@deepseek-ai/dsh-agent' import { entryListProblem, livePresetMounts } from '../src/index.ts' -import { currentKey, harness, declare, contribution, agentOn, liveRegistries } from './harness.ts' +import { currentKey, harness, declare, contribution, agentOn, liveRegistries, plugin } from './harness.ts' import { omitsGeneratedPage } from '../../../settings/settings/tests/live-config.ts' import Loader from '@deepseek-ai/cordis-plugin-loader' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' @@ -121,6 +121,23 @@ describe('declarative preset revisions', () => { expect.objectContaining({ id: 'empty', rows: [expect.objectContaining({ moduleName: 'missing', enabled: false })] }), ])) }) + + it('renders a declaration as entry-list YAML for reading, conditions included', async () => { + const ctx = await setup() + await declare(ctx, { + ...contribution('standard'), name: 'Standard', description: 'General', + plugins: [ + { id: 'contribute', name: plugin('contribute'), config: { tool: 'standard' } }, + { id: 'windows-only', name: 'missing', disabled: { __jsExpr: "process.platform !== 'win32'" } }, + ], + }) + const document = await ctx.agentPresets.readDocument('standard') + expect(document).toMatchObject({ agentPreset: 'standard', name: 'Standard', description: 'General' }) + expect(document.content.startsWith('- id: contribute\n name: ')).toBe(true) + expect(document.content).toContain("\n config:\n tool: standard\n- id: windows-only\n name: missing\n disabled: !!js process.platform !== 'win32'\n") + expect(document.content).not.toContain('__jsExpr') + await expect(ctx.agentPresets.readDocument('absent')).rejects.toThrow('Unknown agent preset: absent') + }) }) it('retains a retired revision for an in-flight cold read', async () => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4bb1a3a0da..475d32fe58 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9409,6 +9409,9 @@ importers: '@deepseek-ai/schemastery': specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery + js-yaml: + specifier: ^4.2.0 + version: 4.3.1 zod: specifier: ^4.4.3 version: 4.4.3 @@ -9479,6 +9482,9 @@ importers: '@deepseek-ai/dsh-user-approval': specifier: workspace:^ version: link:../../interaction/user-approval + '@types/js-yaml': + specifier: ^4.0.9 + version: 4.0.9 packages/preset/persona: dependencies: From 95611fbc635039ab1f62fe7b6bf3090081fe401a Mon Sep 17 00:00:00 2001 From: mektpoy Date: Tue, 22 Sep 2026 12:24:53 +0800 Subject: [PATCH 04/28] fix(native-command): encode the Explorer target so a comma stays in the path --- ...ws-open-through-shell-resolution.i18n.yaml | 4 +-- ...2-windows-open-through-shell-resolution.md | 8 +++--- ...indows-open-through-shell-resolution.zh.md | 8 +++--- .../host/open-in-app/tests/resolver.spec.ts | 2 +- packages/util/native-command/README.i18n.yaml | 4 +-- packages/util/native-command/README.md | 4 +-- packages/util/native-command/README.zh.md | 4 +-- .../util/native-command/src/path-opener.ts | 25 ++++++++++++++----- .../native-command/tests/path-opener.spec.ts | 25 +++++++++++++------ 9 files changed, 54 insertions(+), 30 deletions(-) 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 index 952b69869a..793d1c261f 100644 --- 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 @@ -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-09-22-windows-open-through-shell-resolution.md -2026-09-22-windows-open-through-shell-resolution.md: 1b353f29fe7c170974e2244fe0218e7e9bdf6921 -2026-09-22-windows-open-through-shell-resolution.zh.md: 8421209fe3a0d4ac1ade867716d89b9d3e69078e +2026-09-22-windows-open-through-shell-resolution.md: 359d4163a06cc39b84f423940a22a1bf862dfbc8 +2026-09-22-windows-open-through-shell-resolution.zh.md: 755f9beacf8a635cccd63131acde5a6d072ee077 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 index 1b353f29fe..359d4163a0 100644 --- 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 @@ -12,9 +12,9 @@ That machine records the `.yml` default only in `HKCU\Software\Microsoft\Windows ## Decision -`openWindowsPath` hands the path to `explorer.exe` as one argv element, and `revealNativePath` reaches the same shell through the shared `runExplorer` helper. 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. +`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 percent-encoded, because Explorer parses its own command line and splits it at commas — a raw path containing one opens a different target without reporting it. 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 now crosses the process boundary as an argv element, so no escaping step exists to get wrong. +`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. @@ -34,8 +34,8 @@ The settings sheet's open gesture and the artifact and workspace opens share one 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. -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 runner inherits the host environment and creates no detached process. +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 argv Explorer receives for both intents, the accepted delegate exit code 1 for an open and a reveal, the preserved failure and cancellation, and the absence of any escaping step. `resolver.spec.ts` pins the open-in-app route's Windows command. Native desktop verification stays on Windows, per the package's test policy; file and directory opens were observed on Windows 11 build 26340, where the associated application started and a folder window appeared for each target. +`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 a probe association recorded the argument each handoff passed, and the opener's own commands opened the associated application for a plain file and for a file whose name contains a comma, while the same comma-bearing file handed over as a raw path opened the user's Documents folder instead — the shell split the path at the comma and reported nothing. A directory whose name contains a comma opened as a window rooted at that directory. Every one of these handoffs, successful ones included, exits 1. 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 index 8421209fe3..755f9beacf 100644 --- 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 @@ -12,9 +12,9 @@ Windows 桌面客户端设置页的「打开配置文件」会准备当前 profi ## 决策 -`openWindowsPath` 将路径作为**一个** argv 元素交给 `explorer.exe`,`revealNativePath` 经共享的 `runExplorer` 帮助函数走到同一个 shell。资源管理器执行的是双击所执行的默认应用解析,因此由 shell 的答案选择应用——包括只记录在较新的按用户记录里的默认值;而完全没有处理程序的机器会得到资源管理器自己的「你要如何打开这个文件?」选择框,而不是毫无反应。 +`openWindowsPath` 将路径作为**一个** argv 元素交给 `explorer.exe`,`revealNativePath` 经共享的 `runExplorer` 帮助函数走到同一个 shell。两者都把目标编码成文件 URI 并对逗号做百分号编码,因为资源管理器自己解析命令行并以逗号分段——含逗号的原始路径会打开另一个目标且不作任何提示。资源管理器执行的是双击所执行的默认应用解析,因此由 shell 的答案选择应用——包括只记录在较新的按用户记录里的默认值;而完全没有处理程序的机器会得到资源管理器自己的「你要如何打开这个文件?」选择框,而不是毫无反应。 -`runExplorer` 为两种操作持有唯一一条 Explorer 调用规则:退出码 1 是 Explorer 把请求转交给已在运行的桌面进程后返回的交接码,它照常兑现调用方的 Promise;其余失败和取消仍然报错。该帮助函数取代了 PowerShell 命令字符串,同时删掉了 `powershellLiteral` 及其单引号翻倍逻辑:路径现在以 argv 元素跨进程边界,不存在可能写错的转义步骤。 +`runExplorer` 为两种操作持有唯一一条 Explorer 调用规则:退出码 1 是 Explorer 把请求转交给已在运行的桌面进程后返回的交接码,它照常兑现调用方的 Promise;其余失败和取消仍然报错。该帮助函数取代了 PowerShell 命令字符串,同时删掉了 `powershellLiteral` 及其单引号翻倍逻辑:路径以 argv 元素跨进程边界,去掉的是 shell 层转义,而不是资源管理器自身解析所需的编码;该编码由 `explorerTarget` 为两种意图统一持有。 Windows 对 HTML 与 SVG 仍然无法命名浏览器,因此 `openInBrowser` 继续拒绝该平台,默认意图、关联意图与文本编辑器意图都走同一个 Explorer 交接。 @@ -34,8 +34,8 @@ Windows 对 HTML 与 SVG 仍然无法命名浏览器,因此 `openInBrowser` 打开器再也无法区分「shell 打开了」与「shell 拒绝了」:两种情况 Explorer 都返回 0 或 1,因此 Remote 依旧回答 `{ opened: true }`,完全没有处理程序的机器只能看到 shell 的选择框。此处不校验窗口是否真的出现。 -目录走同一条路径。[open-in-app 记录](../feature/2026-08-25-promote-open-anywhere-plugin.zh.md)中被否决的方案是 detached 且经过凭据清洗的 `explorer.exe ` 派生进程,那不是本交接的做法:运行器继承宿主环境,也不创建 detached 进程。 +目录走同一条路径。[open-in-app 记录](../feature/2026-08-25-promote-open-anywhere-plugin.zh.md)中被否决的方案是 detached 且经过凭据清洗的 `explorer.exe ` 派生进程,那不是本交接的做法:`explorer.exe` 子进程继承宿主环境,也不创建 detached 进程;而它转交给桌面会话启动的应用或文件夹窗口,继承的是桌面会话环境而不是宿主进程环境。 ## 验证 -`path-opener.spec.ts` 固定两种意图交给 Explorer 的 argv、打开与选中都可接受的交接退出码 1、仍然报错的普通失败与取消,以及不存在任何转义步骤。`resolver.spec.ts` 固定 open-in-app 路由在 Windows 上的命令。按该包测试策略,原生桌面验证留在 Windows;在 Windows 11 build 26340 上已观察到文件与目录打开分别启动了关联应用和文件夹窗口。 +`path-opener.spec.ts` 固定两种意图交给 Explorer 的编码文件 URI、打开与选中都可接受的交接退出码 1,以及仍然报错的普通失败与取消。`resolver.spec.ts` 固定 open-in-app 路由在 Windows 上的命令。按该包测试策略,原生桌面验证留在 Windows:在 Windows 11 ARM64 build 26200 上,一个探针关联记录了每次交接实际收到的参数——打开器自身的命令对普通文件、以及文件名含逗号的文件都启动了关联应用;而同一个含逗号文件若以原始路径交接,则会打开用户的「文档」文件夹,shell 在逗号处分段且不作任何提示。名称含逗号的目录会以该目录为根打开窗口。上述每一次交接(包括成功的那些)退出码都是 1。 diff --git a/packages/host/open-in-app/tests/resolver.spec.ts b/packages/host/open-in-app/tests/resolver.spec.ts index cf4b8c79f8..867dec7f7c 100644 --- a/packages/host/open-in-app/tests/resolver.spec.ts +++ b/packages/host/open-in-app/tests/resolver.spec.ts @@ -584,7 +584,7 @@ describe('launchResolved', () => { // The opener is the shipped Explorer channel; the detached spawner never runs. expect(spawns).toEqual([]) expect(commands).toEqual([ - ['explorer.exe', 'C:\\w\\dir'], + ['explorer.exe', 'file:///C:/w/dir'], ]) }) diff --git a/packages/util/native-command/README.i18n.yaml b/packages/util/native-command/README.i18n.yaml index 5010c1e803..0e2bf74b61 100644 --- a/packages/util/native-command/README.i18n.yaml +++ b/packages/util/native-command/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/util/native-command/README.md -README.md: cd090d69ae790ae6a1ccbe70f4a767246c3b4981 -README.zh.md: 17ff391fd29cef4909f415505a73aaca3b901f14 +README.md: b158cbf3153962af14ed99c07f7527f4bdb86f89 +README.zh.md: f02a37480934122dbf569983105b43b9962b894c diff --git a/packages/util/native-command/README.md b/packages/util/native-command/README.md index cd090d69ae..b158cbf315 100644 --- a/packages/util/native-command/README.md +++ b/packages/util/native-command/README.md @@ -45,9 +45,9 @@ The `NativeCommandRunner` type is the injectable command boundary for host integ ### Opening a Host path -`openNativePath(path, signal)` hands a path to the default application and prefers the named default browser for HTML and SVG where the platform can identify one. `openNativeAssociatedPath(path, signal)` always uses the file-type association, without a browser override. `openNativeTextFile(path, signal)` selects text-editor intent; on macOS it uses `open -t`. Windows hands every intent to Explorer, so the shell's own default-application resolution — the one a double-click uses — picks the application. WSL paths are translated with `wslpath -w` before the Windows desktop receives them. `canOpenNativePath()` reports whether the current Host plausibly has a desktop target. +`openNativePath(path, signal)` hands a path to the default application and prefers the named default browser for HTML and SVG where the platform can identify one. `openNativeAssociatedPath(path, signal)` always uses the file-type association, without a browser override. `openNativeTextFile(path, signal)` selects text-editor intent; on macOS it uses `open -t`. Windows hands every intent to Explorer, so the shell's own default-application resolution — the one a double-click uses — picks the application; Explorer receives the target as an encoded file URI and its exit code 1 counts as a delegated handoff, while cancellation, missing executables, and other exit codes still reject. WSL paths are translated with `wslpath -w` before the Windows desktop receives them. `canOpenNativePath()` reports whether the current Host plausibly has a desktop target. -`revealNativePath(path, signal)` selects the file in Finder or Explorer, including WSL path translation, and opens its parent directory through `xdg-open` on desktop Linux. `nativeFileManager()` identifies that action for Host-derived UI labels; desktop availability remains a separate `canOpenNativePath()` check. Callers must authorize the absolute file path before invoking either operation. Platform dispatch is covered by injected-runner tests; native desktop verification belongs to the corresponding platform. Explorer receives an encoded file URI as a separate argument. Its exit code 1 is accepted as a delegated handoff; cancellation, missing executables, and other exit codes still reject. This acknowledgement does not prove that a desktop window selected the file. +`revealNativePath(path, signal)` selects the file in Finder or Explorer, including WSL path translation, and opens its parent directory through `xdg-open` on desktop Linux. `nativeFileManager()` identifies that action for Host-derived UI labels; desktop availability remains a separate `canOpenNativePath()` check. Callers must authorize the absolute file path before invoking either operation. Platform dispatch is covered by injected-runner tests; native desktop verification belongs to the corresponding platform. Explorer receives an encoded file URI as a separate argument. Its exit code 1 is accepted as a delegated handoff; cancellation, missing executables, and other exit codes still reject. This acknowledgement does not prove that an application opened the file or that a window selected it. `nativeFileApplications(path, signal)` returns registered applications, localized names, icons, and the current default. macOS 12 and later use LaunchServices; Windows uses Shell association handlers; Linux uses GIO with shared XDG desktop-entry and icon lookup. On macOS, copies sharing a bundle identifier and display name (staged self-updates, per-version installs) collapse to the system default, else the highest version; deliberate side-by-side installs keep distinct display names and both entries. `openNativeFileApplication(path, application, signal)` revalidates the handler against the complete registered list, so copies collapsed out of the display list stay openable, and never changes the system default. Windows delegates invocation to the Shell, and Linux delegates argument expansion to `gio launch`. WSL translates the path and uses the Windows adapter. Callers authorize the local file path. Native integration tests use private Windows file associations and Linux XDG roots on their respective platforms. diff --git a/packages/util/native-command/README.zh.md b/packages/util/native-command/README.zh.md index 17ff391fd2..f02a374809 100644 --- a/packages/util/native-command/README.zh.md +++ b/packages/util/native-command/README.zh.md @@ -45,9 +45,9 @@ const { stdout, stderr } = await runNativeCommand('osascript', ['-e', script], s ### 打开 Host 路径 -`openNativePath(path, signal)` 将路径交给默认应用;平台能够确定默认浏览器时,HTML 与 SVG 会优先交给该浏览器。`openNativeAssociatedPath(path, signal)` 始终使用文件类型关联,不覆盖为浏览器。`openNativeTextFile(path, signal)` 选择文本编辑器意图;macOS 使用 `open -t`。Windows 将每种意图都交给 Explorer,由 shell 自身的默认应用解析(即双击所走的那条)选择应用。WSL 路径先通过 `wslpath -w` 转换,再交给 Windows 桌面。`canOpenNativePath()` 报告当前 Host 是否可能具备桌面目标。 +`openNativePath(path, signal)` 将路径交给默认应用;平台能够确定默认浏览器时,HTML 与 SVG 会优先交给该浏览器。`openNativeAssociatedPath(path, signal)` 始终使用文件类型关联,不覆盖为浏览器。`openNativeTextFile(path, signal)` 选择文本编辑器意图;macOS 使用 `open -t`。Windows 将每种意图都交给 Explorer,由 shell 自身的默认应用解析(即双击所走的那条)选择应用;Explorer 收到的是编码后的文件 URI,其退出码 1 按已转交请求处理,取消、找不到可执行文件和其他退出码仍然报错。WSL 路径先通过 `wslpath -w` 转换,再交给 Windows 桌面。`canOpenNativePath()` 报告当前 Host 是否可能具备桌面目标。 -`revealNativePath(path, signal)` 在 Finder 或文件资源管理器中选中文件,包含 WSL 路径转换;在桌面 Linux 上通过 `xdg-open` 打开上层目录。`nativeFileManager()` 标识该操作,供 UI 根据 Host 选择文案;桌面是否可用仍由独立的 `canOpenNativePath()` 检查决定。调用方必须先授权绝对文件路径,再执行操作。平台分派由注入运行器的测试覆盖;原生桌面验证由对应平台负责。 Explorer 接收独立参数中的编码文件 URI。退出码 1 按已转交请求处理;取消、找不到可执行文件和其他退出码仍然报错。该确认不能证明桌面窗口已选中文件。 +`revealNativePath(path, signal)` 在 Finder 或文件资源管理器中选中文件,包含 WSL 路径转换;在桌面 Linux 上通过 `xdg-open` 打开上层目录。`nativeFileManager()` 标识该操作,供 UI 根据 Host 选择文案;桌面是否可用仍由独立的 `canOpenNativePath()` 检查决定。调用方必须先授权绝对文件路径,再执行操作。平台分派由注入运行器的测试覆盖;原生桌面验证由对应平台负责。 Explorer 接收独立参数中的编码文件 URI。退出码 1 按已转交请求处理;取消、找不到可执行文件和其他退出码仍然报错。该确认不能证明应用已打开文件,也不能证明桌面窗口已选中文件。 `nativeFileApplications(path, signal)` 返回关联应用、本地化名称、图标和当前默认项。macOS 12 及以上版本使用 LaunchServices,Windows 使用 Shell 关联处理器,Linux 使用 GIO,并共用 XDG 桌面文件和图标读取逻辑。macOS 上 bundle 标识符与显示名都相同的多份拷贝(自更新暂存副本、按版本安装的拷贝)合并为系统默认项,否则保留最高版本;显示名不同的并存安装两项都保留。`openNativeFileApplication(path, application, signal)` 按完整的当前注册列表重新验证关联应用,展示列表中被合并掉的拷贝仍可打开,且不修改系统默认值。Windows 交给 Shell 启动应用,Linux 交给 `gio launch` 展开参数。WSL 转换路径后使用 Windows 适配器。调用方负责验证本地文件路径。原生集成测试分别在相应平台使用独立的 Windows 文件关联和 Linux XDG 目录。 diff --git a/packages/util/native-command/src/path-opener.ts b/packages/util/native-command/src/path-opener.ts index a84da5cb65..e4341f8567 100644 --- a/packages/util/native-command/src/path-opener.ts +++ b/packages/util/native-command/src/path-opener.ts @@ -97,13 +97,28 @@ function isWsl(internals: PathOpenerInternals): boolean { return (internals.osRelease ?? osRelease()).toLowerCase().includes('microsoft') } +/** + * Encode one Windows path as the target Explorer can receive intact. + * + * Explorer parses its own command line and treats commas as field separators, + * so a path handed over raw loses everything after its first comma and the + * shell opens a different target without reporting it. A file URI has `,` + * percent-encoded and keeps whitespace, and Explorer resolves it to the shell + * item the path names. + * @param windowsPath - path already translated for the Windows desktop. + * @returns the target for an open, or the object of a `/select,` reveal. + */ +function explorerTarget(windowsPath: string): string { + return pathToFileURL(windowsPath, { windows: true }).href.replaceAll(',', '%2C') +} + /** * Hand one target to Explorer, accepting its delegated-handoff exit code. * * Explorer exits 1 after handing the request to the desktop process already * running, so exit 1 means the shell took it. Every other failure still * rejects, and cancellation wins over a delegate's exit 1. - * @param args - Explorer argv: the target path alone to open it, or `/select,` to reveal it. + * @param args - Explorer argv: the encoded target alone to open it, or `/select,` to reveal it. * @param signal - caller lifetime; abort terminates the command. * @param run - shell-free command runner. * @throws The runner's failure unless it is Explorer's delegate exit 1. @@ -121,12 +136,12 @@ async function runExplorer(args: readonly string[], signal: AbortSignal, run: Pa /** * Open one Windows-resolvable path through Explorer, the shell that owns the * default-application resolution a double-click uses. - * @param path - Windows-resolvable path; passed as one argv element, never a command string. + * @param path - Windows-resolvable path; Explorer receives its encoded file URI as one argv element, never a command string. * @param signal - caller lifetime; abort terminates the command. * @param run - shell-free command runner. */ async function openWindowsPath(path: string, signal: AbortSignal, run: PathOpenerRunner): Promise { - await runExplorer([path], signal, run) + await runExplorer([explorerTarget(path)], signal, run) } /** Translate a WSL path before handing it to the Windows desktop. */ @@ -278,9 +293,7 @@ export async function revealNativePath( windowsPath = translated.stdout.replace(/[\r\n]+$/, '') if (windowsPath === '') throw new Error('wslpath returned no Windows path') } - // Explorer parses commas itself; a file URI preserves commas and whitespace in the path. - const target = pathToFileURL(windowsPath, { windows: true }).href.replaceAll(',', '%2C') - await runExplorer(['/select,', target], signal, run) + await runExplorer(['/select,', explorerTarget(windowsPath)], signal, run) return } if (manager === 'directory') { diff --git a/packages/util/native-command/tests/path-opener.spec.ts b/packages/util/native-command/tests/path-opener.spec.ts index e5821345e5..9be8ae01e8 100644 --- a/packages/util/native-command/tests/path-opener.spec.ts +++ b/packages/util/native-command/tests/path-opener.spec.ts @@ -56,7 +56,7 @@ describe('native path opener', () => { }) expect(run.mock.calls).toEqual([ ['wslpath', ['-w', '/home/test user/settings.yaml'], requestSignal], - ['explorer.exe', ['\\\\wsl.localhost\\Ubuntu\\home\\test user\\settings.yaml'], requestSignal], + ['explorer.exe', ['file://wsl.localhost/Ubuntu/home/test%20user/settings.yaml'], requestSignal], ]) }) @@ -80,14 +80,25 @@ describe('native path opener', () => { expect(run).toHaveBeenCalledOnce() }) - it('hands Windows one argv path with no command string to escape', async () => { + it('encodes the target Explorer parses so a comma stays inside the path', async () => { const run = vi.fn(async () => ({ stdout: '', stderr: '' })) - // Quotes, commas, a percent, and an ampersand reach Explorer as a single - // argument: there is no PowerShell literal and no shell to interpret them. + // Explorer parses its own command line and splits it at commas, so the path + // crosses as a file URI with the comma percent-encoded. The apostrophe, + // ampersand, space, and percent need no escaping: no shell interprets them. await openNativePath("C:\\work\\o'reilly, & 100%.txt", signal(), { platform: 'win32', run }) expect(run).toHaveBeenCalledWith( 'explorer.exe', - ["C:\\work\\o'reilly, & 100%.txt"], + ["file:///C:/work/o'reilly%2C%20&%20100%25.txt"], + expect.any(AbortSignal), + ) + }) + + it('encodes a directory target the same way it encodes a file target', async () => { + const run = vi.fn(async () => ({ stdout: '', stderr: '' })) + await openNativeAssociatedPath('C:\\work\\dir,comma', signal(), { platform: 'win32', run }) + expect(run).toHaveBeenCalledWith( + 'explorer.exe', + ['file:///C:/work/dir%2Ccomma'], expect.any(AbortSignal), ) }) @@ -97,7 +108,7 @@ describe('native path opener', () => { await openNativeTextFile('C:\\work\\settings.yaml', signal(), { platform: 'win32', run }) expect(run).toHaveBeenCalledWith( 'explorer.exe', - ['C:\\work\\settings.yaml'], + ['file:///C:/work/settings.yaml'], expect.any(AbortSignal), ) }) @@ -273,7 +284,7 @@ describe('browser-renderable documents', () => { }) expect(calls).toEqual([ ['wslpath', '-w', '/home/test/page.html'], - ['explorer.exe', 'C:\\workspace\\page.html'], + ['explorer.exe', 'file:///C:/workspace/page.html'], ]) }) }) From 03294cb6533c2d7c99f6b68f04fd074012864abe Mon Sep 17 00:00:00 2001 From: mektpoy Date: Tue, 22 Sep 2026 17:31:12 +0800 Subject: [PATCH 05/28] test(native-command): pin every Explorer open target through one table --- .../native-command/tests/path-opener.spec.ts | 24 +++++++++++-------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/packages/util/native-command/tests/path-opener.spec.ts b/packages/util/native-command/tests/path-opener.spec.ts index 9be8ae01e8..97167872ed 100644 --- a/packages/util/native-command/tests/path-opener.spec.ts +++ b/packages/util/native-command/tests/path-opener.spec.ts @@ -80,17 +80,21 @@ describe('native path opener', () => { expect(run).toHaveBeenCalledOnce() }) - it('encodes the target Explorer parses so a comma stays inside the path', async () => { + it.each([ + // Explorer parses its own command line and splits it at commas, and libuv + // quotes an argument only when it holds a space, so the encoded file URI is + // what keeps the whole path in one field. The URI encoding covers the comma, + // spaces, the percent sign, and non-ASCII; the apostrophe and ampersand stay + // literal because no shell interprets them. UNC takes the same encoding. + ["C:\\work\\o'reilly, & 100%.txt", "file:///C:/work/o'reilly%2C%20&%20100%25.txt"], + ['C:\\work\\plain.txt', 'file:///C:/work/plain.txt'], + ['C:\\work\\my report.txt', 'file:///C:/work/my%20report.txt'], + ['C:\\my files\\报告,#%.txt', 'file:///C:/my%20files/%E6%8A%A5%E5%91%8A%2C%23%25.txt'], + ['\\\\server\\share\\a,b.txt', 'file://server/share/a%2Cb.txt'], + ] as const)('opens %s through the one encoded Explorer target', async (path, target) => { const run = vi.fn(async () => ({ stdout: '', stderr: '' })) - // Explorer parses its own command line and splits it at commas, so the path - // crosses as a file URI with the comma percent-encoded. The apostrophe, - // ampersand, space, and percent need no escaping: no shell interprets them. - await openNativePath("C:\\work\\o'reilly, & 100%.txt", signal(), { platform: 'win32', run }) - expect(run).toHaveBeenCalledWith( - 'explorer.exe', - ["file:///C:/work/o'reilly%2C%20&%20100%25.txt"], - expect.any(AbortSignal), - ) + await openNativePath(path, signal(), { platform: 'win32', run }) + expect(run).toHaveBeenCalledExactlyOnceWith('explorer.exe', [target], expect.any(AbortSignal)) }) it('encodes a directory target the same way it encodes a file target', async () => { From fac0d8d877a6d85cde58edd27e13bfec953e4b32 Mon Sep 17 00:00:00 2001 From: mektpoy Date: Tue, 22 Sep 2026 17:35:54 +0800 Subject: [PATCH 06/28] docs(native-command): record the interactive-session requirement for Explorer opens --- ...2026-09-22-windows-open-through-shell-resolution.i18n.yaml | 4 ++-- .../2026-09-22-windows-open-through-shell-resolution.md | 4 +++- .../2026-09-22-windows-open-through-shell-resolution.zh.md | 4 +++- packages/util/native-command/README.i18n.yaml | 4 ++-- packages/util/native-command/README.md | 1 + packages/util/native-command/README.zh.md | 1 + 6 files changed, 12 insertions(+), 6 deletions(-) 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 index 793d1c261f..5f9cf4bdf9 100644 --- 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 @@ -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-09-22-windows-open-through-shell-resolution.md -2026-09-22-windows-open-through-shell-resolution.md: 359d4163a06cc39b84f423940a22a1bf862dfbc8 -2026-09-22-windows-open-through-shell-resolution.zh.md: 755f9beacf8a635cccd63131acde5a6d072ee077 +2026-09-22-windows-open-through-shell-resolution.md: dac6b089d8d69d40a4cd7c92f4f74ee06f304329 +2026-09-22-windows-open-through-shell-resolution.zh.md: 8d2a00c6769906b568f605451d2afe339aed6dcf 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 index 359d4163a0..dac6b089d8 100644 --- 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 @@ -34,8 +34,10 @@ The settings sheet's open gesture and the artifact and workspace opens share one 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` returned exit code 0 after about 30 seconds without invoking any association, where an `Invoke-Item` command opened the file in about 0.35 seconds: 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 a probe association recorded the argument each handoff passed, and the opener's own commands opened the associated application for a plain file and for a file whose name contains a comma, while the same comma-bearing file handed over as a raw path opened the user's Documents folder instead — the shell split the path at the comma and reported nothing. A directory whose name contains a comma opened as a window rooted at that directory. Every one of these handoffs, successful ones included, exits 1. +`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, and reveal's `/select,` highlight stays unverified. 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 index 755f9beacf..8d2a00c676 100644 --- 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 @@ -34,8 +34,10 @@ Windows 对 HTML 与 SVG 仍然无法命名浏览器,因此 `openInBrowser` 打开器再也无法区分「shell 打开了」与「shell 拒绝了」:两种情况 Explorer 都返回 0 或 1,因此 Remote 依旧回答 `{ opened: true }`,完全没有处理程序的机器只能看到 shell 的选择框。此处不校验窗口是否真的出现。 +交互桌面会话现在是前提。在非交互 Windows 会话(服务,或没有交互登录的计划任务)中,`explorer.exe` 在约 30 秒后以退出码 0 返回且不调用任何关联,而 `Invoke-Item` 命令在同一会话里约 0.35 秒就打开了文件:这样启动的 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 上,一个探针关联记录了每次交接实际收到的参数——打开器自身的命令对普通文件、以及文件名含逗号的文件都启动了关联应用;而同一个含逗号文件若以原始路径交接,则会打开用户的「文档」文件夹,shell 在逗号处分段且不作任何提示。名称含逗号的目录会以该目录为根打开窗口。上述每一次交接(包括成功的那些)退出码都是 1。 +`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 记录的是委派而非结果,接受它不会把错误目标变成成功;真正保住路径完整的是那段编码。此处不观测窗口是否出现,选中路径的 `/select,` 高亮仍未验证。 diff --git a/packages/util/native-command/README.i18n.yaml b/packages/util/native-command/README.i18n.yaml index 0e2bf74b61..d829a16ffc 100644 --- a/packages/util/native-command/README.i18n.yaml +++ b/packages/util/native-command/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/util/native-command/README.md -README.md: b158cbf3153962af14ed99c07f7527f4bdb86f89 -README.zh.md: f02a37480934122dbf569983105b43b9962b894c +README.md: 72c9d0097975773d316e3f2fdba87da1eca4daa4 +README.zh.md: 35770a99756a9eb218b024406690b340c09bfd84 diff --git a/packages/util/native-command/README.md b/packages/util/native-command/README.md index b158cbf315..72c9d00979 100644 --- a/packages/util/native-command/README.md +++ b/packages/util/native-command/README.md @@ -109,6 +109,7 @@ Linux association discovery and explicit launching require GIO. Missing native c These limits define when this runner is not the right tool. They are current package constraints, not a task backlog. - Command output uses Node’s `execFile` buffer limit; oversized replies reject. Use the subprocess capability for streaming output. +- A Windows host needs an interactive desktop session. In a non-interactive session — a service, or a scheduled task without an interactive logon — `explorer.exe` returns exit code 0 after about 30 seconds without invoking any file association, so an open reports success having opened nothing; a caller's abort signal turns that wait into a timeout rather than a hang. An `Invoke-Item` command resolves the association in-process there in about 0.35 seconds, and its directory failure arrives as a non-zero exit code. No fallback for that session is implemented. ### Dev Note diff --git a/packages/util/native-command/README.zh.md b/packages/util/native-command/README.zh.md index f02a374809..35770a9975 100644 --- a/packages/util/native-command/README.zh.md +++ b/packages/util/native-command/README.zh.md @@ -109,6 +109,7 @@ Linux 的关联查询和指定应用启动需要 GIO。缺少原生命令时查 这些限制说明本运行器何时不是合适的工具。它们是当前包约束,不是任务积压。 - 命令输出受 Node 的 `execFile` 缓冲上限约束,超过上限时拒绝结果。流式输出由 subprocess 能力处理。 +- Windows 主机需要交互桌面会话。在非交互会话(服务,或没有交互登录的计划任务)中,`explorer.exe` 会在约 30 秒后以退出码 0 返回且不调用任何文件关联,于是「打开」会报告成功却什么都没打开;调用方的 abort 信号把这段等待变成超时而不是挂死。同一目标改用 `Invoke-Item` 命令时,该会话内约 0.35 秒即可在进程内解析关联,目录失败也会以非零退出码报出。该会话下的兜底尚未实现。 ### 开发备注 From fd784e44296456d13cee10f3271d4eef682fd9db Mon Sep 17 00:00:00 2001 From: sjw1231 Date: Tue, 22 Sep 2026 20:23:45 +0800 Subject: [PATCH 07/28] fix(web): refine file diff review colors --- apps/web/tests/changed-files-turn.e2e.ts | 22 ++++++++++++++----- .../client/ui-deliverables/README.i18n.yaml | 4 ++-- packages/client/ui-deliverables/README.md | 2 +- packages/client/ui-deliverables/README.zh.md | 2 +- .../src/client/FileDiff.module.css | 12 +++++----- packages/client/ui-theme/README.i18n.yaml | 4 ++-- packages/client/ui-theme/README.md | 2 +- packages/client/ui-theme/README.zh.md | 2 +- .../ui-theme/src/styles/design-platform.css | 12 ++++++++++ 9 files changed, 42 insertions(+), 20 deletions(-) diff --git a/apps/web/tests/changed-files-turn.e2e.ts b/apps/web/tests/changed-files-turn.e2e.ts index 8365c34f45..8d7eeafa36 100644 --- a/apps/web/tests/changed-files-turn.e2e.ts +++ b/apps/web/tests/changed-files-turn.e2e.ts @@ -351,7 +351,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 +368,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).not.toBe(addedRule.markerColour) 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 +404,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 +421,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).not.toBe(deletedRule.markerColour) expect(deletedRule.numberLeft).toBeCloseTo(leftContextRule.numberLeft, 1) expect(deletedRule.textLeft).toBeCloseTo(leftContextRule.textLeft, 1) await compareTool.click() @@ -472,9 +476,15 @@ describe('web e2e: a git workspace turn ends with its changed files', () => { const wrappedAdditionRule = await wrappedAddition.evaluate((cell) => { 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 } + const number = cell.firstElementChild! + 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).not.toBe(wrappedAdditionRule.markerColour) // 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/packages/client/ui-deliverables/README.i18n.yaml b/packages/client/ui-deliverables/README.i18n.yaml index 890059d48e..ec9b6a2d71 100644 --- a/packages/client/ui-deliverables/README.i18n.yaml +++ b/packages/client/ui-deliverables/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-deliverables/README.md -README.md: 7611e613073457c7843cc2a969e2f295fc204bbf -README.zh.md: 15162229dc4adc5c0143cd141481887a5c996be7 +README.md: c52db38df0c800f52de4eebb3a9692eabef6c9df +README.zh.md: 785c2215746e36ead16fe08b1a21b04a58ef418c diff --git a/packages/client/ui-deliverables/README.md b/packages/client/ui-deliverables/README.md index 7611e61307..c52db38df0 100644 --- a/packages/client/ui-deliverables/README.md +++ b/packages/client/ui-deliverables/README.md @@ -42,7 +42,7 @@ The card renders the summary the Host serves for the turn's latest `workspace/ch ### The review tab -A row opens the turn's `changes-review` tab, addressed by the viewed Session and the announcing event's sequence and titled by the turn, on that row's file; another row of the same card reveals the same tab on its file. The header's file selector lists every recorded file with its counts and switches the comparison. A new tab starts in the side-by-side view without wrapping. Its controls switch between unified and side-by-side comparison, toggle line wrapping, open the whole current file in the Sidebar, and, with a Host desktop available, open it in the default application with the same pending and retryable-error states as the cards; the view and wrap choices are kept per tab. The tab reads the summary and each comparison once through the authenticated routes. A text comparison lists its hunks with the old and new line numbers of every line, and highlights each rendered hunk side independently with the code preview's filename grammar and Shiki token colours. Additions and deletions use aligned success or error markers and background tints. The tab also shows a note when the file was created or deleted in the turn, when both sides hold the same lines, when the Host's line comparison timed out and every line shows as replaced, or when the tab stopped drawing at 5,000 lines. A binary or oversized file, a comparison the Host no longer serves, and a failed read each show one line instead; a failed read offers Retry. The comparison is the turn's snapshot of the file, not its current content. +A row opens the turn's `changes-review` tab, addressed by the viewed Session and the announcing event's sequence and titled by the turn, on that row's file; another row of the same card reveals the same tab on its file. The header's file selector lists every recorded file with its counts and switches the comparison. A new tab starts in the side-by-side view without wrapping. Its controls switch between unified and side-by-side comparison, toggle line wrapping, open the whole current file in the Sidebar, and, with a Host desktop available, open it in the default application with the same pending and retryable-error states as the cards; the view and wrap choices are kept per tab. The tab reads the summary and each comparison once through the authenticated routes. A text comparison lists its hunks with the old and new line numbers of every line, and highlights each rendered hunk side independently with the code preview's filename grammar and Shiki token colours. Additions and deletions use separate code and line-number backgrounds in each theme, with coloured line numbers, signs, and aligned edge markers; plain code text keeps the primary text colour. The tab also shows a note when the file was created or deleted in the turn, when both sides hold the same lines, when the Host's line comparison timed out and every line shows as replaced, or when the tab stopped drawing at 5,000 lines. A binary or oversized file, a comparison the Host no longer serves, and a failed read each show one line instead; a failed read offers Retry. The comparison is the turn's snapshot of the file, not its current content. The selected-file title uses the shared `ui-primitives` [`PathLabel`](../ui-primitives/README.md#component-catalog): complete and left-aligned when it fits, otherwise clipped at the left with a fade that preserves trailing characters and the extension. Directories use subdued text, the filename uses primary text, and hovering reveals the complete displayed path. Resizing the pane or selecting another file updates the fade; the dropdown arrow, line counts, and toolbar actions retain their space. diff --git a/packages/client/ui-deliverables/README.zh.md b/packages/client/ui-deliverables/README.zh.md index 15162229dc..785c221574 100644 --- a/packages/client/ui-deliverables/README.zh.md +++ b/packages/client/ui-deliverables/README.zh.md @@ -42,7 +42,7 @@ Web 的 `standard`、`ptc` 与 `cordis` preset 提供 `present`,用于声明 ### review tab -行在该行的文件上打开本轮的 `changes-review` tab,其地址由当前查看的 Session 和宣告事件的序号组成,以轮号作标题;同一张卡片的另一行会在其文件上显示同一个 tab。头部的文件选择器列出所有记录的文件及其行数,用于切换对比。新 tab 默认使用不换行的左右对比。其控件用于切换单栏或左右对比、切换自动换行、在 Sidebar 中打开当前完整文件,并在 Host 有桌面时用默认应用打开文件;原生打开的等待与可重试错误状态与卡片一致,视图和换行选择按 tab 保留。tab 通过经过认证的路由各读取一次摘要和每个对比。文本对比逐个列出 hunk,每一行带旧侧和新侧的行号,并按代码预览的文件名 grammar 与 Shiki token 色分别高亮每个已渲染 hunk 的两侧。新增与删除使用对齐的成功色或错误色标记和背景色。文件是本轮新建或删除、两侧内容相同、Host 的逐行对比超时而按整文件替换显示,或 tab 在 5000 行处停止绘制时,tab 还会给出一行说明。二进制或过大的文件、Host 已不再提供的对比,以及读取失败各显示一行提示;读取失败时提供重试。对比是本轮对该文件的快照,不是它当前的内容。 +行在该行的文件上打开本轮的 `changes-review` tab,其地址由当前查看的 Session 和宣告事件的序号组成,以轮号作标题;同一张卡片的另一行会在其文件上显示同一个 tab。头部的文件选择器列出所有记录的文件及其行数,用于切换对比。新 tab 默认使用不换行的左右对比。其控件用于切换单栏或左右对比、切换自动换行、在 Sidebar 中打开当前完整文件,并在 Host 有桌面时用默认应用打开文件;原生打开的等待与可重试错误状态与卡片一致,视图和换行选择按 tab 保留。tab 通过经过认证的路由各读取一次摘要和每个对比。文本对比逐个列出 hunk,每一行带旧侧和新侧的行号,并按代码预览的文件名 grammar 与 Shiki token 色分别高亮每个已渲染 hunk 的两侧。新增与删除在各主题下使用独立的代码区与行号区底色,并为行号、增删符号和对齐的左侧色条着色;普通代码文字保留主文本色。文件是本轮新建或删除、两侧内容相同、Host 的逐行对比超时而按整文件替换显示,或 tab 在 5000 行处停止绘制时,tab 还会给出一行说明。二进制或过大的文件、Host 已不再提供的对比,以及读取失败各显示一行提示;读取失败时提供重试。对比是本轮对该文件的快照,不是它当前的内容。 当前文件标题使用 `ui-primitives` 的共享组件 [`PathLabel`](../ui-primitives/README.zh.md#component-catalog):空间足够时完整靠左显示,否则从左侧裁剪并渐隐,保留尾部字符和扩展名。目录使用弱化颜色,文件名使用主色,悬停显示完整路径。调整面板宽度或切换文件时更新渐隐;下拉箭头、行数和工具栏操作保留各自的空间。 diff --git a/packages/client/ui-deliverables/src/client/FileDiff.module.css b/packages/client/ui-deliverables/src/client/FileDiff.module.css index 21d18dad44..db25c09e87 100644 --- a/packages/client/ui-deliverables/src/client/FileDiff.module.css +++ b/packages/client/ui-deliverables/src/client/FileDiff.module.css @@ -1,6 +1,5 @@ /** Shared comparison layout, header, states, and numbered diff lines for previews and review tabs. */ -.root { --diff-add-fill: color-mix(in srgb, var(--dsw-alias-state-success-primary) 8%, transparent); --diff-del-fill: color-mix(in srgb, var(--dsw-alias-state-error-primary) 8%, transparent); --diff-empty-fill: color-mix(in srgb, var(--dsw-alias-interactive-bg-hover) 50%, transparent); display: flex; flex-direction: column; box-sizing: border-box; width: 100%; min-height: 0; color: var(--dsw-alias-label-primary); } -:global(body[data-ds-dark-theme]) .root { --diff-add-fill: color-mix(in srgb, var(--dsw-alias-state-success-primary) 12%, transparent); --diff-del-fill: color-mix(in srgb, var(--dsw-alias-state-error-primary) 12%, transparent); } +.root { --diff-empty-fill: color-mix(in srgb, var(--dsw-alias-interactive-bg-hover) 50%, transparent); display: flex; flex-direction: column; box-sizing: border-box; width: 100%; min-height: 0; color: var(--dsw-alias-label-primary); } .header { display: flex; flex: 0 0 auto; gap: 6px; align-items: center; box-sizing: border-box; height: 38px; padding: 0 6px 0 8px; border-bottom: 0.5px solid var(--dsw-alias-border-l3); } .status { display: flex; gap: 12px; align-items: center; margin: 0; padding: 16px; font-size: 13px; color: var(--dsw-alias-label-secondary); } .body { display: flex; flex: 1 1 auto; flex-direction: column; min-height: 0; overflow: auto; padding: 8px 0 16px; font: var(--dsw-font-markdown-code-block); } @@ -21,9 +20,10 @@ .text { padding-right: 16px; } .body[data-review-wrap] .line, .body[data-review-wrap] .splitLine { white-space: pre-wrap; } .body[data-review-wrap] .text { overflow-wrap: anywhere; } -.add { background: var(--diff-add-fill); box-shadow: inset 3px 0 0 var(--dsw-alias-state-success-primary); } -.add .sign, .add .text { color: var(--dsw-alias-state-success-primary); } -.del { background: var(--diff-del-fill); box-shadow: inset 3px 0 0 var(--dsw-alias-state-error-primary); } -.del .sign, .del .text { color: var(--dsw-alias-state-error-primary); } +.add { --diff-gutter-fill: var(--dsw-alias-file-diff-added-gutter); --diff-marker: var(--dsw-alias-file-diff-added-marker); background: var(--dsw-alias-file-diff-added-bg); } +.del { --diff-gutter-fill: var(--dsw-alias-file-diff-deleted-gutter); --diff-marker: var(--dsw-alias-file-diff-deleted-marker); background: var(--dsw-alias-file-diff-deleted-bg); } +.add .number, .del .number { background: var(--diff-gutter-fill); color: var(--diff-marker); } +.add .number:first-child, .del .number:first-child { box-shadow: inset 3px 0 0 var(--diff-marker); } +.add .sign, .del .sign { color: var(--diff-marker); } .context .text { color: var(--dsw-alias-label-secondary); } .empty { background: var(--diff-empty-fill); } diff --git a/packages/client/ui-theme/README.i18n.yaml b/packages/client/ui-theme/README.i18n.yaml index 397f847dab..046b137c88 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: f8db4c3c353aa9b6854c05288316babd198475d7 -README.zh.md: 135878213908ea11093d267c361cf2f80b4d768a +README.md: 7248b7230afa28f817bec7c0eb26fd9512fff405 +README.zh.md: 6dc1db064dd9a6754f6c6475dde18ccf1a17841e diff --git a/packages/client/ui-theme/README.md b/packages/client/ui-theme/README.md index f8db4c3c35..7248b7230a 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 six sheets imported in order by ui-theme's dynamic client entry: `base.css`, `corner-shape.css`, `design-platform.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 six sheets imported in order by ui-theme's dynamic client entry: `base.css`, `corner-shape.css`, `design-platform.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. `brand-font.css` exports the local Montserrat Light, Regular and Medium faces (normal style, weights 300, 400 and 500), with `montserrat-light.woff2`, `montserrat-regular.woff2`, `montserrat-medium.woff2` and its SIL Open Font License in `lib/styles/`. Desktop bundles the same stylesheet, font and license for offline welcome brand text; ordinary UI keeps its system font stack. diff --git a/packages/client/ui-theme/README.zh.md b/packages/client/ui-theme/README.zh.md index 1358782139..6dc1db064d 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`、`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`、`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` 负责语法颜色。 `brand-font.css` 导出本地 Montserrat Light、Regular 和 Medium 字体(正体、字重 300、400 和 500),`lib/styles/` 同时提供 `montserrat-light.woff2`、`montserrat-regular.woff2`、`montserrat-medium.woff2` 及其 SIL Open Font License。Desktop 将同一份样式表、字体和许可证打包,用于欢迎页品牌文字的离线显示;普通界面保留系统字体栈。 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); From b73031be9b5e7131228e628cf21bd4d7bbfdccb6 Mon Sep 17 00:00:00 2001 From: mektpoy Date: Tue, 22 Sep 2026 20:27:45 +0800 Subject: [PATCH 08/28] test(native-command): pin the Explorer target for verbatim and device paths A verbatim `\\?\` prefix and a `\\.\` device namespace take Node's UNC resolution, and the comma encoding has to survive them. The drive-root case is pinned separately because Node restores a trailing separator against the host's `path.sep`, which differs on the Windows host this opener runs on. --- .../native-command/tests/path-opener.spec.ts | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/packages/util/native-command/tests/path-opener.spec.ts b/packages/util/native-command/tests/path-opener.spec.ts index 97167872ed..d4996539f2 100644 --- a/packages/util/native-command/tests/path-opener.spec.ts +++ b/packages/util/native-command/tests/path-opener.spec.ts @@ -91,12 +91,33 @@ describe('native path opener', () => { ['C:\\work\\my report.txt', 'file:///C:/work/my%20report.txt'], ['C:\\my files\\报告,#%.txt', 'file:///C:/my%20files/%E6%8A%A5%E5%91%8A%2C%23%25.txt'], ['\\\\server\\share\\a,b.txt', 'file://server/share/a%2Cb.txt'], + // Node resolves the path before encoding it, so a verbatim prefix is not + // part of the target: `\\?\` and `\\?\UNC\` arrive as the ordinary drive or + // UNC URI. A device-namespace path takes that same UNC handling; it names a + // device rather than a shell item, which this opener does not open. + ['\\\\?\\C:\\work\\a,b.txt', 'file:///C:/work/a%2Cb.txt'], + ['\\\\?\\UNC\\server\\share\\a,b.txt', 'file://server/share/a%2Cb.txt'], + ['\\\\.\\C:\\work\\a,b.txt', 'file://./C:/work/a%2Cb.txt'], + ['\\\\wsl$\\Ubuntu\\home\\t,est a.txt', 'file://wsl$/Ubuntu/home/t%2Cest%20a.txt'], ] as const)('opens %s through the one encoded Explorer target', async (path, target) => { const run = vi.fn(async () => ({ stdout: '', stderr: '' })) await openNativePath(path, signal(), { platform: 'win32', run }) expect(run).toHaveBeenCalledExactlyOnceWith('explorer.exe', [target], expect.any(AbortSignal)) }) + // Node restores a trailing separator against the host's `path.sep`, so a + // separator-terminated Windows path encodes differently on the Windows host + // this opener runs on than on the POSIX host a test run may use. + it('encodes a separator-terminated drive root as the hosting platform resolves it', async () => { + const run = vi.fn(async () => ({ stdout: '', stderr: '' })) + await openNativePath('C:\\', signal(), { platform: 'win32', run }) + expect(run).toHaveBeenCalledExactlyOnceWith( + 'explorer.exe', + [process.platform === 'win32' ? 'file:///C:/' : 'file:///C://'], + expect.any(AbortSignal), + ) + }) + it('encodes a directory target the same way it encodes a file target', async () => { const run = vi.fn(async () => ({ stdout: '', stderr: '' })) await openNativeAssociatedPath('C:\\work\\dir,comma', signal(), { platform: 'win32', run }) From a6b909c7675673e306fb3692971bd63910a1dc54 Mon Sep 17 00:00:00 2001 From: mektpoy Date: Tue, 22 Sep 2026 20:27:46 +0800 Subject: [PATCH 09/28] docs(native-command): correct the non-interactive Explorer claim The recorded exit code 0 after about 30 seconds was not reproduced: an SSH session 0 on Windows 11 ARM64 build 26200 measured exit code 1 after about a second with no association invoked, the same exit code a delegated open returns. The README, both Agent Note languages, and the `explorerTarget` JSDoc now record that measurement, and the `Invoke-Item` contrast keeps its qualitative part without the timing. --- ...026-09-22-windows-open-through-shell-resolution.i18n.yaml | 4 ++-- .../2026-09-22-windows-open-through-shell-resolution.md | 2 +- .../2026-09-22-windows-open-through-shell-resolution.zh.md | 2 +- packages/util/native-command/README.i18n.yaml | 4 ++-- packages/util/native-command/README.md | 2 +- packages/util/native-command/README.zh.md | 2 +- packages/util/native-command/src/path-opener.ts | 5 ++++- 7 files changed, 12 insertions(+), 9 deletions(-) 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 index 5f9cf4bdf9..491f5edba4 100644 --- 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 @@ -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-09-22-windows-open-through-shell-resolution.md -2026-09-22-windows-open-through-shell-resolution.md: dac6b089d8d69d40a4cd7c92f4f74ee06f304329 -2026-09-22-windows-open-through-shell-resolution.zh.md: 8d2a00c6769906b568f605451d2afe339aed6dcf +2026-09-22-windows-open-through-shell-resolution.md: 50caa066613ec372cbaef720e0bae575f800a162 +2026-09-22-windows-open-through-shell-resolution.zh.md: a7c7e4eeeb08e2c6e74fb0e24268ca4ea650d335 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 index dac6b089d8..50caa06661 100644 --- 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 @@ -34,7 +34,7 @@ The settings sheet's open gesture and the artifact and workspace opens share one 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` returned exit code 0 after about 30 seconds without invoking any association, where an `Invoke-Item` command opened the file in about 0.35 seconds: 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. +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. 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 index 8d2a00c676..a7c7e4eeeb 100644 --- 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 @@ -34,7 +34,7 @@ Windows 对 HTML 与 SVG 仍然无法命名浏览器,因此 `openInBrowser` 打开器再也无法区分「shell 打开了」与「shell 拒绝了」:两种情况 Explorer 都返回 0 或 1,因此 Remote 依旧回答 `{ opened: true }`,完全没有处理程序的机器只能看到 shell 的选择框。此处不校验窗口是否真的出现。 -交互桌面会话现在是前提。在非交互 Windows 会话(服务,或没有交互登录的计划任务)中,`explorer.exe` 在约 30 秒后以退出码 0 返回且不调用任何关联,而 `Invoke-Item` 命令在同一会话里约 0.35 秒就打开了文件:这样启动的 Host 既失去能力又要付等待代价,只有调用方的 abort 信号能给这段等待封顶。该会话下的兜底尚未实现。 +交互桌面会话现在是前提。在非交互 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 进程;而它转交给桌面会话启动的应用或文件夹窗口,继承的是桌面会话环境而不是宿主进程环境。 diff --git a/packages/util/native-command/README.i18n.yaml b/packages/util/native-command/README.i18n.yaml index d829a16ffc..3101109b93 100644 --- a/packages/util/native-command/README.i18n.yaml +++ b/packages/util/native-command/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/util/native-command/README.md -README.md: 72c9d0097975773d316e3f2fdba87da1eca4daa4 -README.zh.md: 35770a99756a9eb218b024406690b340c09bfd84 +README.md: 537101a2e1a5e60c1ea1c25d0700ae26d3f4df43 +README.zh.md: f7783a503a84baf0e78572b07794e17d6100e082 diff --git a/packages/util/native-command/README.md b/packages/util/native-command/README.md index 72c9d00979..537101a2e1 100644 --- a/packages/util/native-command/README.md +++ b/packages/util/native-command/README.md @@ -109,7 +109,7 @@ Linux association discovery and explicit launching require GIO. Missing native c These limits define when this runner is not the right tool. They are current package constraints, not a task backlog. - Command output uses Node’s `execFile` buffer limit; oversized replies reject. Use the subprocess capability for streaming output. -- A Windows host needs an interactive desktop session. In a non-interactive session — a service, or a scheduled task without an interactive logon — `explorer.exe` returns exit code 0 after about 30 seconds without invoking any file association, so an open reports success having opened nothing; a caller's abort signal turns that wait into a timeout rather than a hang. An `Invoke-Item` command resolves the association in-process there in about 0.35 seconds, and its directory failure arrives as a non-zero exit code. No fallback for that session is implemented. +- A Windows host needs an interactive desktop session. In a non-interactive session — a service, or a scheduled task without an interactive logon — `explorer.exe` invokes no file association and still exits 1 after about a second (an SSH session 0 on Windows 11 ARM64 build 26200), so an open reports success having opened nothing; a caller's abort signal turns that wait into a timeout rather than a hang. An `Invoke-Item` command resolves the association in-process there, and its directory failure arrives as a non-zero exit code. No fallback for that session is implemented. ### Dev Note diff --git a/packages/util/native-command/README.zh.md b/packages/util/native-command/README.zh.md index 35770a9975..f7783a503a 100644 --- a/packages/util/native-command/README.zh.md +++ b/packages/util/native-command/README.zh.md @@ -109,7 +109,7 @@ Linux 的关联查询和指定应用启动需要 GIO。缺少原生命令时查 这些限制说明本运行器何时不是合适的工具。它们是当前包约束,不是任务积压。 - 命令输出受 Node 的 `execFile` 缓冲上限约束,超过上限时拒绝结果。流式输出由 subprocess 能力处理。 -- Windows 主机需要交互桌面会话。在非交互会话(服务,或没有交互登录的计划任务)中,`explorer.exe` 会在约 30 秒后以退出码 0 返回且不调用任何文件关联,于是「打开」会报告成功却什么都没打开;调用方的 abort 信号把这段等待变成超时而不是挂死。同一目标改用 `Invoke-Item` 命令时,该会话内约 0.35 秒即可在进程内解析关联,目录失败也会以非零退出码报出。该会话下的兜底尚未实现。 +- Windows 主机需要交互桌面会话。在非交互会话(服务,或没有交互登录的计划任务)中,`explorer.exe` 不调用任何文件关联,且仍在约 1 秒后以退出码 1 返回(Windows 11 ARM64 build 26200 的 SSH session 0 实测),于是「打开」会报告成功却什么都没打开;调用方的 abort 信号把这段等待变成超时而不是挂死。同一目标改用 `Invoke-Item` 命令时可在该会话内解析关联,目录失败也会以非零退出码报出。该会话下的兜底尚未实现。 ### 开发备注 diff --git a/packages/util/native-command/src/path-opener.ts b/packages/util/native-command/src/path-opener.ts index e4341f8567..303cfc41e2 100644 --- a/packages/util/native-command/src/path-opener.ts +++ b/packages/util/native-command/src/path-opener.ts @@ -104,7 +104,10 @@ function isWsl(internals: PathOpenerInternals): boolean { * so a path handed over raw loses everything after its first comma and the * shell opens a different target without reporting it. A file URI has `,` * percent-encoded and keeps whitespace, and Explorer resolves it to the shell - * item the path names. + * item the path names. Node resolves the path before encoding it, so a verbatim + * `\\?\` or `\\?\UNC\` prefix reaches Explorer as the ordinary drive or UNC + * URI; a `\\.\` device path keeps that same UNC handling and names a device + * rather than a shell item, which this opener does not open. * @param windowsPath - path already translated for the Windows desktop. * @returns the target for an open, or the object of a `/select,` reveal. */ From 2b9923fedea05efea0a26bab3ba6ded4e943d6ac Mon Sep 17 00:00:00 2001 From: mektpoy Date: Wed, 23 Sep 2026 12:33:31 +0800 Subject: [PATCH 10/28] fix(native-command): escape both Explorer field separators and keep non-ASCII literal On Windows 11 ARM64 build 26200 Explorer splits its own command line at commas and equals signs and rejects percent-encoded non-ASCII in a file URI, falling back to the Documents folder in both cases without reporting it. `explorerTarget()` now escapes `=` next to `,` and decodes Node's non-ASCII escapes back to the literal characters the shell resolves, so the two rules stay in one place. The Agent Note and the target-encoding table record the measured behavior. --- ...ws-open-through-shell-resolution.i18n.yaml | 4 +-- ...2-windows-open-through-shell-resolution.md | 4 +-- ...indows-open-through-shell-resolution.zh.md | 4 +-- .../util/native-command/src/path-opener.ts | 25 ++++++++++++------- .../native-command/tests/path-opener.spec.ts | 21 ++++++++++------ 5 files changed, 35 insertions(+), 23 deletions(-) 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 index 491f5edba4..420cb47036 100644 --- 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 @@ -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-09-22-windows-open-through-shell-resolution.md -2026-09-22-windows-open-through-shell-resolution.md: 50caa066613ec372cbaef720e0bae575f800a162 -2026-09-22-windows-open-through-shell-resolution.zh.md: a7c7e4eeeb08e2c6e74fb0e24268ca4ea650d335 +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 index 50caa06661..7bfe559e7f 100644 --- 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 @@ -12,7 +12,7 @@ That machine records the `.yml` default only in `HKCU\Software\Microsoft\Windows ## 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 percent-encoded, because Explorer parses its own command line and splits it at commas — a raw path containing one opens a different target without reporting it. 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. +`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. @@ -40,4 +40,4 @@ Directories take the same path. The rejected alternative recorded in [the open-i ## 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, and reveal's `/select,` highlight stays unverified. +`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 index a7c7e4eeeb..ff33455da1 100644 --- 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 @@ -12,7 +12,7 @@ Windows 桌面客户端设置页的「打开配置文件」会准备当前 profi ## 决策 -`openWindowsPath` 将路径作为**一个** argv 元素交给 `explorer.exe`,`revealNativePath` 经共享的 `runExplorer` 帮助函数走到同一个 shell。两者都把目标编码成文件 URI 并对逗号做百分号编码,因为资源管理器自己解析命令行并以逗号分段——含逗号的原始路径会打开另一个目标且不作任何提示。资源管理器执行的是双击所执行的默认应用解析,因此由 shell 的答案选择应用——包括只记录在较新的按用户记录里的默认值;而完全没有处理程序的机器会得到资源管理器自己的「你要如何打开这个文件?」选择框,而不是毫无反应。 +`openWindowsPath` 将路径作为**一个** argv 元素交给 `explorer.exe`,`revealNativePath` 经共享的 `runExplorer` 帮助函数走到同一个 shell。两者都把目标编码成文件 URI 并对**逗号与等号**做百分号编码,因为资源管理器自己解析命令行并以这两个字符分段——含其中任一字符的原始路径都会打开另一个目标且不作任何提示。其余字符不做转义:资源管理器会拒绝 file URI 里百分号编码的非 ASCII 并回落到「文档」,因此这些字符以字面形式交给它。资源管理器执行的是双击所执行的默认应用解析,因此由 shell 的答案选择应用——包括只记录在较新的按用户记录里的默认值;而完全没有处理程序的机器会得到资源管理器自己的「你要如何打开这个文件?」选择框,而不是毫无反应。 `runExplorer` 为两种操作持有唯一一条 Explorer 调用规则:退出码 1 是 Explorer 把请求转交给已在运行的桌面进程后返回的交接码,它照常兑现调用方的 Promise;其余失败和取消仍然报错。该帮助函数取代了 PowerShell 命令字符串,同时删掉了 `powershellLiteral` 及其单引号翻倍逻辑:路径以 argv 元素跨进程边界,去掉的是 shell 层转义,而不是资源管理器自身解析所需的编码;该编码由 `explorerTarget` 为两种意图统一持有。 @@ -40,4 +40,4 @@ Windows 对 HTML 与 SVG 仍然无法命名浏览器,因此 `openInBrowser` ## 验证 -`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 记录的是委派而非结果,接受它不会把错误目标变成成功;真正保住路径完整的是那段编码。此处不观测窗口是否出现,选中路径的 `/select,` 高亮仍未验证。 +`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/packages/util/native-command/src/path-opener.ts b/packages/util/native-command/src/path-opener.ts index 303cfc41e2..67bbc35902 100644 --- a/packages/util/native-command/src/path-opener.ts +++ b/packages/util/native-command/src/path-opener.ts @@ -100,19 +100,26 @@ function isWsl(internals: PathOpenerInternals): boolean { /** * Encode one Windows path as the target Explorer can receive intact. * - * Explorer parses its own command line and treats commas as field separators, - * so a path handed over raw loses everything after its first comma and the - * shell opens a different target without reporting it. A file URI has `,` - * percent-encoded and keeps whitespace, and Explorer resolves it to the shell - * item the path names. Node resolves the path before encoding it, so a verbatim - * `\\?\` or `\\?\UNC\` prefix reaches Explorer as the ordinary drive or UNC - * URI; a `\\.\` device path keeps that same UNC handling and names a device - * rather than a shell item, which this opener does not open. + * Explorer parses its own command line and splits fields at commas and equals + * signs, so a raw path loses everything after the first separator and the shell + * opens a different target without reporting it; both separators are escaped. + * Nothing else is: Explorer rejects percent-encoded non-ASCII in a file URI and + * opens the user's Documents folder instead, while it resolves the literal + * characters, so Node's non-ASCII escapes are decoded back and its ASCII escapes + * stand. Node resolves the path before encoding it, so a verbatim `\\?\` or + * `\\?\UNC\` prefix reaches Explorer as the ordinary drive or UNC URI; a `\\.\` + * device path keeps that same UNC handling and names a device rather than a + * shell item, which this opener does not open. * @param windowsPath - path already translated for the Windows desktop. * @returns the target for an open, or the object of a `/select,` reveal. */ function explorerTarget(windowsPath: string): string { - return pathToFileURL(windowsPath, { windows: true }).href.replaceAll(',', '%2C') + const href = pathToFileURL(windowsPath, { windows: true }).href + // A run of escapes whose every byte starts above ASCII is one non-ASCII character. + return href + .replace(/(?:%[89A-F][0-9A-F])+/gi, escaped => decodeURIComponent(escaped)) + .replaceAll(',', '%2C') + .replaceAll('=', '%3D') } /** diff --git a/packages/util/native-command/tests/path-opener.spec.ts b/packages/util/native-command/tests/path-opener.spec.ts index d4996539f2..2b5c32456b 100644 --- a/packages/util/native-command/tests/path-opener.spec.ts +++ b/packages/util/native-command/tests/path-opener.spec.ts @@ -81,15 +81,20 @@ describe('native path opener', () => { }) it.each([ - // Explorer parses its own command line and splits it at commas, and libuv - // quotes an argument only when it holds a space, so the encoded file URI is - // what keeps the whole path in one field. The URI encoding covers the comma, - // spaces, the percent sign, and non-ASCII; the apostrophe and ampersand stay - // literal because no shell interprets them. UNC takes the same encoding. + // Explorer parses its own command line and splits fields at commas and + // equals signs, and libuv quotes an argument only when it holds a space, so + // the encoded file URI is what keeps the whole path in one field. Only those + // two separators are escaped on top of the URI's own encoding: Explorer + // rejects percent-encoded non-ASCII and opens Documents instead, while it + // resolves the literal characters, so `报告` stays literal here. UNC takes + // the same encoding. ["C:\\work\\o'reilly, & 100%.txt", "file:///C:/work/o'reilly%2C%20&%20100%25.txt"], ['C:\\work\\plain.txt', 'file:///C:/work/plain.txt'], ['C:\\work\\my report.txt', 'file:///C:/work/my%20report.txt'], - ['C:\\my files\\报告,#%.txt', 'file:///C:/my%20files/%E6%8A%A5%E5%91%8A%2C%23%25.txt'], + ['C:\\work\\key=value.txt', 'file:///C:/work/key%3Dvalue.txt'], + ['C:\\my files\\报告,#%.txt', 'file:///C:/my%20files/报告%2C%23%25.txt'], + ['C:\\目录\\a,b=c.txt', 'file:///C:/目录/a%2Cb%3Dc.txt'], + ['C:\\工作\\报告 2026.txt', 'file:///C:/工作/报告%202026.txt'], ['\\\\server\\share\\a,b.txt', 'file://server/share/a%2Cb.txt'], // Node resolves the path before encoding it, so a verbatim prefix is not // part of the target: `\\?\` and `\\?\UNC\` arrive as the ordinary drive or @@ -372,7 +377,7 @@ describe('native file manager', () => { expect(nativeFileManager(internals)).toBe('explorer') await revealNativePath('/mnt/c/work/报告.txt', signal(), internals) expect(run.mock.calls.map(([cmd, args]) => [cmd, args])).toEqual([ - ['wslpath', ['-w', '/mnt/c/work/报告.txt']], ['explorer.exe', ['/select,', 'file:///C:/work/%E6%8A%A5%E5%91%8A.txt']], + ['wslpath', ['-w', '/mnt/c/work/报告.txt']], ['explorer.exe', ['/select,', 'file:///C:/work/报告.txt']], ]) }) @@ -449,7 +454,7 @@ it('preserves a non-delegate Explorer failure when opening', async () => { }) it.each([ - ['C:\\my files\\报告,#%.txt', 'file:///C:/my%20files/%E6%8A%A5%E5%91%8A%2C%23%25.txt'], + ['C:\\my files\\报告,#%.txt', 'file:///C:/my%20files/报告%2C%23%25.txt'], ['\\\\server\\share\\a,b.txt', 'file://server/share/a%2Cb.txt'], ])('preserves special characters in the Explorer target %s', async (path, target) => { const run = vi.fn().mockResolvedValue({ stdout: '', stderr: '' }) From c4955b1900836bf7ca00fd3d220c9caaad756694 Mon Sep 17 00:00:00 2001 From: sjw1231 Date: Wed, 23 Sep 2026 14:42:08 +0800 Subject: [PATCH 11/28] test(web): strengthen diff review color assertions --- apps/web/tests/changed-files-turn.e2e.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/apps/web/tests/changed-files-turn.e2e.ts b/apps/web/tests/changed-files-turn.e2e.ts index 8d7eeafa36..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) => { @@ -369,7 +370,7 @@ describe('web e2e: a git workspace turn ends with its changed files', () => { } }) expect(addedRule.shadow).toContain(addedRule.markerColour) - expect(addedRule.textColour).not.toBe(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. @@ -422,7 +423,7 @@ describe('web e2e: a git workspace turn ends with its changed files', () => { } }) expect(deletedRule.shadow).toContain(deletedRule.markerColour) - expect(deletedRule.textColour).not.toBe(deletedRule.markerColour) + expect(deletedRule.textColour).toBe(primaryTextColour) expect(deletedRule.numberLeft).toBeCloseTo(leftContextRule.numberLeft, 1) expect(deletedRule.textLeft).toBeCloseTo(leftContextRule.textLeft, 1) await compareTool.click() @@ -474,9 +475,9 @@ 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') - const number = cell.firstElementChild! + if (number === undefined || text === null) throw new Error('wrapped addition is incomplete') return { shadow: getComputedStyle(number).boxShadow, markerColour: getComputedStyle(number).color, @@ -484,7 +485,7 @@ describe('web e2e: a git workspace turn ends with its changed files', () => { } }) expect(wrappedAdditionRule.shadow).toContain(wrappedAdditionRule.markerColour) - expect(wrappedAdditionRule.textColour).not.toBe(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) From edd0a438bff4b1b822fd44376ac1398a3e58b396 Mon Sep 17 00:00:00 2001 From: mektpoy Date: Wed, 23 Sep 2026 14:49:30 +0800 Subject: [PATCH 12/28] docs(native-command): state that the Explorer handoff acknowledgement proves no open The open paragraph accepted Explorer's exit code 1 as a delegated handoff but did not repeat the reveal paragraph's warning that the acknowledgement proves no application opened the file, which the Agent Note records for the Remote answer `{ opened: true }`. --- packages/util/native-command/README.i18n.yaml | 4 ++-- packages/util/native-command/README.md | 2 +- packages/util/native-command/README.zh.md | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/util/native-command/README.i18n.yaml b/packages/util/native-command/README.i18n.yaml index 3101109b93..876619f9d6 100644 --- a/packages/util/native-command/README.i18n.yaml +++ b/packages/util/native-command/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/util/native-command/README.md -README.md: 537101a2e1a5e60c1ea1c25d0700ae26d3f4df43 -README.zh.md: f7783a503a84baf0e78572b07794e17d6100e082 +README.md: c9e1f3fd936093bcad334390704b97630f7acfc8 +README.zh.md: 2c6feadbb8e09391ab5f112e0129b669f6c33127 diff --git a/packages/util/native-command/README.md b/packages/util/native-command/README.md index 537101a2e1..c9e1f3fd93 100644 --- a/packages/util/native-command/README.md +++ b/packages/util/native-command/README.md @@ -45,7 +45,7 @@ The `NativeCommandRunner` type is the injectable command boundary for host integ ### Opening a Host path -`openNativePath(path, signal)` hands a path to the default application and prefers the named default browser for HTML and SVG where the platform can identify one. `openNativeAssociatedPath(path, signal)` always uses the file-type association, without a browser override. `openNativeTextFile(path, signal)` selects text-editor intent; on macOS it uses `open -t`. Windows hands every intent to Explorer, so the shell's own default-application resolution — the one a double-click uses — picks the application; Explorer receives the target as an encoded file URI and its exit code 1 counts as a delegated handoff, while cancellation, missing executables, and other exit codes still reject. WSL paths are translated with `wslpath -w` before the Windows desktop receives them. `canOpenNativePath()` reports whether the current Host plausibly has a desktop target. +`openNativePath(path, signal)` hands a path to the default application and prefers the named default browser for HTML and SVG where the platform can identify one. `openNativeAssociatedPath(path, signal)` always uses the file-type association, without a browser override. `openNativeTextFile(path, signal)` selects text-editor intent; on macOS it uses `open -t`. Windows hands every intent to Explorer, so the shell's own default-application resolution — the one a double-click uses — picks the application; Explorer receives the target as an encoded file URI and its exit code 1 counts as a delegated handoff, while cancellation, missing executables, and other exit codes still reject. This acknowledgement does not prove that an application opened the file. WSL paths are translated with `wslpath -w` before the Windows desktop receives them. `canOpenNativePath()` reports whether the current Host plausibly has a desktop target. `revealNativePath(path, signal)` selects the file in Finder or Explorer, including WSL path translation, and opens its parent directory through `xdg-open` on desktop Linux. `nativeFileManager()` identifies that action for Host-derived UI labels; desktop availability remains a separate `canOpenNativePath()` check. Callers must authorize the absolute file path before invoking either operation. Platform dispatch is covered by injected-runner tests; native desktop verification belongs to the corresponding platform. Explorer receives an encoded file URI as a separate argument. Its exit code 1 is accepted as a delegated handoff; cancellation, missing executables, and other exit codes still reject. This acknowledgement does not prove that an application opened the file or that a window selected it. diff --git a/packages/util/native-command/README.zh.md b/packages/util/native-command/README.zh.md index f7783a503a..2c6feadbb8 100644 --- a/packages/util/native-command/README.zh.md +++ b/packages/util/native-command/README.zh.md @@ -45,7 +45,7 @@ const { stdout, stderr } = await runNativeCommand('osascript', ['-e', script], s ### 打开 Host 路径 -`openNativePath(path, signal)` 将路径交给默认应用;平台能够确定默认浏览器时,HTML 与 SVG 会优先交给该浏览器。`openNativeAssociatedPath(path, signal)` 始终使用文件类型关联,不覆盖为浏览器。`openNativeTextFile(path, signal)` 选择文本编辑器意图;macOS 使用 `open -t`。Windows 将每种意图都交给 Explorer,由 shell 自身的默认应用解析(即双击所走的那条)选择应用;Explorer 收到的是编码后的文件 URI,其退出码 1 按已转交请求处理,取消、找不到可执行文件和其他退出码仍然报错。WSL 路径先通过 `wslpath -w` 转换,再交给 Windows 桌面。`canOpenNativePath()` 报告当前 Host 是否可能具备桌面目标。 +`openNativePath(path, signal)` 将路径交给默认应用;平台能够确定默认浏览器时,HTML 与 SVG 会优先交给该浏览器。`openNativeAssociatedPath(path, signal)` 始终使用文件类型关联,不覆盖为浏览器。`openNativeTextFile(path, signal)` 选择文本编辑器意图;macOS 使用 `open -t`。Windows 将每种意图都交给 Explorer,由 shell 自身的默认应用解析(即双击所走的那条)选择应用;Explorer 收到的是编码后的文件 URI,其退出码 1 按已转交请求处理,取消、找不到可执行文件和其他退出码仍然报错。该确认不能证明应用已打开文件。WSL 路径先通过 `wslpath -w` 转换,再交给 Windows 桌面。`canOpenNativePath()` 报告当前 Host 是否可能具备桌面目标。 `revealNativePath(path, signal)` 在 Finder 或文件资源管理器中选中文件,包含 WSL 路径转换;在桌面 Linux 上通过 `xdg-open` 打开上层目录。`nativeFileManager()` 标识该操作,供 UI 根据 Host 选择文案;桌面是否可用仍由独立的 `canOpenNativePath()` 检查决定。调用方必须先授权绝对文件路径,再执行操作。平台分派由注入运行器的测试覆盖;原生桌面验证由对应平台负责。 Explorer 接收独立参数中的编码文件 URI。退出码 1 按已转交请求处理;取消、找不到可执行文件和其他退出码仍然报错。该确认不能证明应用已打开文件,也不能证明桌面窗口已选中文件。 From a56e82e5ddd9bcdd645c7dd4f17d34e0a5c47efa Mon Sep 17 00:00:00 2001 From: turtle1999 Date: Wed, 23 Sep 2026 15:02:27 +0800 Subject: [PATCH 13/28] fix(ui-agent-preset): share reader keyboard focus handling --- .../src/client/AgentPresetSection.tsx | 17 ++-------- .../src/client/PresetGuideDialog.tsx | 34 +++++++++++-------- 2 files changed, 22 insertions(+), 29 deletions(-) diff --git a/packages/client/ui-agent-preset/src/client/AgentPresetSection.tsx b/packages/client/ui-agent-preset/src/client/AgentPresetSection.tsx index 47393cbd17..d15cb50649 100644 --- a/packages/client/ui-agent-preset/src/client/AgentPresetSection.tsx +++ b/packages/client/ui-agent-preset/src/client/AgentPresetSection.tsx @@ -8,7 +8,7 @@ import type { ObservableSnapshot, SnapshotStore } from '@deepseek-ai/dsh-client- 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. */ @@ -181,20 +181,7 @@ export function AgentPresetSection({ event.preventDefault() event.stopPropagation() closeViewer() - } else if (event.key === 'Tab') { - const targets = Array.from(event.currentTarget.querySelectorAll( - 'button:not([disabled]):not([tabindex="-1"]), [tabindex="0"]', - )) - 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) }} title={viewed === null ? '' : `${t('view')} · ${viewedTitle}`} className={css.dialog as string} footer={}> 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 ( From dd004b4221db366866973cdabfdb26eba3640fad Mon Sep 17 00:00:00 2001 From: Yudong Han Date: Wed, 23 Sep 2026 15:46:13 +0800 Subject: [PATCH 14/28] fix(web): simplify read-only spreadsheet preview controls (#5006) * fix(web): simplify read-only spreadsheet preview controls * fix(web): soften read-only spreadsheet freeze dividers * fix(web): hide read-only spreadsheet freeze dividers * fix(web): keep spreadsheet formula bar on one line --- ...2026-09-16-browser-excel-preview.i18n.yaml | 4 +- .../2026-09-16-browser-excel-preview.md | 2 +- .../2026-09-16-browser-excel-preview.zh.md | 2 +- apps/web/tests/document-preview.e2e.ts | 209 +++++++++++++++++- .../README.i18n.yaml | 4 +- .../ui-sidebar-documentpreview/README.md | 4 +- .../ui-sidebar-documentpreview/README.zh.md | 4 +- .../src/client/excel/ExcelBody.module.css | 28 +++ .../tests/excel-fixture.ts | 14 ++ patches/@fortune-sheet__core@1.0.4.patch | 20 +- pnpm-lock.yaml | 8 +- 11 files changed, 274 insertions(+), 25 deletions(-) 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 ` { 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() + 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)) + })).toBeLessThanOrEqual(1) +} + +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 +135,13 @@ 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, '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 +153,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 +176,177 @@ 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]') + for (const colorScheme of ['light', 'dark'] as const) { + await page.emulateMedia({ colorScheme }) + await expect.poll(() => page.locator('body').getAttribute('data-ds-dark-theme')).toBe(colorScheme === 'dark' ? '' : null) + for (const name of ['budget.xlsx', 'legacy.xls', 'values.csv', 'values.tsv']) { + 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 diff --git a/packages/client/ui-sidebar-documentpreview/README.i18n.yaml b/packages/client/ui-sidebar-documentpreview/README.i18n.yaml index cacfb2117c..0bf15ef293 100644 --- a/packages/client/ui-sidebar-documentpreview/README.i18n.yaml +++ b/packages/client/ui-sidebar-documentpreview/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-sidebar-documentpreview/README.md -README.md: 5507bd89f07bfd6225d4ecc1bc4500ceb2e30fe8 -README.zh.md: eec96c28654ae01aca6b89862d503af6c12296e8 +README.md: 7354c47c7d7385085156424bdef63a7ab6c2e566 +README.zh.md: d6985c7d8ab3650ad0c7410970ad08070c48f65b diff --git a/packages/client/ui-sidebar-documentpreview/README.md b/packages/client/ui-sidebar-documentpreview/README.md index 5507bd89f0..7354c47c7d 100644 --- a/packages/client/ui-sidebar-documentpreview/README.md +++ b/packages/client/ui-sidebar-documentpreview/README.md @@ -66,7 +66,7 @@ Initial reads, additional pages, and HTML/PDF/image preparation share the ongoin ## Excel preview -Open `.xlsx`, `.xls`, `.csv`, and `.tsv` directly in the browser with worksheet tabs, cell selection, copying, and a read-only formula bar. XLSX retains fonts, solid fills, borders, number formats, rich text, merged cells, row and column sizes, hidden rows/columns/sheets, and frozen headings. XLS retains saved values, formulas, number formats, merges, and available row/column metadata; fonts, borders, and frozen panes are unsupported. Workbooks display saved formula results without recalculating; missing results remain blank, and a compact formula-bar warning marks workbooks whose displayed results may be incomplete or inaccurate. The workbook viewport follows preview pane size changes without reloading its sheets or selection. Spreadsheet preview does not call the Office conversion service. +Open `.xlsx`, `.xls`, `.csv`, and `.tsv` directly in the browser with worksheet tabs, cell selection, copying, and a read-only formula bar. XLSX retains fonts, solid fills, borders, number formats, rich text, merged cells, row and column sizes, hidden rows/columns/sheets, and frozen headings. XLS retains saved values, formulas, number formats, merges, and available row/column metadata; fonts, borders, and frozen panes are unsupported. Workbooks display saved formula results without recalculating; missing results remain blank, and a compact formula-bar warning marks workbooks whose displayed results may be incomplete or inaccurate. The workbook viewport fills the preview pane and follows its size changes without reloading its sheets or selection. Worksheet tabs and zoom controls remain visible; selection statistics and inactive worksheet menu arrows are hidden. Frozen headings remain fixed while scrolling, without freeze dividers or drag handles. Spreadsheet preview does not call the Office conversion service. XLSX preview omits DrawingML parts from an in-memory copy before parsing and ignores worksheet drawing references. The original file and worksheet XML remain unchanged. A notice above the table lists detected charts, images, shapes, and conditional formatting that are not displayed, and recommends opening the workbook in a system application. Files without these detected features have no notice; formula warnings remain separate. @@ -82,7 +82,7 @@ Configure `excel` on the same `ui-sidebar-documentpreview` entry. These limits c The lazy Excel chunk bundles FortuneSheet, ExcelJS for XLSX, SheetJS CE for XLS, and PapaParse for CSV/TSV. Package-local, React-independent adapters map parser output directly to FortuneSheet cells and share cell formatting and initial selection. Third-party license texts remain in the published chunk; SheetJS CE retains its Apache-2.0 terms. Each parse owns a disposable Worker and transfers a copy of retained file bytes; replacement, unmount, failure, and timeout terminate that Worker. The stylesheet is scoped to the Excel preview. Charts, drawings/images, pivot tables, conditional formatting, editing, recalculation, and export are unsupported; font availability, Excel column-width approximation, and theme-tint approximation can affect fidelity. Hyperlinks display as text without loading their targets. ExcelJS decodes entity spellings in cached XLSX string formula results again; a saved literal `<` is displayed as `<`. -The read-only formula bar displays formulas and cell text literally. Copying preserves an HTML table with escaped cell contents, including saved formula results. The [FortuneSheet patch rationale](../../../.agents/notes/implemented/feature/2026-09-16-browser-excel-preview.md) explains the requirements for retaining these behaviors and worksheet selection when upgrading the dependency. +The read-only formula bar displays formulas and cell text literally on one line, with horizontal scrolling for long content. Copying preserves an HTML table with escaped cell contents, including saved formula results. The [FortuneSheet patch rationale](../../../.agents/notes/implemented/feature/2026-09-16-browser-excel-preview.md) explains the requirements for retaining these behaviors and worksheet selection when upgrading the dependency. The pinned [ExcelJS patch](../../../patches/exceljs@4.4.0.patch) resolves the workbook, styles, shared strings, worksheets, comments, Tables, and VML through package relationships, including absolute and relative targets and ASCII case-equivalent part names, and recognizes SpreadsheetML and VML names by namespace URI. Strict OOXML SpreadsheetML and relationship URIs map to the same supported preview features; this is not full Strict conformance. XML parts accept UTF-8 and either byte order of UTF-16; CDATA contributes literal text. Drawing and conditional-format notices follow relationships regardless of part directories. Unreferenced `xl/drawings/*.xml` parts and their relationship files are also omitted, without adding notices; comment VML remains. Missing referenced parts and ambiguous case-equivalent ZIP entries fail the preview. Comments and Table metadata survive parsing but have no dedicated preview controls. The patch covers the Node sources and `dist/exceljs.js`; its browser entry selects that patched bundle. Dependency upgrades must preserve both entry paths and pass the [independent-writer regressions and fuzz diagnostics](tests/fuzz/README.md). diff --git a/packages/client/ui-sidebar-documentpreview/README.zh.md b/packages/client/ui-sidebar-documentpreview/README.zh.md index eec96c2865..d6985c7d8a 100644 --- a/packages/client/ui-sidebar-documentpreview/README.zh.md +++ b/packages/client/ui-sidebar-documentpreview/README.zh.md @@ -66,7 +66,7 @@ PNG、JPEG、GIF、WebP、BMP、ICO 和 SVG 通过 Blob URL 在 `` 静态 ## Excel 预览 -直接在浏览器中打开 `.xlsx`、`.xls`、`.csv` 和 `.tsv`,支持工作表标签、单元格选择、复制和只读公式栏。XLSX 保留字体、纯色填充、边框、数字格式、富文本、合并单元格、行列尺寸、隐藏行列及工作表,以及冻结标题。XLS 保留已保存的值、公式、数字格式、合并及可用的行列元数据;不支持字体、边框和冻结窗格。工作簿显示已保存的公式结果而不重新计算;缺少的结果保持为空,公式栏旁的紧凑提示会标记显示结果可能不完整或不准确的工作簿。工作簿视图随预览面板尺寸变化自动调整,无需重新加载工作表或选择状态。表格预览不调用 Office 转换服务。 +直接在浏览器中打开 `.xlsx`、`.xls`、`.csv` 和 `.tsv`,支持工作表标签、单元格选择、复制和只读公式栏。XLSX 保留字体、纯色填充、边框、数字格式、富文本、合并单元格、行列尺寸、隐藏行列及工作表,以及冻结标题。XLS 保留已保存的值、公式、数字格式、合并及可用的行列元数据;不支持字体、边框和冻结窗格。工作簿显示已保存的公式结果而不重新计算;缺少的结果保持为空,公式栏旁的紧凑提示会标记显示结果可能不完整或不准确的工作簿。工作簿视图填满预览面板,并随面板尺寸变化自动调整,无需重新加载工作表或选择状态。保留工作表标签和缩放控件,隐藏选区统计栏及无效的工作表菜单箭头。冻结标题在滚动时保持固定,不显示冻结分隔线和拖拽条。表格预览不调用 Office 转换服务。 XLSX 预览在解析前从内存副本中移除 DrawingML 部件,并忽略工作表的绘图引用。原文件和工作表 XML 保持不变。表格上方的提示列出实际检测到但未展示的图表、图片、形状和条件格式,并建议使用系统应用打开工作簿。未检测到这些内容的文件不显示此提示;公式提示保持独立。 @@ -82,7 +82,7 @@ CSV 和 TSV 默认使用表格查看器,也可选择纯文本。两者分别 惰性 Excel chunk 打包 FortuneSheet、用于 XLSX 的 ExcelJS、用于 XLS 的 SheetJS CE,以及用于 CSV/TSV 的 PapaParse。包内独立于 React 的适配层将解析结果直接映射为 FortuneSheet 单元格,并复用单元格格式化和初始选区。第三方许可证文本保留在发布的 chunk 中;SheetJS CE 保留其 Apache-2.0 条款。每次解析拥有一个独立可释放的 Worker,并传输所保留文件字节的副本;内容替换、卸载、失败或超时都会终止该 Worker。样式表仅作用于 Excel 预览区域。暂不支持图表、绘图/图片、数据透视表、条件格式、编辑、重新计算和导出;字体可用性、Excel 列宽近似和主题色明暗近似会影响保真度。超链接显示为文本,不加载目标地址。ExcelJS 会再次解码 XLSX 字符串公式缓存结果中的实体写法;保存的字面文本 `<` 会显示为 `<`。 -只读公式栏按字面显示公式和单元格文本。复制时保留 HTML 表格,并转义单元格内容,包括已保存的公式结果。[FortuneSheet 补丁决策](../../../.agents/notes/implemented/feature/2026-09-16-browser-excel-preview.zh.md)说明升级依赖时保留这些行为与工作表选区的要求。 +只读公式栏以单行按字面显示公式和单元格文本,长内容可横向滚动。复制时保留 HTML 表格,并转义单元格内容,包括已保存的公式结果。[FortuneSheet 补丁决策](../../../.agents/notes/implemented/feature/2026-09-16-browser-excel-preview.zh.md)说明升级依赖时保留这些行为与工作表选区的要求。 固定版本的 [ExcelJS 补丁](../../../patches/exceljs@4.4.0.patch)通过包内关系解析工作簿、样式、共享字符串、工作表、批注、Table 和 VML,支持绝对及相对目标,以及 ASCII 大小写等价的部件名,并按命名空间 URI 识别 SpreadsheetML 和 VML 名称。Strict OOXML 的 SpreadsheetML 和关系 URI 映射到相同的已支持预览功能;这不代表完整支持 Strict 标准。XML 部件支持 UTF-8 及两种字节序的 UTF-16;CDATA 按字面文本读取。绘图和条件格式提示按关系查找内容,不依赖部件目录。未被引用的 `xl/drawings/*.xml` 部件及其关系文件也会被移除,但不会产生提示;批注 VML 保持不变。缺失引用部件或存在大小写等价的歧义 ZIP 条目会使预览失败。解析保留批注和 Table 元数据,但预览器没有专门展示它们的控件。补丁同时覆盖 Node 源码和 `dist/exceljs.js`,浏览器入口选择已修补的 bundle。升级依赖时必须保留两个入口的行为,并通过[独立写入器回归测试和 fuzz 诊断](tests/fuzz/README.zh.md)。 diff --git a/packages/client/ui-sidebar-documentpreview/src/client/excel/ExcelBody.module.css b/packages/client/ui-sidebar-documentpreview/src/client/excel/ExcelBody.module.css index 705bee6f1b..f92d535dcf 100644 --- a/packages/client/ui-sidebar-documentpreview/src/client/excel/ExcelBody.module.css +++ b/packages/client/ui-sidebar-documentpreview/src/client/excel/ExcelBody.module.css @@ -16,6 +16,34 @@ color-scheme: light; } +/* Hide editing handles and redundant controls within the read-only preview. */ +.workbook :global(.fortune-stat-area), +.workbook :global(.luckysheet-sheets-item-function), +.workbook :global(.fortune-cols-freeze-handle), +.workbook :global(.fortune-rows-freeze-handle) { + display: none; +} + +.workbook :global(.luckysheet-sheet-area div.luckysheet-sheets-item) { + padding-right: 5px; +} + +.workbook :global(.fortune-sheettab-container) { + max-width: 100%; +} + +.workbook :global(.fortune-fx-input-container) { + min-width: 0; +} + +.workbook :global(.fortune-fx-input) { + min-width: 0; + overflow-x: auto; + overflow-y: hidden; + white-space: nowrap; + scrollbar-width: none; +} + .notice { display: flex; flex: none; diff --git a/packages/client/ui-sidebar-documentpreview/tests/excel-fixture.ts b/packages/client/ui-sidebar-documentpreview/tests/excel-fixture.ts index 5f2f84de81..61bc77f1cb 100644 --- a/packages/client/ui-sidebar-documentpreview/tests/excel-fixture.ts +++ b/packages/client/ui-sidebar-documentpreview/tests/excel-fixture.ts @@ -95,3 +95,17 @@ export async function meetingMinutesFixture(): Promise> workbook.addWorksheet('填写说明') return new Uint8Array(await workbook.xlsx.writeBuffer()) } + +/** Build scrollable sheets with each combination of frozen rows and columns. */ +export async function excelFreezeFixture(): Promise> { + const workbook = new ExcelJS.Workbook() + for (const [name, xSplit, ySplit] of [['Both', 1, 2], ['Rows', 0, 2], ['Columns', 1, 0], ['None', 0, 0]] as const) { + const sheet = workbook.addWorksheet(name, { views: xSplit || ySplit ? [{ state: 'frozen', xSplit, ySplit }] : [] }) + sheet.columns = Array.from({ length: 12 }, () => ({ width: 16 })) + for (let row = 1; row <= 80; row += 1) { + sheet.getRow(row).height = 24 + sheet.getRow(row).values = Array.from({ length: 12 }, (_, column) => `R${row}C${column + 1}`) + } + } + return new Uint8Array(await workbook.xlsx.writeBuffer()) +} diff --git a/patches/@fortune-sheet__core@1.0.4.patch b/patches/@fortune-sheet__core@1.0.4.patch index 64457ac131..b0cbd57e3f 100644 --- a/patches/@fortune-sheet__core@1.0.4.patch +++ b/patches/@fortune-sheet__core@1.0.4.patch @@ -1,5 +1,5 @@ diff --git a/dist/index.esm.js b/dist/index.esm.js -index 5c9820b1a261305d500e69eeaf350821872beaf4..db803c7d59c0951198157c0e6c46aad3af3bfadc 100644 +index 5c9820b1a261305d500e69eeaf350821872beaf4..c0e1bf786c0333fa56edc007c3fd700d834fa40f 100644 --- a/dist/index.esm.js +++ b/dist/index.esm.js @@ -62866,7 +62866,7 @@ function rangeValueToHtml(ctx, sheetId, ranges) { @@ -11,8 +11,16 @@ index 5c9820b1a261305d500e69eeaf350821872beaf4..db803c7d59c0951198157c0e6c46aad3 } else { var _style = ""; if (borderInfoCompute && borderInfoCompute["".concat(r, "_").concat(c)]) { +@@ -73869,6 +73869,7 @@ var Canvas = /*#__PURE__*/function () { + }, { + key: "drawFreezeLine", + value: function drawFreezeLine(_ref2) { ++ if (this.sheetCtx.allowEdit === false) return; + var horizontalTop = _ref2.horizontalTop, + verticalLeft = _ref2.verticalLeft; + var renderCtx = this.canvasElement.getContext("2d"); diff --git a/dist/index.js b/dist/index.js -index 04113a18dee9feee28e9434ee3023626c800e7d3..8304933d13c14e87d8f0c4e84d0a7a02546b0b5b 100644 +index 04113a18dee9feee28e9434ee3023626c800e7d3..8e68fb2436e7662e3c26ba0308e70b7be1567767 100644 --- a/dist/index.js +++ b/dist/index.js @@ -62876,7 +62876,7 @@ function rangeValueToHtml(ctx, sheetId, ranges) { @@ -24,3 +32,11 @@ index 04113a18dee9feee28e9434ee3023626c800e7d3..8304933d13c14e87d8f0c4e84d0a7a02 } else { var _style = ""; if (borderInfoCompute && borderInfoCompute["".concat(r, "_").concat(c)]) { +@@ -73879,6 +73879,7 @@ var Canvas = /*#__PURE__*/function () { + }, { + key: "drawFreezeLine", + value: function drawFreezeLine(_ref2) { ++ if (this.sheetCtx.allowEdit === false) return; + var horizontalTop = _ref2.horizontalTop, + verticalLeft = _ref2.verticalLeft; + var renderCtx = this.canvasElement.getContext("2d"); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9b12381c71..89af30c53c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12,7 +12,7 @@ overrides: patchedDependencies: '@earendil-works/pi-ai@0.85.1': b9bcce474fb2ac44633dff0fa722816a5bff5451b4575d5874035ea14ba70a4f '@electron/osx-sign@1.3.3': 280e7b2816caf01423097d24e79107a66afeb563a348630ce9928ae8499a5861 - '@fortune-sheet/core@1.0.4': 488dad2a7648b7d4df31f6bd776b7849fd0fb85be8f1acf268efff758caba3c0 + '@fortune-sheet/core@1.0.4': 7d369e6feca3c674aeb089624e38e0a79ad61e695d6bdae89711876cbd178f24 '@fortune-sheet/react@1.0.4': c17549e117c8fb99f408497c1e84292c21f25516fb762a32518f91b4d9ab0f04 '@yao-pkg/pkg@6.21.0': 75891fc22b277c145cafeab83e41baf4a70f5b8fe6dc0801c6a0b922d7ee88c7 exceljs@4.4.0: c1bf8ac8f319dab382f08e3a669b715e691d46c703ac5a4163eb383fbedd1d48 @@ -5039,7 +5039,7 @@ importers: version: link:../../util/workspace-path '@fortune-sheet/core': specifier: 1.0.4 - version: 1.0.4(patch_hash=488dad2a7648b7d4df31f6bd776b7849fd0fb85be8f1acf268efff758caba3c0) + version: 1.0.4(patch_hash=7d369e6feca3c674aeb089624e38e0a79ad61e695d6bdae89711876cbd178f24) '@fortune-sheet/react': specifier: 1.0.4 version: 1.0.4(patch_hash=c17549e117c8fb99f408497c1e84292c21f25516fb762a32518f91b4d9ab0f04)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -22472,7 +22472,7 @@ snapshots: bessel: 1.0.2 jstat: 1.9.6 - '@fortune-sheet/core@1.0.4(patch_hash=488dad2a7648b7d4df31f6bd776b7849fd0fb85be8f1acf268efff758caba3c0)': + '@fortune-sheet/core@1.0.4(patch_hash=7d369e6feca3c674aeb089624e38e0a79ad61e695d6bdae89711876cbd178f24)': dependencies: '@fortune-sheet/formula-parser': 0.2.13 dayjs: 1.11.21 @@ -22488,7 +22488,7 @@ snapshots: '@fortune-sheet/react@1.0.4(patch_hash=c17549e117c8fb99f408497c1e84292c21f25516fb762a32518f91b4d9ab0f04)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: - '@fortune-sheet/core': 1.0.4(patch_hash=488dad2a7648b7d4df31f6bd776b7849fd0fb85be8f1acf268efff758caba3c0) + '@fortune-sheet/core': 1.0.4(patch_hash=7d369e6feca3c674aeb089624e38e0a79ad61e695d6bdae89711876cbd178f24) '@types/regenerator-runtime': 0.13.8 immer: 9.0.21 lodash: 4.18.1 From ccaa0dc11c949080b7a434a8f95c6e29d09fb371 Mon Sep 17 00:00:00 2001 From: Turtle Date: Wed, 23 Sep 2026 16:11:30 +0800 Subject: [PATCH 15/28] fix(plugin-manager): bound pnpm runs so a silent child cannot hold the profile lock (#4982) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(plugin-manager): bound pnpm runs so a silent child cannot hold the profile lock A pnpm child that prints its completion line and then never exits holds the profile write lock until the host is restarted, so every other session and host on that profile fails after its own lock timeout (issue #4981). execa resolves its promise only once the piped stdio has ended, so a run now completes on the process's own exit and drains the pipes under a bound instead of awaiting their end. A service run whose captured output stays silent for idleTimeoutMs is terminated, reports timedOut alongside its exit status, and is not retried on the next registry; a run with inherited descriptors captures nothing and is left unbounded for its operator. * test(plugin-manager): type the mocked child and await the drain descendants The inherited-descriptor fixture no longer needs its legacy `as unknown` cast, because the mocked child is typed for the fields the operations read; the retired baseline entry is pruned (verify-no-unknown-casts). The drain specs wait for the killed descendant to exit before removing its directory: Windows refuses to delete a directory a live process still holds. * test(plugin-manager): stop the drain descendant deterministically The descendant that holds the pipes now ends on a stop file the teardown writes, and on its own after a minute, so the fixture leaves no process behind when a kill cannot be delivered. Windows stops it through taskkill, and the spec waits for the exit before removing the directory it ran in. * test(plugin-manager): run the drain descendant from its own script file Windows cannot carry a nested `-e` script through another spawn argument: the descendant failed to start there, so the fixture asserted on a process that was already gone. The descendant now runs from a script file the fixture writes. * test(plugin-manager): cover the bounded drain everywhere and stage the real descendant on POSIX Windows does not stage a descendant that keeps the inherited pipes open past the direct child's exit, so the real-process cases run on POSIX only. The mocked child now models execa's own completion rule — its promise settles only once both pipes end — which covers the bounded drain on every platform and still fails without the raw-exit race. * test(plugin-manager): cover the drain failure path without a child process An output consumer failure that the bounded drain cuts short is staged with the mocked child as well, so the per-file coverage gate also holds where the real-process cases are skipped. * fix(plugin-manager): terminate the whole tree of a stalled run A lifecycle script is a child of pnpm, so signalling pnpm alone left an approved postinstall writing into the profile after the operation reported a timeout and released the lock. A stalled run now stops its process group (or Windows process tree) and waits for it to disappear before the caller restores the files. The cut tail's close is told apart from a reading failure by where it happened, so a failure from before the cut still surfaces. * docs(plugin-manager): regenerate the config catalog after the rebase The English catalog gains `idleTimeoutMs` from the regenerator, and the Chinese counterpart carries the same interface line so the pair stays consistent. * fix(plugin-manager): terminate a run's tree through execa, keeping exit cleanup Spawning a service run with `detached` made execa skip the handler that kills the child when the manager exits, so a crashed manager left the pnpm tree running in the profile. `killDescendants` spawns the run as its own process group while keeping that cleanup, and it addresses the tree on every kill path, including cancellation. The module now only decides the probe target and waits for quiescence. --- ...09-15-guided-plugin-installation.i18n.yaml | 4 +- .../2026-09-15-guided-plugin-installation.md | 2 +- ...026-09-15-guided-plugin-installation.zh.md | 2 +- .../2026-09-23-bounded-pnpm-runs.i18n.yaml | 6 + .../bug-fix/2026-09-23-bounded-pnpm-runs.md | 59 +++++++ .../2026-09-23-bounded-pnpm-runs.zh.md | 59 +++++++ docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 2 + docs/config-catalog.zh.md | 2 + docs/event-producer-consumer.i18n.yaml | 4 +- docs/event-producer-consumer.md | 6 +- docs/event-producer-consumer.zh.md | 6 +- docs/subsystems/boot.i18n.yaml | 4 +- docs/subsystems/boot.md | 2 +- docs/subsystems/boot.zh.md | 2 +- packages/boot/plugin-manager/README.i18n.yaml | 4 +- packages/boot/plugin-manager/README.md | 3 +- packages/boot/plugin-manager/README.zh.md | 3 +- packages/boot/plugin-manager/src/index.ts | 34 +++- .../boot/plugin-manager/src/operations.ts | 143 +++++++++++++++-- packages/boot/plugin-manager/src/run-tree.ts | 70 ++++++++ packages/boot/plugin-manager/src/types.ts | 2 + .../boot/plugin-manager/tests/manager.spec.ts | 72 +++++++++ .../tests/operations-process.spec.ts | 141 ++++++++++++++++ .../plugin-manager/tests/operations.spec.ts | 150 +++++++++++++++++- .../plugin-manager/tests/run-tree.spec.ts | 47 ++++++ .../extensions/tool-cordis/src/api-catalog.ts | 2 +- scripts/no-unknown-casts.baseline.json | 1 - 28 files changed, 785 insertions(+), 51 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-09-23-bounded-pnpm-runs.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-09-23-bounded-pnpm-runs.md create mode 100644 .agents/notes/implemented/bug-fix/2026-09-23-bounded-pnpm-runs.zh.md create mode 100644 packages/boot/plugin-manager/src/run-tree.ts create mode 100644 packages/boot/plugin-manager/tests/operations-process.spec.ts create mode 100644 packages/boot/plugin-manager/tests/run-tree.spec.ts diff --git a/.agents/notes/implemented/architecture/2026-09-15-guided-plugin-installation.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-15-guided-plugin-installation.i18n.yaml index a5b7444619..e1a4357fcd 100644 --- a/.agents/notes/implemented/architecture/2026-09-15-guided-plugin-installation.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-15-guided-plugin-installation.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-15-guided-plugin-installation.md -2026-09-15-guided-plugin-installation.md: 451b1f316eebfdce9c9efcffbf48202177d04343 -2026-09-15-guided-plugin-installation.zh.md: 0c228695828238b4dbfdae7d2153e0358e92360c +2026-09-15-guided-plugin-installation.md: 68bd519b7cb4342c42761dfaef53940c83ceb2d8 +2026-09-15-guided-plugin-installation.zh.md: f3cbefedeb151d37078b55140635b69516fafed2 diff --git a/.agents/notes/implemented/architecture/2026-09-15-guided-plugin-installation.md b/.agents/notes/implemented/architecture/2026-09-15-guided-plugin-installation.md index 451b1f316e..68bd519b7c 100644 --- a/.agents/notes/implemented/architecture/2026-09-15-guided-plugin-installation.md +++ b/.agents/notes/implemented/architecture/2026-09-15-guided-plugin-installation.md @@ -14,7 +14,7 @@ The install dialog put a spec straight into `pnpm add` and showed pnpm's termina **A failed or cancelled installation restores the profile files.** `installBundle` snapshots `package.json` and `pnpm-lock.yaml` before pnpm runs and puts them back when pnpm fails, when the run is cancelled, or when the package pnpm added declares no bundle patch. This reverses, for installations only, the [manager's decision](2026-09-14-current-profile-plugin-management.md) to retain partial changes: an installation that did not produce a usable bundle must not leave a dependency the page cannot show. Removals keep that decision. Downloaded files can stay under `node_modules` and the pnpm store. -**A failed run is classified where the facts are.** `classifyInstallFailure` reads how the run ended and what pnpm printed — its `ERR_PNPM_*` codes and Node's errno names — into `packageResult.kind`. The client shows the kind as one line and folds pnpm's output behind the details. Parsing the log is confined to this one function with a fixture-driven test. +**A failed run is classified where the facts are.** `classifyInstallFailure` reads how the run ended and what pnpm printed — its `ERR_PNPM_*` codes and Node's errno names — into `packageResult.kind`. A run the manager terminated is classified `timeout` whatever exit status the signal left behind, so installation restores the files and removal reports failure instead of reading it as a success ([run bounds](../bug-fix/2026-09-23-bounded-pnpm-runs.md)). The client shows the kind as one line and folds pnpm's output behind the details. Parsing the log is confined to this one function with a fixture-driven test. **GitHub connection failures have a short budget before installation.** The Host runs a read-only `git ls-remote` check with the profile's Git and proxy configuration and a default `githubConnectionTimeoutMs` of 5000 ms. Credential helpers and prompts are disabled for this check, so authentication cannot consume the connection budget. Only network failures and timeouts prevent pnpm from starting; pnpm still owns authentication, HTTPS-to-SSH fallback, ref resolution and the full installation. The budget does not limit downloads or builds. The existing failure kind and `failedAt: 'spec-host'` let the Client show a connection error or timeout and offer a domestic registry for a replacement package spec. Cancellation and manager disposal terminate the check and its descendants before settling. diff --git a/.agents/notes/implemented/architecture/2026-09-15-guided-plugin-installation.zh.md b/.agents/notes/implemented/architecture/2026-09-15-guided-plugin-installation.zh.md index 0c22869582..f3cbefedeb 100644 --- a/.agents/notes/implemented/architecture/2026-09-15-guided-plugin-installation.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-15-guided-plugin-installation.zh.md @@ -14,7 +14,7 @@ Status: implemented **失败或被取消的安装恢复 profile 文件。** `installBundle` 在 pnpm 运行前快照 `package.json` 与 `pnpm-lock.yaml`,在 pnpm 失败、运行被取消、或 pnpm 装入的包没有声明组合包 patch 时把它们放回去。这只针对安装反转了[管理器的决定](2026-09-14-current-profile-plugin-management.zh.md)中"保留部分改动"的部分:没有产出可用组合包的安装,不能留下一个页面无法显示的依赖。删除仍沿用那个决定。已下载文件可能留在 `node_modules` 与 pnpm 缓存中。 -**失败在事实所在处分类。** `classifyInstallFailure` 依据运行的结束方式和 pnpm 的输出——它的 `ERR_PNPM_*` 码和 Node 的 errno 名——给出 `packageResult.kind`。客户端把 kind 显示成一句话,把 pnpm 输出折叠进详情。对日志的解析只存在于这一个函数,用夹具驱动的测试钉住。 +**失败在事实所在处分类。** `classifyInstallFailure` 依据运行的结束方式和 pnpm 的输出——它的 `ERR_PNPM_*` 码和 Node 的 errno 名——给出 `packageResult.kind`。被管理器终止的运行,不论信号留下什么退出状态都归类为 `timeout`,因此安装会还原文件,删除则报告失败而不会读成成功([运行上界](../bug-fix/2026-09-23-bounded-pnpm-runs.zh.md))。客户端把 kind 显示成一句话,把 pnpm 输出折叠进详情。对日志的解析只存在于这一个函数,用夹具驱动的测试钉住。 **GitHub 连接失败在安装前有单独的短时限。** 宿主通过只读的 `git ls-remote` 检查连接,沿用 profile 的 Git 与代理配置,`githubConnectionTimeoutMs` 默认为 5000 毫秒。这次检查禁用凭据助手和认证提示,避免认证过程占用连接预算。只有网络失败与超时会阻止 pnpm 启动;认证、HTTPS 到 SSH 的回退、ref 解析及完整安装仍由 pnpm 负责。时限不约束下载或构建。已有的失败类型与 `failedAt: 'spec-host'` 让客户端显示连接失败或超时,并提供国内镜像供用户输入另一个包名。取消安装或销毁管理器会先终止检查及其子进程,再结束操作。 diff --git a/.agents/notes/implemented/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/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 76d9465922..4fbbb224f7 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: 146a7e58138ec4f1f92cc27dd2dc76632aaff45c -config-catalog.zh.md: 2e67775709d5f5c0e5b0c465be8c1b901fe6ad97 +config-catalog.md: d849123e4f8ac325b9b0b8845035c2bbc2e99741 +config-catalog.zh.md: b2ebd38a1131bcbd39dd8fa9215e6290b1e5fff4 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 146a7e5813..d849123e4f 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -2181,6 +2181,8 @@ export interface Config { inspectTimeoutMs?: number /** Maximum duration of the GitHub repository connection check before installation, in milliseconds. */ githubConnectionTimeoutMs?: number + /** Maximum time one captured package run may print nothing before the manager terminates it, in milliseconds. */ + idleTimeoutMs?: number /** The registry lookups and installations ask first, as an http(s) URL; absent, the one pnpm's own configuration names. */ registry?: string /** diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 2e67775709..b2ebd38a11 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -2183,6 +2183,8 @@ export interface Config { inspectTimeoutMs?: number /** Maximum duration of the GitHub repository connection check before installation, in milliseconds. */ githubConnectionTimeoutMs?: number + /** Maximum time one captured package run may print nothing before the manager terminates it, in milliseconds. */ + idleTimeoutMs?: number /** The registry lookups and installations ask first, as an http(s) URL; absent, the one pnpm's own configuration names. */ registry?: string /** diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml index 1a144727ec..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: 549c80e95ec9fdeb997bd11b392ad30ba9c78c6f -event-producer-consumer.zh.md: b079ddb558e51389c36e4dd77fdc03d3b52f48c3 +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 549c80e95e..54a5e36bb6 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -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 b079ddb558..248aacbafc 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -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/subsystems/boot.i18n.yaml b/docs/subsystems/boot.i18n.yaml index 4df5918665..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: 6ec2204ffe04fe91acb89fe16fa8f8d3332299e7 -boot.zh.md: f5fde00f0cd858450ecd7c75ca42b64e2f4348e4 +boot.md: 5faf482b0532058e480adf13bf7fee8bb882498d +boot.zh.md: 1f50732fd36a996c6bc15b406f7e9a4ac83bcb26 diff --git a/docs/subsystems/boot.md b/docs/subsystems/boot.md index 6ec2204ffe..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. diff --git a/docs/subsystems/boot.zh.md b/docs/subsystems/boot.zh.md index f5fde00f0c..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 自身拉取的主机。 diff --git a/packages/boot/plugin-manager/README.i18n.yaml b/packages/boot/plugin-manager/README.i18n.yaml index a27c97fc81..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: 803566c8650df7186c5344945ea0fe2359e8e70c -README.zh.md: 657e4587e2d813dd53b44fdb15a23f905f2c0dca +README.md: 24936df8b1be4e2997c2382e0d84fddd2dfc5345 +README.zh.md: 122ccc9a2796caf891b19a38c3c90e9d32a22fdc diff --git a/packages/boot/plugin-manager/README.md b/packages/boot/plugin-manager/README.md index 803566c865..24936df8b1 100644 --- a/packages/boot/plugin-manager/README.md +++ b/packages/boot/plugin-manager/README.md @@ -68,6 +68,7 @@ When pnpm 11 blocks dependency scripts, the failed installation reports every pe | `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. | ----- @@ -77,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 657e4587e2..122ccc9a27 100644 --- a/packages/boot/plugin-manager/README.zh.md +++ b/packages/boot/plugin-manager/README.zh.md @@ -68,6 +68,7 @@ pnpm 11 拦下依赖脚本时,失败的安装在 `pendingBuilds` 里报告 pro | `fallbackRegistries` | `['https://registry.npmmirror.com/']` | 前一个注册表不可达或没有该包副本时依次询问的注册表,http(s) URL;pnpm 自身的注册表只在它指向 npm 官方源或这里的某一个时才进入顺序。 | | `outputBytes` | `16384` | 每次操作返回的 pnpm 诊断字节上限;完整输出保留在返回的日志路径中。 | | `lockWaitMs` | `120000` | 获取 profile 写锁的最长等待毫秒数。 | +| `idleTimeoutMs` | `600000` | service 包操作允许持续无捕获输出的最长毫秒数,达到即被管理器终止;继承描述符运行的 `dsh plugin` 不受此上界约束。 | ----- @@ -77,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/src/index.ts b/packages/boot/plugin-manager/src/index.ts index 93d0287b9e..818b68ac16 100644 --- a/packages/boot/plugin-manager/src/index.ts +++ b/packages/boot/plugin-manager/src/index.ts @@ -46,6 +46,8 @@ export interface Config { inspectTimeoutMs?: number /** Maximum duration of the GitHub repository connection check before installation, in milliseconds. */ githubConnectionTimeoutMs?: number + /** Maximum time one captured package run may print nothing before the manager terminates it, in milliseconds. */ + idleTimeoutMs?: number /** The registry lookups and installations ask first, as an http(s) URL; absent, the one pnpm's own configuration names. */ registry?: string /** @@ -176,6 +178,7 @@ export class PluginManager extends TypertRemoteService { 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]), }) @@ -188,6 +191,7 @@ export class PluginManager extends TypertRemoteService { 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 @@ -205,6 +209,7 @@ export class PluginManager extends TypertRemoteService { 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), @@ -470,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) { @@ -573,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') } @@ -620,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 }) }, @@ -631,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/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 4eb78b96f6..e544d53d7d 100644 --- a/packages/boot/plugin-manager/src/types.ts +++ b/packages/boot/plugin-manager/src/types.ts @@ -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/manager.spec.ts b/packages/boot/plugin-manager/tests/manager.spec.ts index 5c372a07a8..d80755bba2 100644 --- a/packages/boot/plugin-manager/tests/manager.spec.ts +++ b/packages/boot/plugin-manager/tests/manager.spec.ts @@ -582,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') @@ -1202,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/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index f13510607a..b218954333 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -5599,7 +5599,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'PackageResult', - declaration: 'export interface PackageResult {\n exitCode: number;\n output: string;\n truncated: boolean;\n logPath: string;\n kind?: PluginInstallFailureKind;\n}', + declaration: 'export interface PackageResult {\n exitCode: number;\n output: string;\n truncated: boolean;\n logPath: string;\n kind?: PluginInstallFailureKind;\n timedOut?: boolean;\n}', }, { name: 'PeerAdmission', diff --git a/scripts/no-unknown-casts.baseline.json b/scripts/no-unknown-casts.baseline.json index 1a590ad1b9..eedd29799c 100644 --- a/scripts/no-unknown-casts.baseline.json +++ b/scripts/no-unknown-casts.baseline.json @@ -350,7 +350,6 @@ "e16418d396e5aa0dc24e4e963fd984fcd0d6641c2286f101fff3a87a160a4805": 1 }, "packages/boot/plugin-manager/tests/operations.spec.ts": { - "3f67668a2a47b23d156327c25aec9838ccfb824ad8e28234cf3e3ed78b7ca84b": 1, "657d57900ec8cdcfa9a4b11544ef4799d3a60c43c49e20f7aea13d3522ddbbee": 2 }, "packages/boot/plugin-manager/tests/tools.spec.ts": { From d550f1c4af24e7bd8f1fc6b25e741297e623703c Mon Sep 17 00:00:00 2001 From: imccyu Date: Wed, 23 Sep 2026 02:11:20 +0800 Subject: [PATCH 16/28] feat(conversation): initialize contexts from transient start matches --- docs/subsystems/conversation.i18n.yaml | 4 +- docs/subsystems/conversation.md | 10 +-- docs/subsystems/conversation.zh.md | 8 +-- .../conversation-nodes/request-prompt.ts | 1 + .../client/ui-conversation/README.i18n.yaml | 4 +- packages/client/ui-conversation/README.md | 2 +- packages/client/ui-conversation/README.zh.md | 2 +- .../src/client/contract/conversation.ts | 12 ++-- .../src/client/conversation/assembler.ts | 64 +++++++++---------- .../conversation-assembler.client.spec.ts | 43 +++++++++---- .../trajectory-request-header-definition.ts | 1 + 11 files changed, 81 insertions(+), 70 deletions(-) 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/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-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 2006e11e50..6c8859d5fd 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: 4823aa0a1ba5975c4efc8b6bdddd7ca8c57c068f -README.zh.md: 5ef8a1b6621bf8bab7e98cb18722a9aedda29101 +README.md: 83ca1b89b1b459d5d562b5d458987bbf91f04e9a +README.zh.md: c5c2299a934824227fb151d3ecd479893c48d231 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 4823aa0a1b..83ca1b89b1 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. 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. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 5ef8a1b662..c5c2299a93 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。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。 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/conversation/assembler.ts b/packages/client/ui-conversation/src/client/conversation/assembler.ts index d51216de32..cbebd2d66b 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 } } @@ -461,7 +454,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 +525,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 +536,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 +549,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 +565,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 +582,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 +604,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 +644,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/tests/conversation-assembler.client.spec.ts b/packages/client/ui-conversation/tests/conversation-assembler.client.spec.ts index 340263e65d..d017453adb 100644 --- a/packages/client/ui-conversation/tests/conversation-assembler.client.spec.ts +++ b/packages/client/ui-conversation/tests/conversation-assembler.client.spec.ts @@ -658,14 +658,17 @@ describe('ConversationNodeAssembler', () => { }) }) - it('rejects a transient event classified as a Context start', () => { - const definition: ConversationNodeDefinition = { - kind: 'invalid-transient-start', + it('initializes from a transient start and clears it when the attempt is withdrawn', () => { + const start = vi.fn((_context: ConversationNodeContext, match: ConversationMatch) => match.event.seq) + const definition: ConversationNodeDefinition = { + kind: 'transient-start', match: event => event.type === 'assistant/live-chunk' ? { id: 'one', role: 'start' } : null, - start: () => null, + start, update: context => context.state, + target: 'test', + buildViewNode: context => node(context, context.state ?? null), } const assembler = new ConversationNodeAssembler( new TestEventDefinitions([definition]), @@ -673,9 +676,13 @@ describe('ConversationNodeAssembler', () => { ) const delta = transientChunk(1, 1, 1, { type: 'text-delta', index: 0, text: 'abc' }) - expect(() => assembler.replaceWindow([delta], false)).toThrow( - 'conversation Context 23:invalid-transient-startone received a transient start Match', - ) + assembler.replaceWindow([delta], false) + assembler.flush() + expect(start).toHaveBeenCalledOnce() + expect([...testSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toBe(1) + assembler.settleAssistant(LlmAttemptId('test-attempt')) + assembler.flush() + expect([...testSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toBeNull() }) it('merges an older page and replays its affected Context once', () => { @@ -1537,12 +1544,14 @@ describe('ConversationNodeAssembler', () => { )).toThrow(/Definition "undefined-update" returned undefined from update/) }) - it('rejects a duplicate start before mutating the existing Context', () => { - const definition: ConversationNodeDefinition = { + it('updates the same Context for later start matches and reselects an earlier prepended start', () => { + const start = vi.fn((_context: ConversationNodeContext, match: ConversationMatch) => [match.event.seq]) + const update = vi.fn((context: { state: number[] }, match: ConversationMatch) => [...context.state, match.event.seq]) + const definition: ConversationNodeDefinition = { kind: 'single-start', match: event => (event.type as string) === 'command/run' ? { id: 'one', role: 'start' } : null, - start: (_context, match) => match.event.seq, - update: context => context.state, + start, + update, target: 'test', buildViewNode: context => node(context, context.state), } @@ -1555,10 +1564,16 @@ describe('ConversationNodeAssembler', () => { ], false) assembler.flush() - expect(() => assembler.append( + assembler.append( input(at(SessionSeq(2), 'command/run', { commandId: 'two', name: 'x' })), - )).toThrow(/received more than one start Match/) + ) assembler.flush() - expect([...testSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toBe(1) + expect(start).toHaveBeenCalledOnce() + expect(update).toHaveBeenCalledOnce() + expect([...testSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toEqual([1, 2]) + assembler.prepend([input(at(SessionSeq(0), 'command/run', { commandId: 'zero', name: 'x' }))], false) + assembler.flush() + expect(start).toHaveBeenCalledTimes(2) + expect([...testSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toEqual([0, 1, 2]) }) }) 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 From d3086e657131cf019cb5da3628de7fbd5d68c542 Mon Sep 17 00:00:00 2001 From: imccyu Date: Wed, 23 Sep 2026 02:12:41 +0800 Subject: [PATCH 17/28] feat(chat): present tools in preparing, start, and result stages --- packages/client/ui-chat/README.i18n.yaml | 4 +- packages/client/ui-chat/README.md | 2 +- packages/client/ui-chat/README.zh.md | 2 +- .../src/client/chat/ApprovalCommand.tsx | 2 +- .../ui-chat/src/client/chat/ChatGroupSeat.tsx | 10 ++-- .../src/client/contract/process-groups.ts | 2 + .../ui-chat/src/client/contract/snapshot.ts | 2 +- .../conversation-nodes/README.i18n.yaml | 4 +- .../src/client/conversation-nodes/README.md | 6 ++- .../client/conversation-nodes/README.zh.md | 6 ++- .../conversation-nodes/process-activity.ts | 5 +- .../conversation-nodes/process-groups.ts | 1 + .../src/client/conversation-nodes/tool.ts | 46 +++++++++++++++---- packages/client/ui-chat/src/client/index.ts | 2 +- packages/client/ui-chat/src/client/locale.ts | 22 +++++++++ .../src/client/model/tool-call-tree.ts | 1 + .../tests/approval-command.client.spec.tsx | 11 +++-- .../ui-chat/tests/chat-view.client.spec.tsx | 6 +-- ...nversation-node-definitions.client.spec.ts | 3 +- .../tests/process-groups.client.spec.ts | 2 +- .../tests/tool-call-tree.client.spec.ts | 6 +-- .../src/client/contract/records.ts | 23 ++++++++-- .../ui-conversation/src/client/index.ts | 2 +- .../ui-conversation/src/client/locales.ts | 2 + .../client/ui-deliverables/README.i18n.yaml | 4 +- packages/client/ui-deliverables/README.md | 2 + packages/client/ui-deliverables/README.zh.md | 2 + .../ui-deliverables/src/client/PresentRow.tsx | 14 +++++- .../ui-deliverables/src/client/locales.ts | 2 + .../tests/present-row.client.spec.tsx | 16 +++++-- packages/client/ui-skill/README.i18n.yaml | 4 +- packages/client/ui-skill/README.md | 2 + packages/client/ui-skill/README.zh.md | 2 + .../ui-skill/src/client/SkillRow.module.css | 3 +- .../client/ui-skill/src/client/SkillRow.tsx | 19 ++++++-- .../client/ui-skill/src/client/locales.ts | 2 + .../tests/browser-plugin.client.spec.ts | 2 + .../ui-skill/tests/skill-row.client.spec.tsx | 18 ++++++-- packages/client/ui-tool/README.i18n.yaml | 4 +- packages/client/ui-tool/README.md | 8 +++- packages/client/ui-tool/README.zh.md | 8 +++- .../ui-tool/src/client/contract/slots.ts | 25 +++++++--- packages/client/ui-tool/src/client/index.ts | 3 +- .../ui-tool/src/client/tool/ToolCallTree.tsx | 45 ++++++++++-------- .../tool/components/PreparingToolRow.tsx | 22 +++++++++ .../src/client/tool/components/ToolRow.tsx | 9 ++-- .../src/client/tool/models/raw-tool-call.ts | 8 ++-- .../src/client/tool/models/todo-diff-model.ts | 2 +- .../src/client/tool/models/tool-call-model.ts | 28 +++++++---- .../client/tool/toolviews/GenericToolCard.tsx | 5 +- .../tool/toolviews/ask-question-row.tsx | 2 +- .../src/client/tool/toolviews/bash-sample.tsx | 11 ++++- .../src/client/tool/toolviews/details-row.tsx | 4 +- .../src/client/tool/toolviews/todo-row.tsx | 6 +-- .../tests/ask-question-row.client.spec.tsx | 6 +-- .../tests/chat-ptc-subcalls.client.spec.tsx | 12 ++--- .../tests/coverage-tails.client.spec.tsx | 12 ++--- .../ui-tool/tests/details-row.client.spec.tsx | 2 +- .../ui-tool/tests/diff-card.client.spec.tsx | 16 +++---- .../ui-tool/tests/image-card.client.spec.tsx | 10 ++-- .../ui-tool/tests/read-card.client.spec.tsx | 16 +++---- .../ui-tool/tests/search-card.client.spec.tsx | 14 +++--- .../tests/terminal-card.client.spec.tsx | 14 +++--- .../ui-tool/tests/todo-row.client.spec.tsx | 8 ++-- .../tests/tool-call-tree.client.spec.tsx | 14 +++++- .../ui-tool/tests/tool-fixtures.client.ts | 4 +- .../ui-tool/tests/tool-row.client.spec.tsx | 10 ++-- .../tests/toolview-type-chain.client.spec.tsx | 6 +++ .../ui-tool/tests/web-card.client.spec.tsx | 12 ++--- .../client/ui-trajectory/README.i18n.yaml | 4 +- packages/client/ui-trajectory/README.md | 2 + packages/client/ui-trajectory/README.zh.md | 2 + .../client/ui-trajectory/src/client/layout.ts | 4 +- .../src/client/trajectory-tool-definition.ts | 14 ++++-- .../tests/layout.client.spec.tsx | 6 +-- .../tests/snapshot-builder.client.spec.ts | 2 +- .../ui-trajectory/tests/views.client.spec.tsx | 2 +- .../src/client/slot-catalog.ts | 13 ++---- .../extensions/ui-cordis/README.i18n.yaml | 4 +- packages/extensions/ui-cordis/README.md | 2 + packages/extensions/ui-cordis/README.zh.md | 2 + .../ui-cordis/src/client/CordisActionRow.tsx | 14 +++++- .../ui-cordis/src/client/CordisDefineRow.tsx | 12 ++++- .../src/client/CordisPreparingRow.tsx | 25 ++++++++++ .../ui-cordis/src/client/CordisRunRow.tsx | 12 ++++- .../ui-cordis/src/client/card-model.ts | 4 +- .../ui-cordis/src/client/locales.ts | 2 + .../ui-cordis/tests/card-model.client.spec.ts | 6 +-- .../tests/status-icons.client.spec.tsx | 27 +++++++++-- .../ui-cordis/tests/versioning.client.spec.ts | 4 +- 90 files changed, 547 insertions(+), 226 deletions(-) create mode 100644 packages/client/ui-tool/src/client/tool/components/PreparingToolRow.tsx create mode 100644 packages/extensions/ui-cordis/src/client/CordisPreparingRow.tsx diff --git a/packages/client/ui-chat/README.i18n.yaml b/packages/client/ui-chat/README.i18n.yaml index f30604f200..c9f58c8963 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: 1aa7d71b3f932a7a14ef6d23e9ace7eda444fc3b -README.zh.md: 2239234448e1e00fd395634de39a96c2a80a35f7 +README.md: c9765db137f1b4fc5740cdf167fcf313d0f47b7a +README.zh.md: 9a11c301e742d309048e0990037780f7c576ea34 diff --git a/packages/client/ui-chat/README.md b/packages/client/ui-chat/README.md index 1aa7d71b3f..c9765db137 100644 --- a/packages/client/ui-chat/README.md +++ b/packages/client/ui-chat/README.md @@ -85,7 +85,7 @@ 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. 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. diff --git a/packages/client/ui-chat/README.zh.md b/packages/client/ui-chat/README.zh.md index 2239234448..9a11c301e7 100644 --- a/packages/client/ui-chat/README.zh.md +++ b/packages/client/ui-chat/README.zh.md @@ -85,7 +85,7 @@ Chat 末尾为进行中的 Turn 控制行,且该轮尚无可见输入时,第 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 时重新绑定按键订阅,不重挂载容器。模式变化保留尺寸观察器,并复用整轮状态选择器。 过程组使用稳定的 `div` 布局盒子、滚动正文及不限高的内容盒子,后者报告正文内部的内容增长。业务样式必须适配组内及组边界间距,处理隐藏或空成员以及回答前的间距特例。CSS 变量不属于 Group Definition。 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 f2d7fa17d3..9c28562f44 100644 --- a/packages/client/ui-chat/src/client/chat/ChatGroupSeat.tsx +++ b/packages/client/ui-chat/src/client/chat/ChatGroupSeat.tsx @@ -31,6 +31,7 @@ type ProcessTitleActivity = ProcessActivity | 'thinking' interface LiveProcessTitle { readonly activity: ProcessTitleActivity readonly detail: string + readonly preparing: boolean } const PROCESS_ICONS: Record = { @@ -49,7 +50,7 @@ const PROCESS_ICONS: Record = { } 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 { @@ -73,7 +74,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 } @@ -99,9 +100,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 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..a5c0e41f8a 100644 --- a/packages/client/ui-chat/src/client/contract/process-groups.ts +++ b/packages/client/ui-chat/src/client/contract/process-groups.ts @@ -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 028cbb1caf..951890377a 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: 3a1660677e173461c353da1d40af3e8adae6d216 -README.zh.md: 024ea0f5da55826651611a7059a647b0c6478e08 +README.md: 5da692fefddcd3d3b01267c27f9f125472813d95 +README.zh.md: ac10f4f0789533eb71d165efe1cba55c196d12f7 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 3a1660677e..5da692fefd 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/README.md +++ b/packages/client/ui-chat/src/client/conversation-nodes/README.md @@ -122,6 +122,8 @@ A closed group's header names the first three categories from its ranked summary ### Group-title rules +A preparing Tool node uses its category's preparation label and its wire name as detail. 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 | @@ -210,7 +212,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. | @@ -256,6 +258,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 and show only the tool name. 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 024ea0f5da..ac10f4f078 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,6 +122,8 @@ description: "Chat 过程分组与活动摘要的明细业务规则。" ### 组标题规则 +准备中的 Tool 节点使用对应类别的准备文案,详情只有协议中的工具名。它计为一次调用,不解析参数,只渲染不可展开的一行。具名实时 delta 可以创建该节点;历史调用直接从 tool/call 开始,不重现准备过程。 + 以下文案描述已记录的活动,不保证操作成功;例如失败的读取仍参与「已读取文件」类别。 | 类别 | 未结束组的活动文案 | 已结束组的摘要文案 | @@ -210,7 +212,7 @@ description: "Chat 过程分组与活动摘要的明细业务规则。" | 本组中的调用记录 | 计数规则 | |---|---| -| 运行中调用、成功结果、失败结果,或保留原调用信息的中断结果 | 按记录的工具名计入对应类别一次;结算或失败不会增加或扣除次数。 | +| 准备中调用、运行中调用、成功结果、失败结果,或保留原调用信息的中断结果 | 按记录的工具名计入对应类别一次;结算或失败不会增加或扣除次数。 | | 重复的 `callId` | 只计首次出现,包括同时作为根调用和嵌套调用出现的情况;后续重复记录及其子树不再处理。 | | 父子调用的 `callId` 不同 | 父子分别按各自类别计数;父调用先于子调用,同级按记录顺序处理。 | | 同一工具使用不同 `callId` 多次调用 | 每次都计数,即使参数相同,或操作的是同一个终端、Session。 | @@ -256,6 +258,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..c234ad8abd 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 @@ -95,6 +95,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 +104,8 @@ 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' ? tool.name : liveToolDetail(tool.name, tool.argsRaw) runningTime = tool.time } counts.set(kind, (counts.get(kind) ?? 0) + 1) @@ -118,5 +120,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..51c482cd7c 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) } 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..efa28a82e9 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/tool.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/tool.ts @@ -1,11 +1,11 @@ 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 type { ChatNode, ToolChatData } from '../contract/chat-nodes.ts' import { CHAT_SYNTHETIC_SEQ_OFFSETS, chatNode } from './common.ts' declare module '../contract/chat-nodes.ts' { @@ -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,8 +267,10 @@ 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 } } @@ -257,11 +278,18 @@ export const toolDefinition: ConversationNodeDefinition = { }, buildViewNode: (context) => { const state = context.state ?? fallbackState(context) - if (state === undefined) return null - const projected = projectBlock(state.root, state, interruption(context)) + if (state === undefined) { + const previous = context.current.get('chat') as ChatNode<'tool-call'> | null | undefined + return previous === undefined || previous === null ? null : { ...previous, 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' + return chatNode(context, 'tool-call', anchor, { root: projected } satisfies ToolChatData, { + visibility: preparing && interruptedAt !== undefined ? 'hidden' : 'visible', + }) }, } 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..b5c69924ff 100644 --- a/packages/client/ui-chat/src/client/locale.ts +++ b/packages/client/ui-chat/src/client/locale.ts @@ -17,6 +17,17 @@ export const zh = { 'message.stepProcess.plan': '正在更新计划', 'message.stepProcess.questions': '等待你的操作', 'message.stepProcess.tools': '正在调用工具', + 'message.stepProcess.prepare.read': '准备读取文件', + '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.search': '已搜索代码', @@ -175,6 +186,17 @@ 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.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.search': 'Searched code', 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 dc70093fc5..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'], 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..0fa62a607b 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: [] } }, } } 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-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/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..2ab66377cf 100644 --- a/packages/client/ui-conversation/src/client/locales.ts +++ b/packages/client/ui-conversation/src/client/locales.ts @@ -282,6 +282,7 @@ export const zh = { 'bash.failed': '失败', 'bash.stopped': '已停止', 'row.running': '运行中', + 'row.preparing': '正在准备调用', 'row.failed': '失败', 'row.stopped': '已停止', 'row.input': '输入', @@ -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', diff --git a/packages/client/ui-deliverables/README.i18n.yaml b/packages/client/ui-deliverables/README.i18n.yaml index ec9b6a2d71..048f3628f2 100644 --- a/packages/client/ui-deliverables/README.i18n.yaml +++ b/packages/client/ui-deliverables/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-deliverables/README.md -README.md: c52db38df0c800f52de4eebb3a9692eabef6c9df -README.zh.md: 785c2215746e36ead16fe08b1a21b04a58ef418c +README.md: 8a0c51a8c88418862438342fc2a0faf41055dd00 +README.zh.md: 3c01c405d390e6fcd06665d9a53face55f5c39dc diff --git a/packages/client/ui-deliverables/README.md b/packages/client/ui-deliverables/README.md index c52db38df0..8a0c51a8c8 100644 --- a/packages/client/ui-deliverables/README.md +++ b/packages/client/ui-deliverables/README.md @@ -30,6 +30,8 @@ Mount this plugin alongside `ui-conversation` and the Host [workspace-changes](. ### Explicit deliveries +The `present` tool's preparing stage is one non-expandable document-icon row. It does not show filenames, create delivery cards, or enable file actions before the recorded call supplies arguments. + The Web `standard`, `ptc`, and `cordis` presets expose `present` for final files accessible through the Session filesystem, including files created through Bash. Call it with `files: [{ path, description? }]` after creating the files. The [present tool](../../deliverables/tool-present/README.md) owns file-count limits and Session declarations. A single delivery fills the row; multiple deliveries form a two-column grid, collapsed after four files. Cards show the basename, description or file type, and a shared native opening control. Clicking the card previews the file in the right Sidebar. The control opens the default application and lists associated applications, with file reveal last. Matching inline-code references also open Sidebar previews. Duplicate declarations use the last description before the closing reply. The `present` tool row retains its document icon and recorded result across progress, success, failure, and interruption. Native actions share pending state and publish progress or retryable errors on the card. Successful open or reveal feedback stays visible for five seconds, fades over 200ms, then restores the file description; failures remain visible until another attempt. Desktop metadata is loaded when cards mount and invalidated on connection replacement. Without a Host desktop, cards retain Sidebar preview but omit native controls; failed metadata reads offer Retry. Delivery cards and change review expose `deliverables.file.actions` and `deliverables.review.file.actions`, supplied by [ui-open-in-app](../ui-open-in-app/README.md). Association GET requests and native POST actions both validate the recorded file coordinates and Session filesystem mapping before using the Host desktop. diff --git a/packages/client/ui-deliverables/README.zh.md b/packages/client/ui-deliverables/README.zh.md index 785c221574..3c01c405d3 100644 --- a/packages/client/ui-deliverables/README.zh.md +++ b/packages/client/ui-deliverables/README.zh.md @@ -30,6 +30,8 @@ kind: "package-reference" ### 显式交付 +`present` 工具的准备阶段是带文档图标的不可展开单行。记录的调用提供参数前,该行不显示文件名、不创建交付卡片,也不启用文件操作。 + Web 的 `standard`、`ptc` 与 `cordis` preset 提供 `present`,用于声明会话文件系统可访问的最终文件,包括通过 Bash 创建的文件。创建后以 `files: [{ path, description? }]` 调用。[present 工具](../../deliverables/tool-present/README.zh.md)拥有文件数量限制和会话声明。单个交付占满一行,多个交付使用双列网格,超过四个时默认收起。卡片显示文件名、说明或文件类型,以及共享的原生打开控件。点击卡片会在右侧 Sidebar 预览文件。控件打开默认应用,并列出关联应用,文件定位始终放在最后。匹配的行内代码引用也打开 Sidebar 预览。重复声明使用收尾回复之前最近一次的说明。 `present` 工具行在执行、成功、失败和中断状态下保留文档图标及记录的结果。原生操作共享等待状态,并在卡片显示进度或可重试错误。打开或定位成功的反馈显示五秒后,在 200ms 内淡出并恢复文件说明;失败信息保留到下次尝试。卡片挂载时读取桌面信息,连接更换时清除缓存。Host 没有桌面时,卡片保留 Sidebar 预览并隐藏原生控件;元数据读取失败时提供重试。交付卡片和变更对比页通过 `deliverables.file.actions` 和 `deliverables.review.file.actions` 使用 [ui-open-in-app](../ui-open-in-app/README.zh.md) 提供的控件。关联应用 GET 查询与原生 POST 操作都会先校验记录中的文件坐标及会话文件系统映射,再调用 Host 桌面。 diff --git a/packages/client/ui-deliverables/src/client/PresentRow.tsx b/packages/client/ui-deliverables/src/client/PresentRow.tsx index 04c37677fd..29a41bf8be 100644 --- a/packages/client/ui-deliverables/src/client/PresentRow.tsx +++ b/packages/client/ui-deliverables/src/client/PresentRow.tsx @@ -25,7 +25,19 @@ function fileNames(raw: string): string { * @param props - tool call and localized status copy. * @returns a status row with a result disclosure. */ -export function PresentRow({ block, inspect, t }: PresentRowProps) { +export function PresentRow(props: PresentRowProps) { + return props.phase === 'preparing' ? : +} + +function PreparingPresentRow({ t, useDisclosure }: Extract) { + const { toggle } = useDisclosure() + return
+ } + open={false} expandable={false} onToggle={toggle} running /> +
+} + +function StartedPresentRow({ block, inspect, t }: Exclude) { const settled = 'kind' in block const state = !settled ? 'running' : block.error?.code === 'interrupted' ? 'stopped' : block.isError ? 'error' : 'ok' const args = (settled ? block.call?.argsRaw : block.argsRaw) ?? '' diff --git a/packages/client/ui-deliverables/src/client/locales.ts b/packages/client/ui-deliverables/src/client/locales.ts index 69e976fa06..eb2af5f30f 100644 --- a/packages/client/ui-deliverables/src/client/locales.ts +++ b/packages/client/ui-deliverables/src/client/locales.ts @@ -28,6 +28,7 @@ export const zh = { 'presented.file': '文件', 'row.title': '交付文件', 'row.running': '正在交付', + 'row.preparing': '准备交付', 'row.ok': '已交付', 'row.error': '交付失败', 'row.stopped': '已中断', @@ -90,6 +91,7 @@ export const en: Record = { 'presented.file': 'File', 'row.title': 'Present files', 'row.running': 'Delivering', + 'row.preparing': 'Preparing deliverables', 'row.ok': 'Delivered', 'row.error': 'Delivery failed', 'row.stopped': 'Interrupted', diff --git a/packages/client/ui-deliverables/tests/present-row.client.spec.tsx b/packages/client/ui-deliverables/tests/present-row.client.spec.tsx index 870d54df97..411d393964 100644 --- a/packages/client/ui-deliverables/tests/present-row.client.spec.tsx +++ b/packages/client/ui-deliverables/tests/present-row.client.spec.tsx @@ -2,17 +2,18 @@ /** Present UI derives statuses and details from durable tool records. */ import { cleanup, fireEvent, render } from '@testing-library/react' import { afterEach, expect, it, vi } from 'vitest' -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 { PresentRow } from '../src/client/PresentRow.tsx' +import { useDisclosure } from '@deepseek-ai/dsh-client-ui-chat/src/client/chat/use-disclosure.ts' import { en } from '../src/client/locales.ts' afterEach(cleanup) type Props = Parameters[0] -const running: RunningToolCall = { callId: 'p', name: 'present', argsRaw: '{"files":[{"path":"report.txt"}]}', turn: 1, step: 1, time: 1, subCalls: [] } +const running: StartedToolCall = { phase: 'start' as const, callId: 'p', name: 'present', argsRaw: '{"files":[{"path":"report.txt"}]}', turn: 1, step: 1, time: 1, subCalls: [] } const settled: ToolResultNode = { kind: 'tool-result', seq: 2, time: 2, callId: 'p', call: { name: 'present', argsRaw: running.argsRaw }, callTime: 1, content: [{ type: 'text', text: 'Presented report.txt (4 bytes)' }], isError: false, subCalls: [] } function props(block: Props['block'], inspect?: () => void): Props { - return { block, callId: 'p', toolName: 'present', openFile: vi.fn(), inspect, t: makeTranslate(en) } as Props + return { ...('kind' in block ? { phase: 'result' as const, block: block } : { phase: block.phase, block: block }), callId: 'p', toolName: 'present', openFile: vi.fn(), inspect, t: makeTranslate(en) } as Props } it('discloses the saved result and offers call inspection', () => { @@ -32,6 +33,15 @@ it('discloses the saved result and offers call inspection', () => { expect(row.getAttribute('aria-expanded')).toBe('false') }) +it('shows a non-expandable preparation before delivery arguments exist', () => { + const view = render() + expect(view.container.querySelector('[data-state="preparing"] svg')).not.toBeNull() + expect(view.queryByRole('button')).toBeNull() + expect(view.container.querySelector('pre')).toBeNull() +}) + it.each([ [running, 'running', 'Delivering'], [{ ...settled, isError: true }, 'error', 'Delivery failed'], diff --git a/packages/client/ui-skill/README.i18n.yaml b/packages/client/ui-skill/README.i18n.yaml index 5885d41e4e..c0203ad861 100644 --- a/packages/client/ui-skill/README.i18n.yaml +++ b/packages/client/ui-skill/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-skill/README.md -README.md: fdd8cb7fddb54cf73d897bb278086428d0e41214 -README.zh.md: 2090711d5f91bf1efba2f8e19d8d361f9ffb99d9 +README.md: 0234ce8fb6c2737ea242ac5590e471c3e4c91834 +README.zh.md: 5b039ec1c5bea818e6396f4896c05b8175beb08a diff --git a/packages/client/ui-skill/README.md b/packages/client/ui-skill/README.md index fdd8cb7fdd..0234ce8fb6 100644 --- a/packages/client/ui-skill/README.md +++ b/packages/client/ui-skill/README.md @@ -33,6 +33,8 @@ Ordinary-session candidates come from the `skills/list` Remote; the host serves ### The skill tool row +The `preparing` stage shows only the skill icon and title, with no requested skill name or expansion. The `start` and `result` stages use the recorded arguments and output described below; preparation does not consult the installed catalog. + A collapsed row renders the skill glyph, `Skill` title, and requested skill name on the same secondary font-size axis, line height, row height, and glyph scaling as ordinary Tool rows. Every lifecycle state retains the ordinary skill glyph; running calls carry the transcript shimmer, while failures replace the name with the first error line and interruptions retain their explicit status text. A settled row expands into a bounded `Instructions` card containing the exact durable tool output, with the standard trajectory `Inspect` affordance when available; this expanded card keeps its own typography. The row derives its name, lifecycle, and body only from the frozen call/result slice supplied by ui-tool, never from the current catalog, so replay stays stable when installed skills or their descriptions change. Hovering over `/name` highlights the entire reference. Clicking a known skill opens its provider-supplied `SKILL.md` path in the right Sidebar while keeping the token editable. An uncached click shares the per-Session catalog fetch and opens when it completes, retaining the clicked Session address. Preset changes, connection resets, and plugin disposal cancel pending previews; a later click fetches the current catalog again. Skills without a file path remain invocable but have no file preview. diff --git a/packages/client/ui-skill/README.zh.md b/packages/client/ui-skill/README.zh.md index 2090711d5f..5b039ec1c5 100644 --- a/packages/client/ui-skill/README.zh.md +++ b/packages/client/ui-skill/README.zh.md @@ -33,6 +33,8 @@ kind: "package-reference" ### skill 工具行 +`preparing` 阶段只显示技能图标与标题,不显示请求加载的技能名称,也不能展开。`start` 和 `result` 阶段使用下述记录参数与输出;准备阶段不查询已安装目录。 + 收起的行显示 skill 图标、`Skill` 标题与请求加载的 skill 名称,并与普通工具行使用相同的 secondary 字号轴、行高、行高度和图标缩放。所有生命周期状态都保留普通 skill 图标;运行中的调用带有 transcript(文本记录)的扫光效果,失败时以错误首行替换名称,中断则保留明确状态文本。已结算的行展开为一个尺寸受限的 `Instructions` 卡片,其中原样呈现持久化的工具输出;可用时还会提供标准轨迹的 `Inspect` 入口,展开卡片保留自身排版。该行的名称、生命周期与正文只派生自 ui-tool 提供的冻结调用/结果切片,绝不读取当前目录,因此即使已安装的 skill 或其描述发生变化,回放仍保持稳定。 鼠标移入 `/name` 时,背景覆盖整个引用。点击已知 skill 会在右侧栏打开提供方给出的 `SKILL.md` 路径,同时保留文本的可编辑性。缓存未就绪时,点击复用该 Session 的目录请求,在完成后按点击时的 Session 地址打开预览。切换预设、重置连接和插件释放会取消待处理的预览;后续点击重新获取当前目录。没有文件路径的 skill 仍可调用,但没有文件预览。 diff --git a/packages/client/ui-skill/src/client/SkillRow.module.css b/packages/client/ui-skill/src/client/SkillRow.module.css index 34f255483d..9fad40a2d0 100644 --- a/packages/client/ui-skill/src/client/SkillRow.module.css +++ b/packages/client/ui-skill/src/client/SkillRow.module.css @@ -18,7 +18,8 @@ cursor: pointer; } -.card[data-state='running'] .row::after { +.card[data-state='running'] .row::after, +.card[data-state='preparing'] .row::after { content: ''; position: absolute; inset: 0 auto 0 0; diff --git a/packages/client/ui-skill/src/client/SkillRow.tsx b/packages/client/ui-skill/src/client/SkillRow.tsx index 7aa5d33892..0800bc7812 100644 --- a/packages/client/ui-skill/src/client/SkillRow.tsx +++ b/packages/client/ui-skill/src/client/SkillRow.tsx @@ -2,7 +2,7 @@ import { useState, type KeyboardEvent, type ReactNode } from 'react' import { IconChevronDownOutlineRegular, IconInspectOutlineRegular, IconSkillOutlineRegular, } from '@deepseek-ai/dsh-client-ui-primitives' -import type { ToolCallViewProps } from '@deepseek-ai/dsh-client-ui-tool/client' +import type { StartedToolCallViewProps, ToolCallViewProps } from '@deepseek-ai/dsh-client-ui-tool/client' import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' import css from './SkillRow.module.css' @@ -42,7 +42,7 @@ function skillName(argsRaw: string, callId: string): string { /** Flatten durable result blocks under the generic Tool-row text contract. * Keep aligned with ui-tool's models/tool-call-model.ts `resultText`. */ -function resultText(block: ToolCallViewProps['block']): string | null { +function resultText(block: StartedToolCallViewProps['block']): string | null { if (!('kind' in block)) return null const parts: string[] = [] for (const item of block.content) { @@ -55,7 +55,7 @@ function resultText(block: ToolCallViewProps['block']): string | null { } /** Derive display state without consulting the live skill catalog. */ -function skillRowModel(block: ToolCallViewProps['block']): SkillRowModel { +function skillRowModel(block: StartedToolCallViewProps['block']): SkillRowModel { const settled = 'kind' in block const argsRaw = (settled ? block.call?.argsRaw : block.argsRaw) ?? '' const state: SkillRowState = !settled @@ -99,7 +99,18 @@ function stateStatus(state: SkillRowState, t: SkillRowProps['t']): string | null * @param props - keyed toolview payload plus the skill locale seat. * @returns the dedicated skill row. */ -export function SkillRow({ block, inspect, t }: SkillRowProps) { +export function SkillRow(props: SkillRowProps) { + if (props.phase === 'preparing') return
+
+ + {props.t('row.preparing')} + {props.t('row.title')} +
+
+ return +} + +function StartedSkillRow({ block, inspect, t }: Exclude) { const model = skillRowModel(block) const [expanded, setExpanded] = useState(false) const expandable = model.output !== null diff --git a/packages/client/ui-skill/src/client/locales.ts b/packages/client/ui-skill/src/client/locales.ts index ebd0910324..1028a827b4 100644 --- a/packages/client/ui-skill/src/client/locales.ts +++ b/packages/client/ui-skill/src/client/locales.ts @@ -7,6 +7,7 @@ export const NS = 'skill' export const zh = { 'row.title': '加载技能', 'row.running': '正在加载 skill', + 'row.preparing': '准备加载技能', 'row.failed': 'skill 加载失败', 'row.stopped': 'skill 加载已中止', 'row.instructions': '说明', @@ -21,6 +22,7 @@ export type SkillKey = keyof typeof zh export const en = { 'row.title': 'Skill', 'row.running': 'Loading skill', + 'row.preparing': 'Preparing to load a skill', 'row.failed': 'Skill load failed', 'row.stopped': 'Skill load stopped', 'row.instructions': 'Instructions', diff --git a/packages/client/ui-skill/tests/browser-plugin.client.spec.ts b/packages/client/ui-skill/tests/browser-plugin.client.spec.ts index ead382b4ac..b47abfe949 100644 --- a/packages/client/ui-skill/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-skill/tests/browser-plugin.client.spec.ts @@ -134,6 +134,7 @@ describe('apply', () => { zh: { 'row.title': '加载技能', 'row.running': '正在加载 skill', + 'row.preparing': '准备加载技能', 'row.failed': 'skill 加载失败', 'row.stopped': 'skill 加载已中止', 'row.instructions': '说明', @@ -143,6 +144,7 @@ describe('apply', () => { en: { 'row.title': 'Skill', 'row.running': 'Loading skill', + 'row.preparing': 'Preparing to load a skill', 'row.failed': 'Skill load failed', 'row.stopped': 'Skill load stopped', 'row.instructions': 'Instructions', diff --git a/packages/client/ui-skill/tests/skill-row.client.spec.tsx b/packages/client/ui-skill/tests/skill-row.client.spec.tsx index 053f78b4a9..d1515a2c5b 100644 --- a/packages/client/ui-skill/tests/skill-row.client.spec.tsx +++ b/packages/client/ui-skill/tests/skill-row.client.spec.tsx @@ -2,7 +2,7 @@ import { cleanup, fireEvent, render, screen } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' -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 { SkillRow } from '../src/client/SkillRow.tsx' @@ -29,9 +29,9 @@ function settled(over: Partial = {}): ToolResultNode { } } -function running(argsRaw = '{"name":"dsh-manage-issues"}'): RunningToolCall { +function running(argsRaw = '{"name":"dsh-manage-issues"}'): StartedToolCall { return { - callId: 'call-skill', name: 'skill', argsRaw, turn: 1, step: 1, time: 2_000, subCalls: [], + phase: 'start' as const, callId: 'call-skill', name: 'skill', argsRaw, turn: 1, step: 1, time: 2_000, subCalls: [], } } @@ -39,7 +39,7 @@ function props(block: SkillRowProps['block'], inspect?: () => void): SkillRowPro return { callId: block.callId, toolName: 'skill', - block, + ...('kind' in block ? { phase: 'result' as const, block: block } : { phase: block.phase, block: block }), openFile: vi.fn(), inspect, @@ -48,6 +48,16 @@ function props(block: SkillRowProps['block'], inspect?: () => void): SkillRowPro } describe('SkillRow', () => { + it('shows preparation without arguments, instructions, or disclosure', () => { + const view = render() + expect(view.getByText('准备加载技能')).toBeTruthy() + expect(view.container.querySelector('svg')).not.toBeNull() + expect(view.queryByRole('button')).toBeNull() + expect(view.container.querySelector('pre')).toBeNull() + }) + it('renders a compact Bash-shaped summary and discloses the exact instructions', () => { const inspect = vi.fn() const view = render() diff --git a/packages/client/ui-tool/README.i18n.yaml b/packages/client/ui-tool/README.i18n.yaml index b5dd281e0e..4e249a1621 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: 8f0dfd0bc0222bb6ea428deb0dd6769f7347e41c +README.zh.md: bd4681022f1215a3e0dcda48b73c911b5bc6a283 diff --git a/packages/client/ui-tool/README.md b/packages/client/ui-tool/README.md index 622dbd89c9..8f0dfd0bc0 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. 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`: @@ -45,6 +47,8 @@ The owner payload is `ToolCallOwnerProps`: `callId`, `toolName`, the frozen `blo ### 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 and retain their existing icon and title. The common row model returns empty presentation fields during preparation, 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. It derives explicit phase props from the root and 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..bd4681022f 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`: @@ -45,6 +47,8 @@ owner 载荷为 `ToolCallOwnerProps`:`callId`、`toolName`、冻结的 `block` ### 内置视图 +每个注册视图都接收[工具 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` 和导航回调。它从根调用数据生成显式阶段 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/src/client/contract/slots.ts b/packages/client/ui-tool/src/client/contract/slots.ts index d87ccf3ade..3919becdff 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,15 +50,13 @@ export interface ToolImagesOwnerProps { } /** Standard owner currency supplied to every atomic Tool view. */ -export interface ToolCallOwnerProps { +interface ToolCallCommonProps { /** Stable injected Hook; each invocation owns its open state and subscribes to enclosing-Turn resets. */ useDisclosure: UseDisclosure /** Tool call identity, stable across running and settled forms. */ 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 `~`. */ @@ -77,9 +78,21 @@ export interface ToolCallOwnerProps { 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 +106,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..73232825fe 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, 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..d799dd384f 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 (
& { - block: ToolCallBlock + call: ToolCallPhaseProps home?: string | undefined }) { 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 + const call = useMemo(() => toolCallPhase(node.data.root), [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..0544d3ce1c 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 and title without a summary or disclosure. * @param props - tool state, summary, output, and navigation callbacks. * @returns the tool disclosure. */ @@ -157,15 +159,16 @@ 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 normalSummary = terminalBody?.description ?? (open ? detailsBody?.expandedSummary ?? summary : summary) + const running = state === 'running' || state === 'preparing' + const normalSummary = state === 'preparing' ? '' + : 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 // amber while retaining the business icon and hidden state announcement. 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..f2c13d3b3d 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 { @@ -93,6 +90,15 @@ 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 @@ -229,14 +235,21 @@ 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 + if (!done && block.phase === 'preparing') { + return { + variant, titleKey, state: 'preparing', summary: '', filePath: undefined, + bodyRaw: null, output: null, errorSummary: null, autoReviewDenial: null, + } + } const argsRaw = (done ? block.call?.argsRaw : block.argsRaw) ?? '' const state: ToolRowState = !done ? 'running' : block.error?.code === 'interrupted' ? 'stopped' @@ -244,10 +257,9 @@ export function toolRowModel(toolName: string, block: ToolCallBlock, cwd?: strin 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 + const summary = variant === 'others' && toolName !== '' && TOOL_TITLE_KEYS[toolName] === undefined ? `${toolName} · ${base}` : base // The empty string is "no text" for both derived result fields: a settled @@ -258,7 +270,7 @@ 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), bodyRaw, 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..75bff722da 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 @@ -52,7 +53,7 @@ export function GenericToolCard({ toolName, block, cwd, home, openFile, inspect, variant={model.variant} toolName={toolName} icon={VARIANT_ICONS[model.variant]} - title={t(model.titleKey)} + title={model.state === 'preparing' && model.titleKey === 'tool.title.generic' ? toolName : t(model.titleKey)} summary={model.summary} // Single-file tools never expose an args body — the path link is the only // args interaction. A card is not an args body: a read/write/edit row is 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..cc3acf64d7 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 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 ce5344f6bf..a7abe7f641 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 @@ -14,6 +14,8 @@ import { detailsCardModel } from '../models/details-card-model.ts' import { parsedToolCall } from '../models/raw-tool-call.ts' import { toolRowModel } from '../models/tool-call-model.ts' +type DetailsRowProps = ToolCallViewProps & PropsLocale<'conversation'> + const TITLE_KEYS = { create_goal: 'tool.title.createGoal', get_goal: 'tool.title.getGoal', @@ -80,7 +82,7 @@ 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]) diff --git a/packages/client/ui-tool/src/client/tool/toolviews/todo-row.tsx b/packages/client/ui-tool/src/client/tool/toolviews/todo-row.tsx index 6f16103852..d922747cd3 100644 --- a/packages/client/ui-tool/src/client/tool/toolviews/todo-row.tsx +++ b/packages/client/ui-tool/src/client/tool/toolviews/todo-row.tsx @@ -27,7 +27,8 @@ interface RowSummary { extra: number } -function summarize(argsRaw: string, t: TodoRowProps['t']): RowSummary | null { +function summarize(argsRaw: string | null, t: TodoRowProps['t']): RowSummary | null { + if (argsRaw === null) return null let parsed: unknown try { parsed = JSON.parse(argsRaw) @@ -54,8 +55,7 @@ export function TodoRow({ toolName, block, inspect, useDisclosure, useTodoHistor const hasMore = useSession(snapshot => 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 ( - ({ 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, 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..23120ba2ce 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 })) @@ -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-row.client.spec.tsx b/packages/client/ui-tool/tests/details-row.client.spec.tsx index b7c3f6eb2e..dfd7d536a0 100644 --- a/packages/client/ui-tool/tests/details-row.client.spec.tsx +++ b/packages/client/ui-tool/tests/details-row.client.spec.tsx @@ -63,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) }) }) 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/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..967ab2b8c9 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() 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..fba3a01f33 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,17 @@ function props( } describe('ToolCallTree', () => { + 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 +150,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..cba4fb817a 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', () => { 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/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-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/extensions/cordis-client-runner/src/client/slot-catalog.ts b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts index 487e32985b..0a7c199474 100644 --- a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts @@ -3815,14 +3815,14 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'tool.call.images\', () => ctx.slots.register(\n { name: \'tool.call.images\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-tool/src/client/contract/slots.ts:35', + source: 'packages/client/ui-tool/src/client/contract/slots.ts:38', }, { key: 'tool.call.toolview', kind: 'keyed', scope: 'session', summary: 'Keyed Tool call view dispatched by wire Tool name.', - doc: 'Keyed Tool call view dispatched by wire Tool name. Any name is allowed,\nincluding tools registered by your package. Register with\n`key: \'\'`; a typo never renders.\n\nRegistering an occupied key replaces its view; unclaimed keys use the\ngeneric row. The owner supplies the call identity and frozen running\nor settled node through ToolCallOwnerProps.', + doc: 'Keyed Tool call view dispatched by wire Tool name. Any name is allowed,\nincluding tools registered by your package. Register with\n`key: \'\'`; a typo never renders.\n\nRegistering an occupied key replaces its view; unclaimed keys use the\ngeneric row. The owner supplies the call identity and frozen running\nor settled node through explicit phase props; preparing inputs have no arguments.', registerOptions: [ { name: 'key', @@ -3832,13 +3832,10 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ }, ], ownerProps: [ - '/** Standard owner currency supplied to every atomic Tool view. */\nexport interface ToolCallOwnerProps {\n /** Stable injected Hook; each invocation owns its open state and subscribes to enclosing-Turn resets. */\n useDisclosure: UseDisclosure\n /** Tool call identity, stable across running and settled forms. */\n callId: string\n /** Wire Tool name and keyed dispatch value. */\n toolName: string\n /** Frozen running call or settled result node. */\n block: ToolCallBlock\n /** Session workspace root for relative summaries. */\n cwd?: string | undefined\n /** Host account home; POSIX home-rooted summaries display as `~`. */\n home?: string | undefined\n /**\n * Open a Tool argument path. A view that knows which line the call was about\n * passes it, and the opened surface lands there.\n */\n openFile: (path: string, options?: OpenFileOptions) => void\n /**\n * Session-authorized image loader for the `tool.call.images` slot, supplied\n * by the chat node that owns this call. A composed chat node always\n * supplies it (`ChatNodeOwnerProps.loadImage` is required), so the tool\n * layer never imports an attachment implementation nor handles URL\n * authorization.\n */\n loadIm /* …truncated — full shape in source */', + '/** Common owner callbacks and the data admitted at the current tool stage. */\nexport type ToolCallOwnerProps = ToolCallCommonProps & ToolCallPhaseProps', ], ownerPropsReferences: [ - 'ChatNodeOwnerProps', - 'MessageImageLoader', - 'OpenFileOptions', - 'UseDisclosure', + 'ToolCallPhaseProps', ], standardProps: [ 'useResource: UseResource', @@ -3918,7 +3915,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'tool.call.toolview\', () => ctx.slots.register(\n { name: \'tool.call.toolview\', key: \'\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-tool/src/client/contract/slots.ts:21', + source: 'packages/client/ui-tool/src/client/contract/slots.ts:24', }, { key: 'tool.view.cordis', diff --git a/packages/extensions/ui-cordis/README.i18n.yaml b/packages/extensions/ui-cordis/README.i18n.yaml index a164478ef6..8e072cd9ac 100644 --- a/packages/extensions/ui-cordis/README.i18n.yaml +++ b/packages/extensions/ui-cordis/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/extensions/ui-cordis/README.md -README.md: 1f3677d48eda13cad18a24ed6021f30535db686a -README.zh.md: f3855ce6f75efc7510abdaff8d211cec167d5117 +README.md: 02ffc54ebb54b705d9e881d39f2b65d581a86257 +README.zh.md: 235310346ba3e3f34e3567646e5521ee5ba72bd6 diff --git a/packages/extensions/ui-cordis/README.md b/packages/extensions/ui-cordis/README.md index 1f3677d48e..02ffc54ebb 100644 --- a/packages/extensions/ui-cordis/README.md +++ b/packages/extensions/ui-cordis/README.md @@ -33,6 +33,8 @@ A `sidebar.footer.action` seat shows a badge counting what runs plus what waits; ### What the tool cards show +Preparing calls show their ordinary Cordis icon and title in one non-expandable row. This stage does not derive a package identity, read its live inventory, or mount its business view; those operations require the start/result props. + The `cordis_define` card is a record: the name and purpose the model wrote, the source it wrote, and whether the definition is running — no switch, no approval, and a pointer to the panel. The `cordis_run` card shows the mode, the plugin, package, and run ids, the outcome, and offers the package's own business view through the `tool.view.cordis` slot when the package registered one. Define, run, stop, and undefine cards retain their ordinary business glyph when they fail or are interrupted, including a correlated activation failure after a successful run receipt. All cards render from the recorded call and result, so replay shows the same card. ### Boundaries to plan around diff --git a/packages/extensions/ui-cordis/README.zh.md b/packages/extensions/ui-cordis/README.zh.md index f3855ce6f7..235310346b 100644 --- a/packages/extensions/ui-cordis/README.zh.md +++ b/packages/extensions/ui-cordis/README.zh.md @@ -33,6 +33,8 @@ kind: "package-reference" ### 工具卡片显示什么 +准备中的调用显示为带普通 Cordis 图标与标题的不可展开单行。此阶段不派生包身份、不读取其当前清单,也不挂载其业务视图;这些操作需要 start/result props。 + `cordis_define` 卡片是一份记录:模型写下的 name 与 purpose、它写的源码,以及该定义是否在跑——没有开关、没有审批,只有一句指向面板的指引。`cordis_run` 卡片显示模式、插件标识、包标识与运行标识、结果,并在包注册了业务视图时经 `tool.view.cordis` slot 提供它。define、run、stop 与 undefine 卡片在失败或中断时继续保留普通业务图标,包括调用回执成功后发生的关联激活失败。所有卡片都渲染会话记录下的 call 与 result,因此回放显示同一张卡。 ### 需要规划的边界 diff --git a/packages/extensions/ui-cordis/src/client/CordisActionRow.tsx b/packages/extensions/ui-cordis/src/client/CordisActionRow.tsx index 61fe374573..eeff2bcd11 100644 --- a/packages/extensions/ui-cordis/src/client/CordisActionRow.tsx +++ b/packages/extensions/ui-cordis/src/client/CordisActionRow.tsx @@ -7,12 +7,24 @@ import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' import type { ToolCallViewProps } from '@deepseek-ai/dsh-client-ui-tool/client' import { cordisActionCard } from './card-model.ts' import css from './CordisRunRow.module.css' +import { CordisPreparingRow } from './CordisPreparingRow.tsx' /** Full action-card props composed by the keyed Tool slot. */ export type CordisActionRowProps = ToolCallViewProps & PropsLocale<'cordis'> /** Render one Stop or Remove call with Cordis-owned localized copy. */ -export function CordisActionRow({ callId, toolName, block, inspect, t }: CordisActionRowProps) { +export function CordisActionRow(props: CordisActionRowProps) { + if (props.phase === 'preparing') { + const remove = props.toolName === 'cordis_undefine' + return : } + title={props.t(remove ? 'row.removeTitle' : 'row.stopTitle')} + className={css.card} rowClassName={css.row} titleClassName={css.title} /> + } + return +} + +function StartedCordisActionRow({ callId, toolName, block, inspect, t }: Exclude) { const card = cordisActionCard(block) const remove = toolName === 'cordis_undefine' const summary = card.errorSummary ?? card.pluginId ?? callId diff --git a/packages/extensions/ui-cordis/src/client/CordisDefineRow.tsx b/packages/extensions/ui-cordis/src/client/CordisDefineRow.tsx index b36b62b599..d33a4b9ae2 100644 --- a/packages/extensions/ui-cordis/src/client/CordisDefineRow.tsx +++ b/packages/extensions/ui-cordis/src/client/CordisDefineRow.tsx @@ -11,6 +11,7 @@ import type { CordisCardFace } from './slots.ts' import { cordisVisibleStatus, type CordisVisibleStatus } from './status.ts' import type { CordisKey } from './locales.ts' import css from './CordisDefineRow.module.css' +import { CordisPreparingRow } from './CordisPreparingRow.tsx' /** Full card props composed by the keyed Tool slot. */ export type CordisDefineRowProps = ToolCallViewProps & InjectFace & PropsLocale<'cordis'> @@ -35,9 +36,16 @@ function stateStatus(state: CordisToolState): CordisKey | null { } /** Render one immutable Package definition. */ -export function CordisDefineRow({ +export function CordisDefineRow(props: CordisDefineRowProps) { + if (props.phase === 'preparing') return } title={props.t('row.defineTitle')} + className={css.card} rowClassName={css.row} titleClassName={css.title} /> + return +} + +function StartedCordisDefineRow({ callId, block, inspect, useInventory, useLoaded, t, -}: CordisDefineRowProps) { +}: Exclude) { const card = cordisDefineCard(block) const inventory = useInventory(snapshot => snapshot) const loaded = useLoaded(snapshot => snapshot) diff --git a/packages/extensions/ui-cordis/src/client/CordisPreparingRow.tsx b/packages/extensions/ui-cordis/src/client/CordisPreparingRow.tsx new file mode 100644 index 0000000000..12f6ec7f02 --- /dev/null +++ b/packages/extensions/ui-cordis/src/client/CordisPreparingRow.tsx @@ -0,0 +1,25 @@ +/** Argument-free Cordis tool prefix shared by its three card families. */ +import type { ReactNode } from 'react' +import { DisclosureRow } from '@deepseek-ai/dsh-client-ui-primitives' +import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' +import type { ToolCallViewProps } from '@deepseek-ai/dsh-client-ui-tool/client' + +type CordisPreparingRowProps = Pick & PropsLocale<'cordis'> & { + readonly icon: ReactNode + readonly title: string + readonly className?: string | undefined + readonly rowClassName?: string | undefined + readonly titleClassName?: string | undefined +} + +/** + * Render a Cordis-owned prefix without disclosure. + * @param props - Cordis prefix, styling, and locale. + * @returns the preparation row. + */ +export function CordisPreparingRow({ toolName, useDisclosure, t, ...prefix }: CordisPreparingRowProps) { + const { toggle } = useDisclosure() + return
+ +
+} diff --git a/packages/extensions/ui-cordis/src/client/CordisRunRow.tsx b/packages/extensions/ui-cordis/src/client/CordisRunRow.tsx index 44cfbccc53..526728535a 100644 --- a/packages/extensions/ui-cordis/src/client/CordisRunRow.tsx +++ b/packages/extensions/ui-cordis/src/client/CordisRunRow.tsx @@ -12,6 +12,7 @@ import type { CordisRunCardFace } from './slots.ts' import { cordisVisibleStatus, type CordisVisibleStatus } from './status.ts' import type { CordisKey } from './locales.ts' import css from './CordisRunRow.module.css' +import { CordisPreparingRow } from './CordisPreparingRow.tsx' /** Full Run-card props including its declared Package business-view child slot. */ export type CordisRunRowProps = ToolCallViewProps @@ -32,10 +33,17 @@ const READING_LABELS = { } as const satisfies Record /** Render one activation result and, when eligible, its Package-owned view. */ -export function CordisRunRow({ +export function CordisRunRow(props: CordisRunRowProps) { + if (props.phase === 'preparing') return } title={props.t('row.runTitle')} + className={css.card} rowClassName={css.row} titleClassName={css.title} /> + return +} + +function StartedCordisRunRow({ callId, block, inspect, renderSlot, useInventory, useLoaded, useRunCards, useActiveRuns, onObserveRunCard, t, -}: CordisRunRowProps) { +}: Exclude) { const card = cordisRunCard(block) const inventory = useInventory(snapshot => snapshot) const loaded = useLoaded(snapshot => snapshot) diff --git a/packages/extensions/ui-cordis/src/client/card-model.ts b/packages/extensions/ui-cordis/src/client/card-model.ts index 2aceffb5d9..bccd1b61b1 100644 --- a/packages/extensions/ui-cordis/src/client/card-model.ts +++ b/packages/extensions/ui-cordis/src/client/card-model.ts @@ -1,11 +1,11 @@ /** Replay-stable view models for Cordis lifecycle Tool calls. */ -import type { ToolCallViewProps } from '@deepseek-ai/dsh-client-ui-tool/client' +import type { StartedToolCallViewProps } from '@deepseek-ai/dsh-client-ui-tool/client' import type { CordisDynamicPackageId, CordisDynamicPluginId, CordisDynamicPluginRunId, CordisDynamicRunMode, } from './events.ts' -type Block = ToolCallViewProps['block'] +type Block = StartedToolCallViewProps['block'] /** Lifecycle of the tool call itself. */ export type CordisToolState = 'running' | 'ok' | 'error' | 'stopped' diff --git a/packages/extensions/ui-cordis/src/client/locales.ts b/packages/extensions/ui-cordis/src/client/locales.ts index 2c410a8f09..60b061aca4 100644 --- a/packages/extensions/ui-cordis/src/client/locales.ts +++ b/packages/extensions/ui-cordis/src/client/locales.ts @@ -5,6 +5,7 @@ export const NS = 'cordis' /** Simplified Chinese Cordis UI messages. */ export const zh = { 'row.defineTitle': '注册 Cordis 插件', + 'a11y.preparing': '准备调用 Cordis 工具', 'row.runTitle': '运行 Cordis 插件', 'row.updateTitle': '更新 Cordis 插件', 'row.stopTitle': '停止 Cordis 插件', @@ -69,6 +70,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { /** English Cordis UI messages. */ export const en = { 'row.defineTitle': 'Register Cordis Plugin', + 'a11y.preparing': 'Preparing a Cordis tool call', 'row.runTitle': 'Run Cordis Plugin', 'row.updateTitle': 'Update Cordis Plugin', 'row.stopTitle': 'Stop Cordis Plugin', diff --git a/packages/extensions/ui-cordis/tests/card-model.client.spec.ts b/packages/extensions/ui-cordis/tests/card-model.client.spec.ts index 02062489e7..a0f0859d56 100644 --- a/packages/extensions/ui-cordis/tests/card-model.client.spec.ts +++ b/packages/extensions/ui-cordis/tests/card-model.client.spec.ts @@ -2,14 +2,14 @@ // call/result slice. import { describe, expect, it } from 'vitest' -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 { cordisActionCard, cordisDefineCard } from '../src/client/card-model.ts' const ARGS = '{"name":"clock","purpose":"顶栏时钟","code":{"client":"return {}","host":"harness.handle(\'now\', () => Date.now())"}}' -function running(over: Partial = {}): RunningToolCall { +function running(over: Partial = {}): StartedToolCall { return { - callId: 'call-1', name: 'cordis_define', argsRaw: ARGS, turn: 1, step: 1, time: 1_000, + phase: 'start' as const, callId: 'call-1', name: 'cordis_define', argsRaw: ARGS, turn: 1, step: 1, time: 1_000, subCalls: [], ...over, } } diff --git a/packages/extensions/ui-cordis/tests/status-icons.client.spec.tsx b/packages/extensions/ui-cordis/tests/status-icons.client.spec.tsx index 3d5a8920f1..4083c9dfd3 100644 --- a/packages/extensions/ui-cordis/tests/status-icons.client.spec.tsx +++ b/packages/extensions/ui-cordis/tests/status-icons.client.spec.tsx @@ -23,6 +23,23 @@ function failed(name: string): ToolResultNode { const useValue = (value: T) => (selector: (snapshot: T) => Selected): Selected => selector(value) describe('Cordis tool failure icons', () => { + it.each([ + ['cordis_define', CordisDefineRow], ['cordis_run', CordisRunRow], + ['cordis_stop', CordisActionRow], ['cordis_undefine', CordisActionRow], + ] as const)('%s prepares without invoking its inventory or execution hooks', (toolName, Component) => { + const unused = vi.fn(() => { throw new Error('preparation must not read execution details') }) + const props = { + phase: 'preparing', toolName, callId: 'call', t, + useDisclosure: () => ({ expanded: false, setExpanded: vi.fn(), toggle: vi.fn() }), + block: { phase: 'preparing', name: toolName, callId: 'call', turn: 1, step: 1, time: 1, subCalls: [] }, + useInventory: unused, useLoaded: unused, useRunCards: unused, useActiveRuns: unused, + } as Parameters[0] & Parameters[0] + const view = render() + expect(view.container.querySelector('[data-state="preparing"] svg')).not.toBeNull() + expect(view.queryByRole('button')).toBeNull() + expect(unused).not.toHaveBeenCalled() + }) + it('retains the code glyph for define and run failures', () => { const common = { openFile: vi.fn(), @@ -32,14 +49,14 @@ describe('Cordis tool failure icons', () => { useLoaded: useValue([]), } const define = render([0]} />) expect(define.container.querySelector('[data-tool="cordis_define"] [data-disclosure-row] > :first-child svg')).not.toBeNull() expect(define.container.querySelector('[data-tool="cordis_define"] [data-state]')).toBeNull() define.unmount() const run = render([0]} />) @@ -83,7 +100,7 @@ describe('Cordis tool failure icons', () => { content: [], isError: false, subCalls: [], meta: { pluginId, packageId, pluginRunId }, } const view = render( { it.each(['cordis_stop', 'cordis_undefine'] as const)('%s retains its action icon on failure', (toolName) => { const view = render([0]} />) expect(view.container.querySelector('[data-state="error"] svg')).not.toBeNull() expect(view.container.querySelector('[data-state="error"] [data-state]')).toBeNull() @@ -118,7 +135,7 @@ describe('Cordis tool failure icons', () => { error: { name: 'InterruptedError', code: 'interrupted' }, } const view = render([0]} />) expect(view.container.querySelector('[data-state="stopped"] svg')).not.toBeNull() expect(view.container.querySelector('[data-state="stopped"] [data-state]')).toBeNull() diff --git a/packages/extensions/ui-cordis/tests/versioning.client.spec.ts b/packages/extensions/ui-cordis/tests/versioning.client.spec.ts index 568aecf232..74350ef1ca 100644 --- a/packages/extensions/ui-cordis/tests/versioning.client.spec.ts +++ b/packages/extensions/ui-cordis/tests/versioning.client.spec.ts @@ -29,7 +29,7 @@ const row = (client: boolean): DynamicCordisInventoryRow => ({ describe('versioned Cordis card models', () => { it('reads symmetric Host and Client source fields from cordis_define', () => { const card = cordisDefineCard({ - callId: 'call-1', + phase: 'start' as const, callId: 'call-1', name: 'cordis_define', argsRaw: JSON.stringify({ plugin: { kind: 'new', idPrefix: 'clock' }, @@ -78,7 +78,7 @@ describe('versioned Cordis card models', () => { it('keeps the target identities while cordis_run waits for approval', () => { const card = cordisRunCard({ - callId: 'call-3', + phase: 'start' as const, callId: 'call-3', name: 'cordis_run', argsRaw: JSON.stringify({ pluginId: PLUGIN, packageId: PACKAGE, mode: 'update' }), turn: 1, From 74511b3dcb92de5b8a444663dc36fe173a478e7a Mon Sep 17 00:00:00 2001 From: imccyu Date: Wed, 23 Sep 2026 02:13:33 +0800 Subject: [PATCH 18/28] test(chat): cover live preparation and durable history replay --- ...lient-conversation-node-assembly.i18n.yaml | 4 +- ...08-09-client-conversation-node-assembly.md | 2 + ...09-client-conversation-node-assembly.zh.md | 2 + ...026-09-22-tool-call-three-phases.i18n.yaml | 6 + .../2026-09-22-tool-call-three-phases.md | 67 ++++++++ .../2026-09-22-tool-call-three-phases.zh.md | 67 ++++++++ apps/web/tests/ptc-round.e2e.ts | 39 ++++- .../tests/tool-preparation.client.spec.ts | 162 ++++++++++++++++++ .../tests/preparing-rows.client.spec.tsx | 84 +++++++++ snapshots/web/ptc-round/preparing.expected.md | 3 + snapshots/web/ptc-round/session.v4.jsonl | 26 +++ 11 files changed, 457 insertions(+), 5 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-09-22-tool-call-three-phases.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-09-22-tool-call-three-phases.md create mode 100644 .agents/notes/implemented/architecture/2026-09-22-tool-call-three-phases.zh.md create mode 100644 packages/client/ui-chat/tests/tool-preparation.client.spec.ts create mode 100644 packages/client/ui-tool/tests/preparing-rows.client.spec.tsx create mode 100644 snapshots/web/ptc-round/preparing.expected.md create mode 100644 snapshots/web/ptc-round/session.v4.jsonl 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-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..b97cd650a1 --- /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: 6b1943e9f30903d9a7f2c9b8cf87dceeae6cae92 +2026-09-22-tool-call-three-phases.zh.md: a03d20dac236af53b664bb4d2e7b505ef61d6102 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..6b1943e9f3 --- /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 returns empty presentation fields for preparing, 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..a03d20dac2 --- /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) 在三个阶段复用既有图标、标题和行组件。公共行模型为 preparing 返回空展示字段,共享参数解析入口直接返回 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/apps/web/tests/ptc-round.e2e.ts b/apps/web/tests/ptc-round.e2e.ts index abf392c727..85f63f2fe7 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()]) + expect(await page.locator('[data-state="preparing"]').count()).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 }) + expect(await page.locator('[data-state="preparing"]').count()).toBe(0) } const nest = page.locator('[data-subcalls]').first() const frame = page.locator('[style*="grid-template-columns"]').first() 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..cbd6e12d62 --- /dev/null +++ b/packages/client/ui-chat/tests/tool-preparation.client.spec.ts @@ -0,0 +1,162 @@ +import { describe, expect, it } 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) { + const assembler = new ConversationNodeAssembler( + { entries: () => [assistantDefinition, toolDefinition], 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('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: 'edit', count: 2 }], preparing: true, + }) + 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('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-tool/tests/preparing-rows.client.spec.tsx b/packages/client/ui-tool/tests/preparing-rows.client.spec.tsx new file mode 100644 index 0000000000..c975c696fe --- /dev/null +++ b/packages/client/ui-tool/tests/preparing-rows.client.spec.tsx @@ -0,0 +1,84 @@ +// @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('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/snapshots/web/ptc-round/preparing.expected.md b/snapshots/web/ptc-round/preparing.expected.md new file mode 100644 index 0000000000..53b352db4c --- /dev/null +++ b/snapshots/web/ptc-round/preparing.expected.md @@ -0,0 +1,3 @@ +- button "Preparing to run code" [expanded] +- button "Think" +- text: Preparing tool call Code diff --git a/snapshots/web/ptc-round/session.v4.jsonl b/snapshots/web/ptc-round/session.v4.jsonl new file mode 100644 index 0000000000..ca0915fa13 --- /dev/null +++ b/snapshots/web/ptc-round/session.v4.jsonl @@ -0,0 +1,26 @@ +{"type":"session","version":4,"id":"{{session:1}}","createdAt":1788240622920,"cwd":"{{cwd}}","isSeeded":false,"delegationDepth":0,"agentPreset":"ptc"} +{"type":"permission/preset","data":{"preset":"workspace-write"}} +{"type":"sandbox/mode","data":{"mode":"workspace-write"}} +{"type":"approval/policy","data":{"policy":"ask"}} +{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Using ONE run_code program: run bash `echo CODE_ROUND_OK`, then read the file missing.txt catching its error in the program. Return an object with both outcomes. Then reply DONE and stop."}],"source":{"kind":"user","rpcId":"{{rpc:1}}","clientTimeZone":"{{clientTimeZone}}"},"role":"user","id":"{{message:1}}"}]}} +{"type":"turn/start","data":{"turn":1}} +{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","data":{"turn":1,"step":1}} +{"type":"system/message","data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{message:2}}"}},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Using ONE run_code program: run bash `echo CODE_ROUND_OK`, then read the file missing.txt catching its error in the program. Return an object with both outcomes. Then reply DONE and stop."}],"source":{"kind":"user","rpcId":"{{rpc:1}}","clientTimeZone":"{{clientTimeZone}}"},"role":"user","id":"{{message:1}}"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{message:3}}"},"surfaceOp":"append"} +{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash","contextWindow":128000}} +{"type":"session/title","data":{"title":"Using ONE run_code program: run","messageSeqs":[8],"source":{"kind":"fallback"}}} +{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to write a single `run_code` program that:\n1. Runs bash to echo \"CODE_ROUND_OK\"\n2. Tries to read a file \"missing.txt\" and catches the error\n3. Returns an object with both outcomes\n4. They also want me to reply \"DONE\" and stop after\n\nLet me write this program."},{"type":"tool-call","id":"call_00_6VNoF1gDSerTBKoCfYSH3765","name":"run_code","arguments":"{\"description\": \"Run bash echo and catch missing file read\", \"code\": \"\\nconst bashResult = await tools.bash({\\n command: \\\"echo CODE_ROUND_OK\\\",\\n description: \\\"Echo CODE_ROUND_OK\\\"\\n});\\n\\nlet readError = null;\\ntry {\\n await tools.read({ file_path: \\\"missing.txt\\\" });\\n} catch (e) {\\n readError = {\\n toolName: e.toolName,\\n message: e.message\\n };\\n}\\n\\nreturn { bash: bashResult.stdout.text.trim(), readError };\\n\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:4}}"},"usage":{"inputTokens":8082,"outputTokens":235,"cacheReadTokens":384,"reasoningTokens":75},"stream":[{"type":"chunk","time":1788699493302,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}},{"type":"reasoning-chunks","time0":1788699493318,"index":0,"dt":[16,16,16,16,18,16,17,16,15,16,16,16,16,15,16,16,17,17,16,16,17,16,16,16,16,16,16,16,17,16,16,17,16,16,17,16,17,16,16,16,16,16,16,16,15,16,16,16,17,16,16,17,16,16,16,16,16,15,16,16,16,17,16,16,16,15,16,16,15,16,16,16,17,16],"texts":["The"," user"," wants"," me"," to"," write"," a"," single"," `","run","_code","`"," program"," that",":\n","1","."," Runs"," bash"," to"," echo"," \"","CODE","_RO","UND","_OK","\"\n","2","."," T","ries"," to"," read"," a"," file"," \"","missing",".txt","\""," and"," catches"," the"," error","\n","3","."," Returns"," an"," object"," with"," both"," outcomes","\n","4","."," They"," also"," want"," me"," to"," reply"," \"","D","ONE","\""," and"," stop"," after","\n\n","Let"," me"," write"," this"," program","."]},{"type":"chunk","time":1788699494526,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":1788699494542,"index":1,"dt":[17,16,16,16,16,16,16,16,16,16,17,16,15,17,16,16,16,16,16,16,16,16,16,15,18,16,16,17,16,15,16,16,16,16,16,17,16,17,16,18,16,16,16,15,17,16,16,16,15,17,16,16,15,17,15,16,16,16,17,16,16,17,16,16,16,16,16,17,16,16,17,18,17,17,15,16,16,16,17,16,16,16,17,16,16,16,16,18,15,16,16,17,18,18,16,16,17,16,17,16,16,16,16,16,16,16,16,16,17,17,16,15,16,15,17,16,15,15],"id":"call_00_6VNoF1gDSerTBKoCfYSH3765","name":"run_code","args":["","{","\"","description","\"",": ","\"","Run"," bash"," echo"," and"," catch"," missing"," file"," read","\"",", ","\"","code","\"",": ","\"","\\n","const"," bash","Result"," ="," await"," tools",".b","ash","({\\n"," "," command",":"," \\\"","echo"," CODE","_RO","UND","_OK","\\\",\\n"," "," description",":"," \\\"","E","cho"," CODE","_RO","UND","_OK","\\\"\\n","});\\n\\n","let"," read","Error"," ="," null",";\\n","try"," {\\n"," "," await"," tools",".read","({"," file","_path",":"," \\\"","missing",".txt","\\\""," });\\n","}"," catch"," (","e",")"," {\\n"," "," read","Error"," ="," {\\n"," "," tool","Name",":"," e",".t","ool","Name",",\\n"," "," message",":"," e",".message","\\n"," "," };\\n","}\\n\\n","return"," {"," bash",":"," bash","Result",".stdout",".text",".trim","(),"," read","Error"," };\\n","\"","}"]},{"type":"chunk","time":1788699496468,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to write a single `run_code` program that:\n1. Runs bash to echo \"CODE_ROUND_OK\"\n2. Tries to read a file \"missing.txt\" and catches the error\n3. Returns an object with both outcomes\n4. They also want me to reply \"DONE\" and stop after\n\nLet me write this program."}}},{"type":"chunk","time":1788699496484,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_6VNoF1gDSerTBKoCfYSH3765","name":"run_code","arguments":"{\"description\": \"Run bash echo and catch missing file read\", \"code\": \"\\nconst bashResult = await tools.bash({\\n command: \\\"echo CODE_ROUND_OK\\\",\\n description: \\\"Echo CODE_ROUND_OK\\\"\\n});\\n\\nlet readError = null;\\ntry {\\n await tools.read({ file_path: \\\"missing.txt\\\" });\\n} catch (e) {\\n readError = {\\n toolName: e.toolName,\\n message: e.message\\n };\\n}\\n\\nreturn { bash: bashResult.stdout.text.trim(), readError };\\n\"}"}}},{"type":"chunk","time":1788699496501,"chunk":{"type":"usage","usage":{"inputTokens":8082,"outputTokens":235,"cacheReadTokens":384,"reasoningTokens":75}}},{"type":"chunk","time":1788699496517,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"tool/call","data":{"turn":1,"step":1,"callId":"call_00_6VNoF1gDSerTBKoCfYSH3765","name":"run_code","arguments":"{\"description\": \"Run bash echo and catch missing file read\", \"code\": \"\\nconst bashResult = await tools.bash({\\n command: \\\"echo CODE_ROUND_OK\\\",\\n description: \\\"Echo CODE_ROUND_OK\\\"\\n});\\n\\nlet readError = null;\\ntry {\\n await tools.read({ file_path: \\\"missing.txt\\\" });\\n} catch (e) {\\n readError = {\\n toolName: e.toolName,\\n message: e.message\\n };\\n}\\n\\nreturn { bash: bashResult.stdout.text.trim(), readError };\\n\"}"}} +{"type":"tool/ptc-dispatch-start","data":{"rootCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765","parentCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765","subCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765:ptc:1","name":"bash","arguments":{"command":"echo CODE_ROUND_OK","description":"Echo CODE_ROUND_OK"}}} +{"type":"tool/ptc-dispatch","data":{"rootCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765","parentCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765","subCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765:ptc:1","name":"bash","arguments":{"command":"echo CODE_ROUND_OK","description":"Echo CODE_ROUND_OK"},"isError":false,"content":[{"type":"text","text":"CODE_ROUND_OK\n"}]}} +{"type":"tool/ptc-dispatch-start","data":{"rootCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765","parentCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765","subCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765:ptc:2","name":"read","arguments":{"file_path":"missing.txt"}}} +{"type":"tool/ptc-dispatch","data":{"rootCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765","parentCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765","subCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765:ptc:2","name":"read","arguments":{"file_path":"missing.txt"},"isError":true,"error":{"name":"FsError","code":"FS_NOT_FOUND"},"content":[{"type":"text","text":"Error: cannot read \"{{cwd}}/missing.txt\": not found"}]}} +{"type":"tool/result","data":{"turn":1,"step":1,"message":{"role":"tool","source":{"kind":"tool","callId":"call_00_6VNoF1gDSerTBKoCfYSH3765"},"toolCallId":"call_00_6VNoF1gDSerTBKoCfYSH3765","content":[{"type":"text","text":"{\n \"bash\": \"CODE_ROUND_OK\",\n \"readError\": {\n \"toolName\": \"read\",\n \"message\": \"cannot read \\\"{{cwd}}/missing.txt\\\": not found\"\n }\n}"}],"isError":false,"id":"{{message:5}}"}},"sourceEventSeqs":[14],"surfaceOp":"append"} +{"type":"step/end","data":{"turn":1,"step":1}} +{"type":"step/start","data":{"turn":1,"step":2}} +{"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The program ran successfully. Let me now reply DONE as instructed."},{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:6}}"},"usage":{"inputTokens":196,"outputTokens":17,"cacheReadTokens":8576,"reasoningTokens":14},"stream":[{"type":"chunk","time":1788699496621,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}},{"type":"reasoning-chunks","time0":1788699496637,"index":0,"dt":[17,16,15,16,18,16,16,16,16,17,16,16,16],"texts":["The"," program"," ran"," successfully","."," Let"," me"," now"," reply"," D","ONE"," as"," instructed","."]},{"type":"chunk","time":1788699496863,"chunk":{"type":"block-start","index":1,"blockType":"text"}},{"type":"text-chunks","time0":1788699496879,"index":1,"dt":[16],"texts":["D","ONE"]},{"type":"chunk","time":1788699496911,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The program ran successfully. Let me now reply DONE as instructed."}}},{"type":"chunk","time":1788699496927,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}},{"type":"chunk","time":1788699496943,"chunk":{"type":"usage","usage":{"inputTokens":196,"outputTokens":17,"cacheReadTokens":8576,"reasoningTokens":14}}},{"type":"chunk","time":1788699496959,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"} +{"type":"step/end","data":{"turn":1,"step":2}} +{"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}} From 833835d95cac59eee90400fe48ae903afd6f8f7f Mon Sep 17 00:00:00 2001 From: imccyu Date: Wed, 23 Sep 2026 02:30:54 +0800 Subject: [PATCH 19/28] test(chat): retire unknown casts from tool row fixtures --- .../ui-skill/tests/skill-row.client.spec.tsx | 13 ++++-- .../tests/status-icons.client.spec.tsx | 46 +++++++++++-------- scripts/no-unknown-casts.baseline.json | 12 ----- 3 files changed, 35 insertions(+), 36 deletions(-) diff --git a/packages/client/ui-skill/tests/skill-row.client.spec.tsx b/packages/client/ui-skill/tests/skill-row.client.spec.tsx index d1515a2c5b..a34b8ff4e9 100644 --- a/packages/client/ui-skill/tests/skill-row.client.spec.tsx +++ b/packages/client/ui-skill/tests/skill-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 { StartedToolCall, ToolResultNode } from '@deepseek-ai/dsh-client-ui-chat/client' +import type { ToolCallOwnerProps } from '@deepseek-ai/dsh-client-ui-tool/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 { SkillRow } from '../src/client/SkillRow.tsx' @@ -36,15 +37,17 @@ function running(argsRaw = '{"name":"dsh-manage-issues"}'): StartedToolCall { } function props(block: SkillRowProps['block'], inspect?: () => void): SkillRowProps { - return { + const owner: ToolCallOwnerProps = { callId: block.callId, toolName: 'skill', - ...('kind' in block ? { phase: 'result' as const, block: block } : { phase: block.phase, block: block }), - + ...('kind' in block ? { phase: 'result' as const, block } + : block.phase === 'preparing' ? { phase: 'preparing' as const, block } : { phase: 'start' as const, block }), + useDisclosure: () => ({ expanded: false, setExpanded: vi.fn(), toggle: vi.fn() }), + loadImage: vi.fn(), openFile: vi.fn(), inspect, - t, - } as unknown as SkillRowProps + } + return { ...owner, t } as SkillRowProps } describe('SkillRow', () => { diff --git a/packages/extensions/ui-cordis/tests/status-icons.client.spec.tsx b/packages/extensions/ui-cordis/tests/status-icons.client.spec.tsx index 4083c9dfd3..54ed1ba13a 100644 --- a/packages/extensions/ui-cordis/tests/status-icons.client.spec.tsx +++ b/packages/extensions/ui-cordis/tests/status-icons.client.spec.tsx @@ -3,6 +3,7 @@ import { cleanup, render } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' import type { ToolResultNode } from '@deepseek-ai/dsh-client-ui-chat/client' +import type { CordisDynamicPackageId, CordisDynamicPluginId, CordisDynamicPluginRunId, SessionId } from '@deepseek-ai/dsh-api-remotes/client' import { CordisActionRow } from '../src/client/CordisActionRow.tsx' import { CordisDefineRow } from '../src/client/CordisDefineRow.tsx' import { CordisRunRow } from '../src/client/CordisRunRow.tsx' @@ -21,6 +22,10 @@ function failed(name: string): ToolResultNode { } const useValue = (value: T) => (selector: (snapshot: T) => Selected): Selected => selector(value) +const callbacks = { + openFile: vi.fn(), loadImage: vi.fn(), + useDisclosure: () => ({ expanded: false, setExpanded: vi.fn(), toggle: vi.fn() }), +} describe('Cordis tool failure icons', () => { it.each([ @@ -29,8 +34,8 @@ describe('Cordis tool failure icons', () => { ] as const)('%s prepares without invoking its inventory or execution hooks', (toolName, Component) => { const unused = vi.fn(() => { throw new Error('preparation must not read execution details') }) const props = { + ...callbacks, phase: 'preparing', toolName, callId: 'call', t, - useDisclosure: () => ({ expanded: false, setExpanded: vi.fn(), toggle: vi.fn() }), block: { phase: 'preparing', name: toolName, callId: 'call', turn: 1, step: 1, time: 1, subCalls: [] }, useInventory: unused, useLoaded: unused, useRunCards: unused, useActiveRuns: unused, } as Parameters[0] & Parameters[0] @@ -42,15 +47,15 @@ describe('Cordis tool failure icons', () => { it('retains the code glyph for define and run failures', () => { const common = { - openFile: vi.fn(), + ...callbacks, inspect: undefined, t, - useInventory: useValue({ rows: [], removed: new Set(), read: true }), + useInventory: useValue({ rows: [], removed: new Set(), read: true }), useLoaded: useValue([]), } const define = render([0]} />) + } as Parameters[0]} />) expect(define.container.querySelector('[data-tool="cordis_define"] [data-disclosure-row] > :first-child svg')).not.toBeNull() expect(define.container.querySelector('[data-tool="cordis_define"] [data-state]')).toBeNull() define.unmount() @@ -59,7 +64,7 @@ describe('Cordis tool failure icons', () => { ...common, callId: 'call-cordis_run', toolName: 'cordis_run', phase: 'result' as const, block: failed('cordis_run'), renderSlot: vi.fn(), useRunCards: useValue(new Map()), useActiveRuns: useValue(new Map()), onObserveRunCard: vi.fn(), - } as unknown as Parameters[0]} />) + } as Parameters[0]} />) expect(run.container.querySelector('[data-tool="cordis_run"] > div:first-child > span:first-child svg')).not.toBeNull() expect(run.container.querySelector('[data-tool="cordis_run"] [data-state]')).toBeNull() }) @@ -70,40 +75,41 @@ describe('Cordis tool failure icons', () => { error: { name: 'InterruptedError', code: 'interrupted' }, }) const common = { - openFile: vi.fn(), inspect: undefined, t, - useInventory: useValue({ rows: [], removed: new Set(), read: true }), + ...callbacks, inspect: undefined, t, + useInventory: useValue({ rows: [], removed: new Set(), read: true }), useLoaded: useValue([]), } const define = render([0]} />) + ...common, callId: 'call-cordis_define', toolName: 'cordis_define', phase: 'result', block: interrupted('cordis_define'), + } as Parameters[0]} />) expect(define.container.querySelector('[data-tool="cordis_define"] [data-disclosure-row] > :first-child svg')).not.toBeNull() expect(define.container.querySelector('[data-tool="cordis_define"] [data-state]')).toBeNull() define.unmount() const run = render([0]} />) + } as Parameters[0]} />) expect(run.container.querySelector('[data-tool="cordis_run"] > div:first-child > span:first-child svg')).not.toBeNull() expect(run.container.querySelector('[data-tool="cordis_run"] [data-state]')).toBeNull() }) it('uses the dashed code glyph when a successful run receipt later reports activation failure', () => { - const pluginId = 'plugin-1' - const packageId = 'package-1' - const pluginRunId = 'run-1' + const pluginId = 'plugin-1' as CordisDynamicPluginId + const packageId = 'package-1' as CordisDynamicPackageId + const pluginRunId = 'run-1' as CordisDynamicPluginRunId const block: ToolResultNode = { kind: 'tool-result', seq: 3, time: 3, callId: 'call-cordis_run', callTime: 2, call: { name: 'cordis_run', argsRaw: JSON.stringify({ pluginId, packageId, mode: 'run' }) }, content: [], isError: false, subCalls: [], meta: { pluginId, packageId, pluginRunId }, } const view = render( { error: { phase: 'host-apply', message: 'boom', pluginId, packageId, pluginRunId }, }, }], - removed: new Set(), read: true, + removed: new Set(), read: true, }), useLoaded: useValue([]), renderSlot: vi.fn(), useRunCards: useValue(new Map()), useActiveRuns: useValue(new Map()), onObserveRunCard: vi.fn(), - } as unknown as Parameters[0]} />) + } as Parameters[0]} />) expect(view.container.querySelector('[data-cordis-status="failed"]')).not.toBeNull() expect(view.container.querySelector('[data-state="ok"] svg')).not.toBeNull() }) it.each(['cordis_stop', 'cordis_undefine'] as const)('%s retains its action icon on failure', (toolName) => { const view = render([0]} />) + } as Parameters[0]} />) expect(view.container.querySelector('[data-state="error"] svg')).not.toBeNull() expect(view.container.querySelector('[data-state="error"] [data-state]')).toBeNull() }) @@ -135,8 +142,9 @@ describe('Cordis tool failure icons', () => { error: { name: 'InterruptedError', code: 'interrupted' }, } const view = render([0]} />) + } as Parameters[0]} />) expect(view.container.querySelector('[data-state="stopped"] svg')).not.toBeNull() expect(view.container.querySelector('[data-state="stopped"] [data-state]')).toBeNull() }) diff --git a/scripts/no-unknown-casts.baseline.json b/scripts/no-unknown-casts.baseline.json index 1a590ad1b9..f70fbdf58b 100644 --- a/scripts/no-unknown-casts.baseline.json +++ b/scripts/no-unknown-casts.baseline.json @@ -765,9 +765,6 @@ "packages/client/ui-sidebar/src/client/index.ts": { "4c73f40f3ce989f285848573d7154fb295886bba707ad829e2c8c089e326aeea": 1 }, - "packages/client/ui-skill/tests/skill-row.client.spec.tsx": { - "6a1c3746f69a09ed859887aa4f1c9c0cffc1a9152459f3b1aef8b39c0020be74": 1 - }, "packages/client/ui-subagent/tests/browser-plugin.client.spec.ts": { "810a8e63d34628d25866491b3c6455a51b2d114261b67e62b42728b765370993": 1 }, @@ -1306,15 +1303,6 @@ "packages/extensions/ui-cordis/tests/inventory.client.spec.ts": { "205cd2f0d90ee000d0bd5f8c9e7e03d7d602f22e12220ebdf8043d68fd9b796a": 1 }, - "packages/extensions/ui-cordis/tests/status-icons.client.spec.tsx": { - "197a02bb861b1e7f091fed36ce23f7fe7b29686e869eae4ef97ca5f2d3db0c3f": 1, - "21e66428cbbde28dc4babe182c025a24ce551db8213ad838f32032f1637f82f3": 1, - "2a1282321e7b71e11b6c7f185fee563b40d3bb154ae3ddea20b09885f7e70d1e": 1, - "39b3669a761fd862ddc2653942105307bb6b7e9af87a52924dce1f9a6a3ca27d": 1, - "3f1740cf4e1f98e62eed39475eefe4975e40c0ef37f9eaa9d42b82e2b3e59441": 1, - "ac0beabad88e70e9d83e0ff5f45e36c04da79841f57a9580531dd930d2695511": 1, - "c331b9162c06d90cddeb7fd093839d0e12eb2ef6a17e1a5afbcaa335ed3a29c2": 1 - }, "packages/feedback/command-feedback/tests/loader-composition.spec.ts": { "59c63f734e8bba862325f776e3fcbebd78a84cfb4161f8df41c216950d9bf351": 1 }, From a60d4df027684f09fc1a2d0621cb0c43acda8adf Mon Sep 17 00:00:00 2001 From: imccyu Date: Wed, 23 Sep 2026 02:41:24 +0800 Subject: [PATCH 20/28] test(desktop): await ASAR fixture output completion --- .../installed-update-package-content.spec.ts | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) 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', () => { From b2817d77fb46b4833eb55c3028923799f02b08f0 Mon Sep 17 00:00:00 2001 From: imccyu Date: Wed, 23 Sep 2026 13:37:43 +0800 Subject: [PATCH 21/28] perf(chat): batch preparing tool updates --- packages/client/ui-chat/README.i18n.yaml | 4 ++-- packages/client/ui-chat/README.md | 2 ++ packages/client/ui-chat/README.zh.md | 2 ++ .../src/client/conversation-nodes/tool.ts | 16 ++++++++----- .../tests/tool-preparation.client.spec.ts | 23 ++++++++++++++++--- 5 files changed, 36 insertions(+), 11 deletions(-) diff --git a/packages/client/ui-chat/README.i18n.yaml b/packages/client/ui-chat/README.i18n.yaml index c9f58c8963..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: c9765db137f1b4fc5740cdf167fcf313d0f47b7a -README.zh.md: 9a11c301e742d309048e0990037780f7c576ea34 +README.md: b3d649c319c58ff807f11cc6b6d0e634eb931711 +README.zh.md: 6feb9d459299755968a3d99d5789d0cfae8b6e8e diff --git a/packages/client/ui-chat/README.md b/packages/client/ui-chat/README.md index c9765db137..b3d649c319 100644 --- a/packages/client/ui-chat/README.md +++ b/packages/client/ui-chat/README.md @@ -87,6 +87,8 @@ Chat registers its process Group Definition through `uiConversation.groups`. Rea `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. Scroll-edge fades initialize when `ResizeObserver` reports the open group's layout; opening the group performs no immediate scroll-dimension read in a layout effect. diff --git a/packages/client/ui-chat/README.zh.md b/packages/client/ui-chat/README.zh.md index 9a11c301e7..6feb9d4592 100644 --- a/packages/client/ui-chat/README.zh.md +++ b/packages/client/ui-chat/README.zh.md @@ -87,6 +87,8 @@ Chat 通过 `uiConversation.groups` 注册过程 Group Definition。React 通过 `groupPart` 在 Assistant 渲染器中选择推理或回复,不复制 Node 载荷。同一个 callId 的准备、派发与结果阶段由 Tool 节点自己拥有。每个部分有独立的 DOM 锚点用于恢复阅读位置;轮次导航使用原 Node key,落到它的第一个可见部分。展示模式切换以及为完整旧组补入更早成员时,保留组来源、成员父级及 key。原 Node Store 仍是唯一节点数据所有者,替换 Builder 时重新绑定按键订阅,不重挂载容器。模式变化保留尺寸观察器,并复用整轮状态选择器。 +实时工具 delta 与推理共用按帧合并的发布节奏;持久调用和结果立即发布。重复具名 delta 在投影调用、锚点、位置及可见性均未变化时保留 Tool 节点及其数据引用。 + 过程组使用稳定的 `div` 布局盒子、滚动正文及不限高的内容盒子,后者报告正文内部的内容增长。业务样式必须适配组内及组边界间距,处理隐藏或空成员以及回答前的间距特例。CSS 变量不属于 Group Definition。 滚动边缘渐隐在 `ResizeObserver` 报告已展开组的布局后初始化;展开组时不会在 layout effect 中立即读取滚动尺寸。 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 efa28a82e9..ffbc94de5a 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/tool.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/tool.ts @@ -6,7 +6,7 @@ import type { import { isAppendSurfaceEvent } from '@deepseek-ai/dsh-session/surface' import type {} from '@deepseek-ai/dsh-tools/types' import type { ChatNode, ToolChatData } from '../contract/chat-nodes.ts' -import { CHAT_SYNTHETIC_SEQ_OFFSETS, chatNode } from './common.ts' +import { CHAT_SYNTHETIC_SEQ_OFFSETS, chatNode, contextLocation } from './common.ts' declare module '../contract/chat-nodes.ts' { interface ChatNodeDataMap { @@ -276,20 +276,24 @@ export const toolDefinition: ConversationNodeDefinition = { } 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) { - const previous = context.current.get('chat') as ChatNode<'tool-call'> | null | undefined - return previous === undefined || previous === null ? null : { ...previous, visibility: 'hidden' } + 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) const preparing = !('kind' in projected) && projected.phase === 'preparing' - return chatNode(context, 'tool-call', anchor, { root: projected } satisfies ToolChatData, { - visibility: preparing && interruptedAt !== undefined ? 'hidden' : 'visible', - }) + 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/tests/tool-preparation.client.spec.ts b/packages/client/ui-chat/tests/tool-preparation.client.spec.ts index cbd6e12d62..f09a6bae98 100644 --- a/packages/client/ui-chat/tests/tool-preparation.client.spec.ts +++ b/packages/client/ui-chat/tests/tool-preparation.client.spec.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest' +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' @@ -65,9 +65,9 @@ function settlement(): SessionAssistantSettlementEntry { } } } -function harness(entries: readonly SessionEventLikeEntry[] = opening, hasMore = false) { +function harness(entries: readonly SessionEventLikeEntry[] = opening, hasMore = false, tool = toolDefinition) { const assembler = new ConversationNodeAssembler( - { entries: () => [assistantDefinition, toolDefinition], fallbackEntry: () => undefined }, + { entries: () => [assistantDefinition, tool], fallbackEntry: () => undefined }, { entries: () => [chatViewDefinition] }, { entries: () => [processGroupDefinition], forTarget: target => target === 'chat' ? processGroupDefinition : undefined }, ) @@ -85,6 +85,23 @@ function harness(entries: readonly SessionEventLikeEntry[] = opening, hasMore = } describe('Tool preparation and durable replay', () => { + it('batches repeated named deltas and retains the unchanged Tool node', () => { + const start = vi.fn(toolDefinition.start) + 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)) From 5840898e07170b653539aa3f74dbd237995f7c4b Mon Sep 17 00:00:00 2001 From: imccyu Date: Wed, 23 Sep 2026 13:37:47 +0800 Subject: [PATCH 22/28] perf(tool): retain unchanged subcall branches --- packages/client/ui-tool/README.i18n.yaml | 4 ++-- packages/client/ui-tool/README.md | 2 +- packages/client/ui-tool/README.zh.md | 2 +- .../ui-tool/src/client/tool/ToolCallTree.tsx | 10 +++++----- .../ui-tool/tests/tool-call-tree.client.spec.tsx | 14 ++++++++++++++ 5 files changed, 23 insertions(+), 9 deletions(-) diff --git a/packages/client/ui-tool/README.i18n.yaml b/packages/client/ui-tool/README.i18n.yaml index 4e249a1621..a37c471ad9 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: 8f0dfd0bc0222bb6ea428deb0dd6769f7347e41c -README.zh.md: bd4681022f1215a3e0dcda48b73c911b5bc6a283 +README.md: 39b05236deae913d8be31be7846cac6f853b7eca +README.zh.md: e06ec350ad299593c82a216949fcdfa713122387 diff --git a/packages/client/ui-tool/README.md b/packages/client/ui-tool/README.md index 8f0dfd0bc0..39b05236de 100644 --- a/packages/client/ui-tool/README.md +++ b/packages/client/ui-tool/README.md @@ -63,7 +63,7 @@ The package realizes one dispatch rule: atomic Tool views are keyed by wire Tool ### Rendering contract -`ToolCallTree` receives one Tool node, Session `cwd`, and navigation callbacks. It derives explicit phase props from the root and 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. +`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. diff --git a/packages/client/ui-tool/README.zh.md b/packages/client/ui-tool/README.zh.md index bd4681022f..e06ec350ad 100644 --- a/packages/client/ui-tool/README.zh.md +++ b/packages/client/ui-tool/README.zh.md @@ -63,7 +63,7 @@ owner 载荷为 `ToolCallOwnerProps`:`callId`、`toolName`、冻结的 `block` ### 渲染约定 -`ToolCallTree` 接收一个 Tool 节点、会话 `cwd` 和导航回调。它从根调用数据生成显式阶段 props,并通过 `tool.call.toolview` 按工具名分发。已派发的根调用保留递归 `subCalls`,准备阶段没有子调用。每个根调用和子调用包装层都保留 `data-chat-anchor-key="call:"` 与 `data-chat-call-id` DOM 约定,供分页和选择使用。Tool 节点在三个阶段保持同一个 callId。 +`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 身份;展示模式切换保留该状态。 diff --git a/packages/client/ui-tool/src/client/tool/ToolCallTree.tsx b/packages/client/ui-tool/src/client/tool/ToolCallTree.tsx index d799dd384f..d6419aa205 100644 --- a/packages/client/ui-tool/src/client/tool/ToolCallTree.tsx +++ b/packages/client/ui-tool/src/client/tool/ToolCallTree.tsx @@ -59,11 +59,12 @@ const ToolCall = memo(function ToolCall({ }) const ToolCallBranch = memo(function ToolCallBranch({ - renderSlot, call, cwd, home, openFile, inspectCall, loadImage, useDisclosure, t, + renderSlot, block, cwd, home, openFile, inspectCall, loadImage, useDisclosure, t, }: Pick & { - call: ToolCallPhaseProps + block: ToolCallBlock home?: string | undefined }) { + const call = useMemo(() => toolCallPhase(block), [block]) return ( info.home) - const call = useMemo(() => toolCallPhase(node.data.root), [node.data.root]) return ( { + 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( Date: Wed, 23 Sep 2026 13:39:48 +0800 Subject: [PATCH 23/28] test(chat): bind the preparation start spy --- packages/client/ui-chat/tests/tool-preparation.client.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/client/ui-chat/tests/tool-preparation.client.spec.ts b/packages/client/ui-chat/tests/tool-preparation.client.spec.ts index f09a6bae98..9f58f00d13 100644 --- a/packages/client/ui-chat/tests/tool-preparation.client.spec.ts +++ b/packages/client/ui-chat/tests/tool-preparation.client.spec.ts @@ -86,7 +86,7 @@ function harness(entries: readonly SessionEventLikeEntry[] = opening, hasMore = describe('Tool preparation and durable replay', () => { it('batches repeated named deltas and retains the unchanged Tool node', () => { - const start = vi.fn(toolDefinition.start) + 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]! From 913aa2289bd18875c6aef85cdff70233dd18bba8 Mon Sep 17 00:00:00 2001 From: imccyu Date: Wed, 23 Sep 2026 14:50:49 +0800 Subject: [PATCH 24/28] fix(chat): unify tool lifecycle titles and activity labels --- ...026-09-22-tool-call-three-phases.i18n.yaml | 4 +- .../2026-09-22-tool-call-three-phases.md | 2 +- .../2026-09-22-tool-call-three-phases.zh.md | 2 +- .../ui-chat/src/client/chat/ChatGroupSeat.tsx | 2 + .../src/client/contract/process-groups.ts | 2 +- .../conversation-nodes/README.i18n.yaml | 4 +- .../src/client/conversation-nodes/README.md | 14 ++-- .../client/conversation-nodes/README.zh.md | 14 ++-- .../conversation-nodes/process-activity.ts | 10 ++- packages/client/ui-chat/src/client/locale.ts | 12 ++++ .../tests/process-groups.client.spec.ts | 32 +++++++++ .../tests/tool-preparation.client.spec.ts | 2 +- .../ui-conversation/src/client/locales.ts | 18 ++--- packages/client/ui-tool/README.i18n.yaml | 4 +- packages/client/ui-tool/README.md | 4 +- packages/client/ui-tool/README.zh.md | 4 +- .../src/client/tool/components/ToolRow.tsx | 5 +- .../src/client/tool/models/tool-call-model.ts | 65 ++++++++++++++----- .../client/tool/toolviews/GenericToolCard.tsx | 2 +- .../tool/toolviews/ask-question-row.tsx | 2 +- .../src/client/tool/toolviews/details-row.tsx | 41 +----------- .../src/client/tool/toolviews/todo-row.tsx | 2 +- .../tests/ask-question-row.client.spec.tsx | 10 +-- .../tests/chat-ptc-subcalls.client.spec.tsx | 2 +- .../tests/preparing-rows.client.spec.tsx | 27 ++++++++ .../ui-tool/tests/todo-row.client.spec.tsx | 8 +-- .../ui-tool/tests/tool-row.client.spec.tsx | 2 +- .../tests/toolview-slot.client.spec.tsx | 2 +- snapshots/web/tool-details/ui.expected.md | 10 +-- 29 files changed, 191 insertions(+), 117 deletions(-) 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 index b97cd650a1..bfbfe3cdf3 100644 --- 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 @@ -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-22-tool-call-three-phases.md -2026-09-22-tool-call-three-phases.md: 6b1943e9f30903d9a7f2c9b8cf87dceeae6cae92 -2026-09-22-tool-call-three-phases.zh.md: a03d20dac236af53b664bb4d2e7b505ef61d6102 +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 index 6b1943e9f3..e7b42a4ed9 100644 --- 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 @@ -40,7 +40,7 @@ Groups include Tool nodes from preparation onward and classify and count them by ### 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 returns empty presentation fields for preparing, and the shared argument parser returns null without parsing JSON. ToolRow prevents preparation rows from expanding. +[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. 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 index a03d20dac2..5a68575fd8 100644 --- 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 @@ -40,7 +40,7 @@ Group 从准备阶段起就聚合 Tool 节点,按工具名分类和计数。As ### 通用行复用现有外壳 -[ToolRow](../../../../packages/client/ui-tool/src/client/tool/components/ToolRow.tsx) 在三个阶段复用既有图标、标题和行组件。公共行模型为 preparing 返回空展示字段,共享参数解析入口直接返回 null,不解析 JSON;ToolRow 禁止准备行展开。 +[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 维护另一份工具分类名单。 diff --git a/packages/client/ui-chat/src/client/chat/ChatGroupSeat.tsx b/packages/client/ui-chat/src/client/chat/ChatGroupSeat.tsx index 9c28562f44..2944f85c38 100644 --- a/packages/client/ui-chat/src/client/chat/ChatGroupSeat.tsx +++ b/packages/client/ui-chat/src/client/chat/ChatGroupSeat.tsx @@ -37,8 +37,10 @@ interface LiveProcessTitle { const PROCESS_ICONS: Record = { thinking: , read: , + readImage: , search: , edit: , + write: , commands: , code: , webSearch: , 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 a5c0e41f8a..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. */ 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 951890377a..700b1bba54 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: 5da692fefddcd3d3b01267c27f9f125472813d95 -README.zh.md: ac10f4f0789533eb71d165efe1cba55c196d12f7 +README.md: 3b6b82685c763837e706c60d8ea69b896f582ae2 +README.zh.md: d599bcc3fe63ff2e5b7e35fc371af51d066ea25b 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 5da692fefd..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,7 +122,7 @@ A closed group's header names the first three categories from its ranked summary ### Group-title rules -A preparing Tool node uses its category's preparation label and its wire name as detail. 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. +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. @@ -130,7 +130,9 @@ The labels below describe recorded activity, not successful outcomes. For exampl |---|---|---| | 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 | @@ -227,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` | @@ -237,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 @@ -258,7 +262,7 @@ 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 and show only the tool name. Dispatched calls use their tool/call time and complete arguments. +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. 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 ac10f4f078..d599bcc3fe 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,7 +122,7 @@ description: "Chat 过程分组与活动摘要的明细业务规则。" ### 组标题规则 -准备中的 Tool 节点使用对应类别的准备文案,详情只有协议中的工具名。它计为一次调用,不解析参数,只渲染不可展开的一行。具名实时 delta 可以创建该节点;历史调用直接从 tool/call 开始,不重现准备过程。 +三个阶段共用下方工具名分类。准备中的 Tool 节点使用对应类别的准备文案;`read` 为「准备读取文件」,`read_image` 为「准备读取图片」,`write` 为「准备写入文件」,`edit` 和 `apply_patch` 为「准备编辑文件」,`todo_write` 及目标工具为「准备更新计划」。只有通用类别的「准备调用工具」在 Detailed 中追加协议工具名,其他类别不追加。它计为一次调用,不解析参数,只渲染不可展开的一行。具名实时 delta 可以创建该节点;历史调用直接从 tool/call 开始,不重现准备过程。 以下文案描述已记录的活动,不保证操作成功;例如失败的读取仍参与「已读取文件」类别。 @@ -130,7 +130,9 @@ description: "Chat 过程分组与活动摘要的明细业务规则。" |---|---|---| | 无实时类别 / 无计数类别 | 正在分析请求 | 已完成分析 | | `read` | 正在读取文件 | 已读取文件 | +| `readImage` | 正在读取图片 | 已读取图片 | | `search` | 正在搜索代码 | 已搜索代码 | +| `write` | 正在写入文件 | 已写入文件 | | `edit` | 正在编辑文件 | 修改了文件 | | `commands` | 正在运行命令 | 执行了命令 | | `code` | 正在运行代码 | 运行了代码 | @@ -227,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` | @@ -237,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、后台任务与嵌套调用 @@ -258,7 +262,7 @@ Subagent 工具遵循普通分组边界,不会仅因委派任务就单独成 ### 实时活动与详情 -准备中的调用使用首个具名 delta 的时间,详情只有工具名。已派发调用使用 tool/call 的时间和完整参数。 +准备中的调用使用首个具名 delta 的时间,只有通用工具类别提供工具名详情。已派发调用使用 tool/call 的时间和完整参数。 运行中的调用以最大 `time` 决定实时类别和详情;时间相同时取后遍历的调用。没有运行调用时,类别为空,详情取成员顺序中最近一个含有非空推理段落的运行中 Assistant 的最后一个非空段落。推理详情移除 `**` 标记,不要求首行以换行结束;单个推理行的预览另有规则。 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 c234ad8abd..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' @@ -105,7 +107,9 @@ export function processActivity(nodes: readonly ChatNode[]): ProcessActivitySumm if (isRunningTool(tool) && tool.time >= runningTime) { running = kind preparing = tool.phase === 'preparing' - runningDetail = tool.phase === 'preparing' ? tool.name : liveToolDetail(tool.name, tool.argsRaw) + runningDetail = tool.phase === 'preparing' + ? kind === 'tools' ? tool.name : '' + : liveToolDetail(tool.name, tool.argsRaw) runningTime = tool.time } counts.set(kind, (counts.get(kind) ?? 0) + 1) diff --git a/packages/client/ui-chat/src/client/locale.ts b/packages/client/ui-chat/src/client/locale.ts index b5c69924ff..9e188c9dc6 100644 --- a/packages/client/ui-chat/src/client/locale.ts +++ b/packages/client/ui-chat/src/client/locale.ts @@ -7,6 +7,8 @@ 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': '正在运行命令', @@ -18,6 +20,8 @@ export const zh = { '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': '准备运行命令', @@ -30,6 +34,8 @@ export const zh = { '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': '执行了命令', @@ -176,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', @@ -187,6 +195,8 @@ export const en = { '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', @@ -199,6 +209,8 @@ export const en = { '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/tests/process-groups.client.spec.ts b/packages/client/ui-chat/tests/process-groups.client.spec.ts index 0fa62a607b..5581a245f9 100644 --- a/packages/client/ui-chat/tests/process-groups.client.spec.ts +++ b/packages/client/ui-chat/tests/process-groups.client.spec.ts @@ -67,6 +67,38 @@ function harness(nodes: ChatNode[], currentTimeline = timeline) { } describe('Definition-owned Chat process groups', () => { + 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/tool-preparation.client.spec.ts b/packages/client/ui-chat/tests/tool-preparation.client.spec.ts index 9f58f00d13..345ff43672 100644 --- a/packages/client/ui-chat/tests/tool-preparation.client.spec.ts +++ b/packages/client/ui-chat/tests/tool-preparation.client.spec.ts @@ -118,7 +118,7 @@ describe('Tool preparation and durable replay', () => { 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: 'edit', count: 2 }], preparing: true, + counts: [{ kind: 'write', count: 2 }], preparing: true, runningDetail: '', }) h.assembler.append(call(6)) expect(h.tools()[0]?.key).toBe(original.key) diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts index 2ab66377cf..826b998e18 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': '无', @@ -295,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 插件', @@ -483,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', @@ -512,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', @@ -648,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-tool/README.i18n.yaml b/packages/client/ui-tool/README.i18n.yaml index a37c471ad9..7ac23e5713 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: 39b05236deae913d8be31be7846cac6f853b7eca -README.zh.md: e06ec350ad299593c82a216949fcdfa713122387 +README.md: 5a09b479cd1111b0e7bf6a8bacfa5af3affff522 +README.zh.md: 5dc041a6f1b2a1b7ff75e0af8f44a6bbecc5e8fd diff --git a/packages/client/ui-tool/README.md b/packages/client/ui-tool/README.md index 39b05236de..5a09b479cd 100644 --- a/packages/client/ui-tool/README.md +++ b/packages/client/ui-tool/README.md @@ -29,7 +29,7 @@ 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. 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. +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 @@ -47,7 +47,7 @@ The owner payload is `ToolCallOwnerProps`: `callId`, `toolName`, the frozen `blo ### 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 and retain their existing icon and title. The common row model returns empty presentation fields during preparation, 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`. +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`. diff --git a/packages/client/ui-tool/README.zh.md b/packages/client/ui-tool/README.zh.md index e06ec350ad..5dc041a6f1 100644 --- a/packages/client/ui-tool/README.zh.md +++ b/packages/client/ui-tool/README.zh.md @@ -29,7 +29,7 @@ kind: "package-reference" 共享工具行和 Bash 行的失败、停止摘要在悬停时仍保留错误色和警告色;只有不处于这两种状态的摘要会在悬停时加深。 -派发前,模型已给出名称的调用显示为不可展开的一行,使用工具自己的图标与标题。准备阶段不提供参数正文、文件链接、结果或依赖参数的交互。`tool/call` 才启用既有调用展示;参数块结束本身不代表开始执行。 +派发前,模型已给出名称的调用显示为不可展开的一行,使用工具自己的图标与标题。通用行显示为`工具调用 · <工具名>`。准备阶段不提供参数正文、文件链接、结果或依赖参数的交互。`tool/call` 才启用既有调用展示;参数块结束本身不代表开始执行。 ### 注册业务工具视图 @@ -47,7 +47,7 @@ owner 载荷为 `ToolCallOwnerProps`:`callId`、`toolName`、冻结的 `block` ### 内置视图 -每个注册视图都接收[工具 slot 类型](src/client/contract/slots.ts)声明的显式 `preparing`、`start` 和 `result` props。通用行在三个阶段使用同一个 `ToolRow`,沿用既有图标和标题。公共行模型在准备阶段返回空展示字段,共享参数解析入口直接返回无调用,不解析 JSON。Bash、Skill、Cordis 等自定义 renderer 分别处理准备态,其依赖参数的组件接收 `StartedToolCallViewProps`。 +每个注册视图都接收[工具 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` 注册项。 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 0544d3ce1c..618344b4c7 100644 --- a/packages/client/ui-tool/src/client/tool/components/ToolRow.tsx +++ b/packages/client/ui-tool/src/client/tool/components/ToolRow.tsx @@ -106,7 +106,7 @@ function stateStatus(state: ToolRowState, t: TranslateNS<'conversation'>): strin /** * Render one localized tool summary and lazily mounted result card. - * Preparation retains the icon and title without a summary or disclosure. + * 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. */ @@ -167,8 +167,7 @@ export const ToolRow = memo(function ToolRow({ ) const status = stateStatus(state, t) const running = state === 'running' || state === 'preparing' - const normalSummary = state === 'preparing' ? '' - : terminalBody?.description ?? (open ? detailsBody?.expandedSummary ?? summary : summary) + 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 // amber while retaining the business icon and hidden state announcement. 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 f2c13d3b3d..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 @@ -23,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 = { @@ -79,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', } /** @@ -103,6 +141,7 @@ export function toolTitleKey(toolName: string): ToolTitleKey { 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 @@ -244,24 +283,14 @@ export function toolRowModel(toolName: string, block: ToolCallBlock, cwd?: strin const variant = classifyTool(toolName) const titleKey = toolTitleKey(toolName) const done = 'kind' in block - if (!done && block.phase === 'preparing') { - return { - variant, titleKey, state: 'preparing', summary: '', filePath: undefined, - bodyRaw: null, output: null, errorSummary: null, autoReviewDenial: null, - } - } - 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) - // 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 !== '' && TOOL_TITLE_KEYS[toolName] === 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. @@ -272,7 +301,7 @@ export function toolRowModel(toolName: string, block: ToolCallBlock, cwd?: strin 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 75bff722da..bc0faacb13 100644 --- a/packages/client/ui-tool/src/client/tool/toolviews/GenericToolCard.tsx +++ b/packages/client/ui-tool/src/client/tool/toolviews/GenericToolCard.tsx @@ -53,7 +53,7 @@ export function GenericToolCard({ toolName, block, cwd, home, openFile, inspect, variant={model.variant} toolName={toolName} icon={VARIANT_ICONS[model.variant]} - title={model.state === 'preparing' && model.titleKey === 'tool.title.generic' ? toolName : t(model.titleKey)} + title={t(model.titleKey)} summary={model.summary} // Single-file tools never expose an args body — the path link is the only // args interaction. A card is not an args body: a read/write/edit row is 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 cc3acf64d7..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 @@ -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/details-row.tsx b/packages/client/ui-tool/src/client/tool/toolviews/details-row.tsx index a7abe7f641..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 @@ -16,45 +16,6 @@ import { toolRowModel } from '../models/tool-call-model.ts' type DetailsRowProps = ToolCallViewProps & PropsLocale<'conversation'> -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 - const LSP_TITLE_KEYS = { goToDefinition: 'tool.title.findDefinition', findReferences: 'tool.title.findReferences', @@ -89,7 +50,7 @@ export function DetailsRow({ toolName, block, cwd, home, openFile, inspect, useD 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 ( } - 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 27fa48a594..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 @@ -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 23120ba2ce..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 @@ -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 diff --git a/packages/client/ui-tool/tests/preparing-rows.client.spec.tsx b/packages/client/ui-tool/tests/preparing-rows.client.spec.tsx index c975c696fe..1140e6498c 100644 --- a/packages/client/ui-tool/tests/preparing-rows.client.spec.tsx +++ b/packages/client/ui-tool/tests/preparing-rows.client.spec.tsx @@ -74,6 +74,33 @@ describe('argument-free tool preparation', () => { 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() 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 967ab2b8c9..a2bb96fcc3 100644 --- a/packages/client/ui-tool/tests/todo-row.client.spec.tsx +++ b/packages/client/ui-tool/tests/todo-row.client.spec.tsx @@ -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-row.client.spec.tsx b/packages/client/ui-tool/tests/tool-row.client.spec.tsx index cba4fb817a..f9338cac00 100644 --- a/packages/client/ui-tool/tests/tool-row.client.spec.tsx +++ b/packages/client/ui-tool/tests/tool-row.client.spec.tsx @@ -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/snapshots/web/tool-details/ui.expected.md b/snapshots/web/tool-details/ui.expected.md index 4a7cc83df9..0014fc70d6 100644 --- a/snapshots/web/tool-details/ui.expected.md +++ b/snapshots/web/tool-details/ui.expected.md @@ -244,7 +244,7 @@ - listitem: - paragraph: terminal_list - button "Inspect" -- button "Run Ralph 已检查详情区域的换行和工具名称显示。" [expanded] +- button "Run ralph loop 已检查详情区域的换行和工具名称显示。" [expanded] - text: Recorded result - list: - listitem: @@ -383,7 +383,7 @@ - text: deepseek/deepseek-reasoner - paragraph: DeepSeek Reasoner - button "Inspect" -- button "Delegate task 检查工具卡片布局,并列出三个最值得改进的地方。" [expanded] +- button "Create subagent 检查工具卡片布局,并列出三个最值得改进的地方。" [expanded] - text: Recorded result - list: - listitem: @@ -391,7 +391,7 @@ - term: Agent ID - definition: agent-demo-review - button "Inspect" -- button "List agents 3 agents" [expanded] +- button "List subagents 3 agents" [expanded] - text: Recorded result - list: - listitem: @@ -574,7 +574,7 @@ - term: Context - definition: fresh - button "Inspect" -- button "List agents 3 agents" [expanded] +- button "List subagents 3 agents" [expanded] - text: Recorded result - list: - listitem: @@ -703,7 +703,7 @@ - listitem: - paragraph: task-demo-1 - button "Inspect" -- button "Wait for teammates Teammate activity" [expanded] +- button "Wait for subagent Teammate activity" [expanded] - text: Recorded result - list: - listitem: Teammate activity Change detected From b66b602c729fef1adc771c611d1301b0ea253be9 Mon Sep 17 00:00:00 2001 From: imccyu Date: Wed, 23 Sep 2026 15:18:00 +0800 Subject: [PATCH 25/28] fix(chat): address preparing lifecycle review feedback --- apps/web/tests/ptc-round.e2e.ts | 4 +- .../conversation-nodes/process-groups.ts | 2 +- .../tests/process-groups.client.spec.ts | 18 ++++++++ .../tests/tool-preparation.client.spec.ts | 16 ++++++++ .../client/ui-conversation/README.i18n.yaml | 4 +- packages/client/ui-conversation/README.md | 2 +- packages/client/ui-conversation/README.zh.md | 2 +- .../src/client/conversation/assembler.ts | 2 + .../conversation-assembler.client.spec.ts | 41 ++++++++++++++++++- .../ui-deliverables/src/client/PresentRow.tsx | 8 ++-- .../tests/present-row.client.spec.tsx | 3 +- packages/client/ui-tool/README.i18n.yaml | 4 +- packages/client/ui-tool/README.md | 2 +- packages/client/ui-tool/README.zh.md | 2 +- .../ui-tool/src/client/contract/slots.ts | 19 +++------ packages/client/ui-tool/src/client/index.ts | 2 +- .../cordis-client-runner/README.i18n.yaml | 4 +- .../extensions/cordis-client-runner/README.md | 4 ++ .../cordis-client-runner/README.zh.md | 4 ++ .../src/client/slot-catalog.ts | 9 +++- .../tests/providers.client.spec.ts | 24 +++++++++++ .../src/client/CordisPreparingRow.tsx | 13 ++++-- .../tests/status-icons.client.spec.tsx | 2 +- scripts/gen-client-catalog.spec.ts | 33 +++++++++++++++ scripts/gen-client-catalog.ts | 31 ++++++++++---- 25 files changed, 209 insertions(+), 46 deletions(-) diff --git a/apps/web/tests/ptc-round.e2e.ts b/apps/web/tests/ptc-round.e2e.ts index 85f63f2fe7..4c95f6f14a 100644 --- a/apps/web/tests/ptc-round.e2e.ts +++ b/apps/web/tests/ptc-round.e2e.ts @@ -92,7 +92,7 @@ describe('web e2e: PTC mode round renders nested sub-calls', () => { } } const [sessionId] = await Promise.all([settled, observePreparation()]) - expect(await page.locator('[data-state="preparing"]').count()).toBe(0) + await expect.poll(() => page.locator('[data-state="preparing"]').count(), { timeout: 15_000 }).toBe(0) if (MODE === 'record') { await recordFixture(scaffold, sessionId, FIXTURE) } @@ -150,7 +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 }) - expect(await page.locator('[data-state="preparing"]').count()).toBe(0) + 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/packages/client/ui-chat/src/client/conversation-nodes/process-groups.ts b/packages/client/ui-chat/src/client/conversation-nodes/process-groups.ts index 51c482cd7c..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 @@ -67,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/tests/process-groups.client.spec.ts b/packages/client/ui-chat/tests/process-groups.client.spec.ts index 5581a245f9..2f98938274 100644 --- a/packages/client/ui-chat/tests/process-groups.client.spec.ts +++ b/packages/client/ui-chat/tests/process-groups.client.spec.ts @@ -67,6 +67,24 @@ 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'], diff --git a/packages/client/ui-chat/tests/tool-preparation.client.spec.ts b/packages/client/ui-chat/tests/tool-preparation.client.spec.ts index 345ff43672..c458a0869f 100644 --- a/packages/client/ui-chat/tests/tool-preparation.client.spec.ts +++ b/packages/client/ui-chat/tests/tool-preparation.client.spec.ts @@ -162,6 +162,22 @@ describe('Tool preparation and durable replay', () => { 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)) diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 6c8859d5fd..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: 83ca1b89b1b459d5d562b5d458987bbf91f04e9a -README.zh.md: c5c2299a934824227fb151d3ecd479893c48d231 +README.md: 153c505250f5654f0aeade23c221e19554294641 +README.zh.md: c849b1bdfd033e07cac382c4b62cd0df35333c8e diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 83ca1b89b1..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. 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. 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. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index c5c2299a93..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 则可以携带任一种。持久与瞬态事件使用同一组 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。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。 diff --git a/packages/client/ui-conversation/src/client/conversation/assembler.ts b/packages/client/ui-conversation/src/client/conversation/assembler.ts index cbebd2d66b..0ab42da1c2 100644 --- a/packages/client/ui-conversation/src/client/conversation/assembler.ts +++ b/packages/client/ui-conversation/src/client/conversation/assembler.ts @@ -262,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. diff --git a/packages/client/ui-conversation/tests/conversation-assembler.client.spec.ts b/packages/client/ui-conversation/tests/conversation-assembler.client.spec.ts index d017453adb..c9cd56ea7a 100644 --- a/packages/client/ui-conversation/tests/conversation-assembler.client.spec.ts +++ b/packages/client/ui-conversation/tests/conversation-assembler.client.spec.ts @@ -2,7 +2,7 @@ import { describe, expect, it, vi } from 'vitest' import type { SessionEventLike, SessionEventLikeEntry, SessionLiveEventEntry, } from '@deepseek-ai/dsh-api-session-controller/client' -import { LlmAttemptId } from '@deepseek-ai/dsh-llm/brand' +import { LlmAttemptId, ToolCallId } from '@deepseek-ai/dsh-llm/brand' import type { StreamChunk } from '@deepseek-ai/dsh-llm' import { SessionSeq } from '@deepseek-ai/dsh-session/types' import type { SessionEvent } from '@deepseek-ai/dsh-session/types' @@ -919,6 +919,45 @@ describe('ConversationNodeAssembler', () => { expect([...testSnapshot(assembler)?.nodes.values() ?? []][0]?.data).toBe(-1) }) + it('reorders predecessor reads when durable calls replace transient starts after dispatch', () => { + const source: ConversationNodeDefinition = { + kind: 'reindexed-source', + match: (event) => { + if (event.type === 'assistant/live-chunk' && event.data.chunk.type === 'tool-call-delta') { + return { id: String(event.data.chunk.id), role: 'start' } + } + return event.type === 'tool/call' ? { id: String(event.data.callId), role: 'start' } : null + }, + start: (_context, match) => match.event.seq, + update: context => context.state, + target: 'test', buildViewNode: () => null, + } + const consumer: ConversationNodeDefinition = { + kind: 'consumer', + match: event => event.type === 'step/end' ? { id: String(event.seq), role: 'start' } : null, + start: (_context, _match, reader) => reader.previous('reindexed-source')?.state ?? -1, + update: context => context.state, + target: 'test', buildViewNode: context => node(context, context.state), + } + const assembler = new ConversationNodeAssembler( + new TestEventDefinitions([source, consumer]), new TestViewDefinitions([testView()]), + ) + assembler.replaceWindow([], false) + for (const [index, id] of ['a', 'b'].entries()) { + assembler.append(transientChunk(1.1 + index / 10, 1, 1, { + type: 'tool-call-delta', index, id: ToolCallId(id), name: 'write', argumentsDelta: '', + })) + } + assembler.append(input(at(SessionSeq(3), 'tool/call', { turn: 1, step: 1, callId: ToolCallId('b'), name: 'write', arguments: '{}' }))) + assembler.append(input(at(SessionSeq(4), 'tool/call', { turn: 1, step: 1, callId: ToolCallId('a'), name: 'write', arguments: '{}' }))) + assembler.append(input(at(SessionSeq(5), 'step/end', { turn: 1, step: 1 }))) + assembler.flush() + expect([...testSnapshot(assembler)!.nodes.values()][0]?.data).toBeCloseTo(1.2) + assembler.settleAssistant(LlmAttemptId('test-attempt')) + assembler.flush() + expect([...testSnapshot(assembler)!.nodes.values()][0]?.data).toBe(4) + }) + it('replays direct dependents when an append revises their predecessor Context', () => { const source: ConversationNodeDefinition = { kind: 'source', diff --git a/packages/client/ui-deliverables/src/client/PresentRow.tsx b/packages/client/ui-deliverables/src/client/PresentRow.tsx index 29a41bf8be..57d6006f3f 100644 --- a/packages/client/ui-deliverables/src/client/PresentRow.tsx +++ b/packages/client/ui-deliverables/src/client/PresentRow.tsx @@ -8,6 +8,9 @@ import css from './PresentRow.module.css' type PresentRowProps = ToolCallViewProps & PropsLocale +/* v8 ignore next -- Non-expandable rows never invoke DisclosureRow's required toggle callback. */ +const noop = (): void => undefined + /** Raw arguments can be partial while a call is streaming. */ function fileNames(raw: string): string { let args: unknown @@ -29,11 +32,10 @@ export function PresentRow(props: PresentRowProps) { return props.phase === 'preparing' ? : } -function PreparingPresentRow({ t, useDisclosure }: Extract) { - const { toggle } = useDisclosure() +function PreparingPresentRow({ t }: Extract) { return
} - open={false} expandable={false} onToggle={toggle} running /> + open={false} expandable={false} onToggle={noop} running />
} diff --git a/packages/client/ui-deliverables/tests/present-row.client.spec.tsx b/packages/client/ui-deliverables/tests/present-row.client.spec.tsx index 411d393964..ee01d9b093 100644 --- a/packages/client/ui-deliverables/tests/present-row.client.spec.tsx +++ b/packages/client/ui-deliverables/tests/present-row.client.spec.tsx @@ -5,7 +5,6 @@ import { afterEach, expect, it, vi } from 'vitest' import type { StartedToolCall, ToolResultNode } from '@deepseek-ai/dsh-client-ui-chat/client' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { PresentRow } from '../src/client/PresentRow.tsx' -import { useDisclosure } from '@deepseek-ai/dsh-client-ui-chat/src/client/chat/use-disclosure.ts' import { en } from '../src/client/locales.ts' afterEach(cleanup) @@ -34,12 +33,14 @@ it('discloses the saved result and offers call inspection', () => { }) it('shows a non-expandable preparation before delivery arguments exist', () => { + const useDisclosure = vi.fn(() => { throw new Error('preparation must not subscribe to disclosure resets') }) const view = render() expect(view.container.querySelector('[data-state="preparing"] svg')).not.toBeNull() expect(view.queryByRole('button')).toBeNull() expect(view.container.querySelector('pre')).toBeNull() + expect(useDisclosure).not.toHaveBeenCalled() }) it.each([ diff --git a/packages/client/ui-tool/README.i18n.yaml b/packages/client/ui-tool/README.i18n.yaml index 7ac23e5713..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: 5a09b479cd1111b0e7bf6a8bacfa5af3affff522 -README.zh.md: 5dc041a6f1b2a1b7ff75e0af8f44a6bbecc5e8fd +README.md: a00ed8e8c78dce29144a3ff89d2eb671b5f209fb +README.zh.md: c4a6f6183be082d8c21877b0c1c7052652bc7e2e diff --git a/packages/client/ui-tool/README.md b/packages/client/ui-tool/README.md index 5a09b479cd..a00ed8e8c7 100644 --- a/packages/client/ui-tool/README.md +++ b/packages/client/ui-tool/README.md @@ -43,7 +43,7 @@ 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 diff --git a/packages/client/ui-tool/README.zh.md b/packages/client/ui-tool/README.zh.md index 5dc041a6f1..c4a6f6183b 100644 --- a/packages/client/ui-tool/README.zh.md +++ b/packages/client/ui-tool/README.zh.md @@ -43,7 +43,7 @@ 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 节点或运行时服务。 ### 内置视图 diff --git a/packages/client/ui-tool/src/client/contract/slots.ts b/packages/client/ui-tool/src/client/contract/slots.ts index 3919becdff..3ebda6f301 100644 --- a/packages/client/ui-tool/src/client/contract/slots.ts +++ b/packages/client/ui-tool/src/client/contract/slots.ts @@ -50,10 +50,10 @@ export interface ToolImagesOwnerProps { } /** Standard owner currency supplied to every atomic Tool view. */ -interface ToolCallCommonProps { - /** 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 @@ -61,18 +61,9 @@ interface ToolCallCommonProps { 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 diff --git a/packages/client/ui-tool/src/client/index.ts b/packages/client/ui-tool/src/client/index.ts index 73232825fe..bf79129892 100644 --- a/packages/client/ui-tool/src/client/index.ts +++ b/packages/client/ui-tool/src/client/index.ts @@ -1,6 +1,6 @@ /** Browser Tool plugin: whole-call composition and keyed atomic Tool views. */ export { apply, inject } from './apply.ts' export type { - StartedToolCallViewProps, ToolCallOwnerProps, ToolCallPhaseProps, ToolCallViewProps, + StartedToolCallViewProps, ToolCallCommonProps, ToolCallOwnerProps, ToolCallPhaseProps, ToolCallViewProps, ToolHostInfoInjected, ToolTreeProps, } from './contract/slots.ts' diff --git a/packages/extensions/cordis-client-runner/README.i18n.yaml b/packages/extensions/cordis-client-runner/README.i18n.yaml index 7d9e255da8..54c615917f 100644 --- a/packages/extensions/cordis-client-runner/README.i18n.yaml +++ b/packages/extensions/cordis-client-runner/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/extensions/cordis-client-runner/README.md -README.md: aa87e2919be899539bea8aff00d072553f339c38 -README.zh.md: f4925e4bd52098884c6a25f2d9504ce9a9d667f0 +README.md: c5d9a10bde22c6e48b23cd113737a80cc03bcdd0 +README.zh.md: c6463de9cd03f1b0de8a9a8fee3037fe01392607 diff --git a/packages/extensions/cordis-client-runner/README.md b/packages/extensions/cordis-client-runner/README.md index aa87e2919b..c5d9a10bde 100644 --- a/packages/extensions/cordis-client-runner/README.md +++ b/packages/extensions/cordis-client-runner/README.md @@ -66,6 +66,10 @@ The browser half is built on one principle: a dynamic package must ride the same | [`src/client/providers.ts`](src/client/providers.ts) | First-party client Inspect Providers (slots, theme, events) | | [`src/client/timer.ts`](src/client/timer.ts) | The client timer service dynamic packages inject | +### Inspecting slot inputs + +The Slots provider's exact `listSubTree` lookup includes documented owner fields and top-level alias, intersection, and union declarations. Nested field-value types remain named references, so inspecting a Tool view exposes its shared callbacks and stage union without expanding the full Session graph. + ### How a run is carried out A `cordis/request-run` event asks this page whether to run a definition. Whoever answers — the page after an approval, or the user pressing run — drives the orchestration: the host half first (so a host-half failure short-circuits before the browser has moved), then the source fetch, then the browser half, then one resolution carrying what happened. The browser half's source is evaluated as an async function body with the symbol surface as parameters, the returned plugin is guard-wrapped and mounted through the loader, and the resolution reports the loaded revision or the failing stage with the closure's, guard's, or fiber's message. `host.call` routes through the Remote namespace; an omitted argument travels as `null`, and a payload the generated codec refuses becomes a teaching error naming the call and the contract. diff --git a/packages/extensions/cordis-client-runner/README.zh.md b/packages/extensions/cordis-client-runner/README.zh.md index f4925e4bd5..c6463de9cd 100644 --- a/packages/extensions/cordis-client-runner/README.zh.md +++ b/packages/extensions/cordis-client-runner/README.zh.md @@ -66,6 +66,10 @@ kind: "package-reference" | [`src/client/providers.ts`](src/client/providers.ts) | 第一方 client Inspect Provider(slots、theme、events) | | [`src/client/timer.ts`](src/client/timer.ts) | 动态包注入的 client 定时器服务 | +### 查询槽位输入 + +Slots provider 的精确 `listSubTree` 查询包含带说明的 owner 字段及顶层别名、交叉类型和联合类型声明。嵌套字段的值类型只列出引用名称,因此查询 Tool 视图可以获得共享回调和阶段联合,而不会展开完整 Session 类型图。 + ### 一次 run 如何执行 一条 `cordis/request-run` 事件问这一页要不要运行某个定义。作答的一方——审批后的页面,或按下运行的用户——驱动编排:先 host 半(host 半失败会在浏览器动作之前短路),再取源码,再浏览器半,最后一次结算带上发生的一切。浏览器半源码作为 async 函数体求值,符号面就是参数;返回的插件经 guard 包装后通过 loader 挂载;结算报告已装载的 revision,或失败阶段加闭包、guard 或 fiber 的消息。`host.call` 经 Remote namespace 路由;省略的入参以 `null` 过线,而生成 codec 拒收的载荷会变成一条点名「哪次调用 + 约定是什么」的教学错误。 diff --git a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts index 0a7c199474..85a21e28c0 100644 --- a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts @@ -3832,10 +3832,17 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ }, ], ownerProps: [ + '/** Standard owner currency supplied to every atomic Tool view. */\nexport interface ToolCallCommonProps {\n /** Stable Hook; each invocation owns its open state and subscribes to enclosing-Turn resets. */\n useDisclosure: UseDisclosure\n /** Call identity, stable across all stages. */\n callId: string\n /** Wire Tool name and keyed dispatch value. */\n toolName: string\n /** Session workspace root for relative summaries. */\n cwd?: string | undefined\n /** Host account home; POSIX home-rooted summaries display as `~`. */\n home?: string | undefined\n /** Open an argument path at its optional requested line. */\n openFile: (path: string, options?: OpenFileOptions) => void\n /** Chat-supplied, session-authorized loader for durable images; Tool views do not manage attachment URLs. */\n loadImage: MessageImageLoader\n /** Inspect this call in the trajectory view when available. */\n inspect?: (() => void) | undefined\n}', '/** Common owner callbacks and the data admitted at the current tool stage. */\nexport type ToolCallOwnerProps = ToolCallCommonProps & ToolCallPhaseProps', + '/** Stage-specific tool data; only start/result expose the dispatched call material. */\nexport type ToolCallPhaseProps =\n | { readonly phase: \'preparing\'; readonly block: PreparingToolCall }\n | { readonly phase: \'start\'; readonly block: StartedToolCall }\n | { readonly phase: \'result\'; readonly block: ToolResultNode }', ], ownerPropsReferences: [ - 'ToolCallPhaseProps', + 'MessageImageLoader', + 'OpenFileOptions', + 'PreparingToolCall', + 'StartedToolCall', + 'ToolResultNode', + 'UseDisclosure', ], standardProps: [ 'useResource: UseResource', diff --git a/packages/extensions/cordis-client-runner/tests/providers.client.spec.ts b/packages/extensions/cordis-client-runner/tests/providers.client.spec.ts index 554072167b..9965ae63c6 100644 --- a/packages/extensions/cordis-client-runner/tests/providers.client.spec.ts +++ b/packages/extensions/cordis-client-runner/tests/providers.client.spec.ts @@ -7,6 +7,30 @@ import { clientInspectProviders } from '../src/client/providers.ts' const Component = () => null describe('Client inspect providers', () => { + it('exposes Tool owner fields and phases through the live slot lookup', async () => { + const ctx = new Context() + const fiber = ctx.plugin(SlotRegistry) + try { + await fiber + const slots = ctx.slots as { registerFactory(options: object, component: unknown): () => void } + slots.registerFactory({ + name: 'provider.tool-owner', scope: 'root', + children: { 'tool.call.toolview': { kind: 'keyed', scope: 'session' } }, + }, Component) + const provider = clientInspectProviders(ctx).find(item => item.manifest.id === 'Slots')! + const result = await provider.query('listSubTree', { root: 'tool.call.toolview' }, {} as never) + const selected = result as { selected: { catalog: { ownerProps: string[] } } } + const owner = selected.selected.catalog.ownerProps.join('\n') + for (const field of ['callId', 'toolName', 'useDisclosure', 'cwd', 'home', 'openFile', 'loadImage', 'inspect']) { + expect(owner).toMatch(new RegExp(`\\b${field}\\??:`)) + } + for (const phase of ['preparing', 'start', 'result']) expect(owner).toContain(`phase: '${phase}'`) + expect(owner).not.toContain('truncated') + } finally { + await fiber.dispose() + } + }) + it('reports strictly discriminated Slot and Factory topology nodes', async () => { const ctx = new Context() const fiber = ctx.plugin(SlotRegistry) diff --git a/packages/extensions/ui-cordis/src/client/CordisPreparingRow.tsx b/packages/extensions/ui-cordis/src/client/CordisPreparingRow.tsx index 12f6ec7f02..b1b24ef3fa 100644 --- a/packages/extensions/ui-cordis/src/client/CordisPreparingRow.tsx +++ b/packages/extensions/ui-cordis/src/client/CordisPreparingRow.tsx @@ -4,7 +4,10 @@ import { DisclosureRow } from '@deepseek-ai/dsh-client-ui-primitives' import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' import type { ToolCallViewProps } from '@deepseek-ai/dsh-client-ui-tool/client' -type CordisPreparingRowProps = Pick & PropsLocale<'cordis'> & { +/* v8 ignore next -- Non-expandable rows never invoke DisclosureRow's required toggle callback. */ +const noop = (): void => undefined + +type CordisPreparingRowProps = Pick & PropsLocale<'cordis'> & { readonly icon: ReactNode readonly title: string readonly className?: string | undefined @@ -17,9 +20,11 @@ type CordisPreparingRowProps = Pick - +
} diff --git a/packages/extensions/ui-cordis/tests/status-icons.client.spec.tsx b/packages/extensions/ui-cordis/tests/status-icons.client.spec.tsx index 54ed1ba13a..ff5db2e814 100644 --- a/packages/extensions/ui-cordis/tests/status-icons.client.spec.tsx +++ b/packages/extensions/ui-cordis/tests/status-icons.client.spec.tsx @@ -37,7 +37,7 @@ describe('Cordis tool failure icons', () => { ...callbacks, phase: 'preparing', toolName, callId: 'call', t, block: { phase: 'preparing', name: toolName, callId: 'call', turn: 1, step: 1, time: 1, subCalls: [] }, - useInventory: unused, useLoaded: unused, useRunCards: unused, useActiveRuns: unused, + useInventory: unused, useLoaded: unused, useRunCards: unused, useActiveRuns: unused, useDisclosure: unused, } as Parameters[0] & Parameters[0] const view = render() expect(view.container.querySelector('[data-state="preparing"] svg')).not.toBeNull() diff --git a/scripts/gen-client-catalog.spec.ts b/scripts/gen-client-catalog.spec.ts index be02a03f9f..09cd3d1845 100644 --- a/scripts/gen-client-catalog.spec.ts +++ b/scripts/gen-client-catalog.spec.ts @@ -184,6 +184,30 @@ describe('client slot projection', () => { expect(entry?.example).toContain("id: 'my-entry'") }) + it('expands composed owner aliases without expanding their field value types', () => { + const types = new Map(OWNER_TYPES) + for (const [name, text] of [ + ['Phase', "export type Phase = { phase: 'preparing'; block: BigSnapshot } | { phase: 'start'; block: BigSnapshot }"], + ['Owner', 'export type Owner = DemoOwnerProps & (Phase | Alias)'], + ['Alias', 'export type Alias = Owner'], + ['BigSnapshot', 'export interface BigSnapshot { history: string[] }'], + ] as const) types.set(name, { name, text, source: 'owner.ts:1' }) + const [entry] = resolveSlotEntries([declaration({ ownerType: 'Owner' })], [], types, kits) + expect(entry?.ownerProps).toHaveLength(4) + expect(entry?.ownerProps.join('\n')).toContain('width: number') + expect(entry?.ownerProps.join('\n')).toContain("phase: 'preparing'") + expect(entry?.ownerProps.join('\n')).not.toContain('history: string[]') + expect(entry?.ownerPropsReferences).toEqual(['BigSnapshot']) + }) + + it('keeps generic owner arguments as references instead of expanding unselected fields', () => { + const types = new Map(OWNER_TYPES) + types.set('Owner', { name: 'Owner', text: "export type Owner = Pick", source: 'owner.ts:1' }) + const [entry] = resolveSlotEntries([declaration({ ownerType: 'Owner' })], [], types, kits) + expect(entry?.ownerProps).toHaveLength(1) + expect(entry?.ownerPropsReferences).toEqual(['DemoOwnerProps']) + }) + it('uses an authored example when a slot interaction needs more than generic markup', () => { const [entry] = resolveSlotEntries([ declaration({ jsDoc: '/** A seat.\n * @example\n * return { custom: true }\n */' }), @@ -227,5 +251,14 @@ describe('the real workspace surface', () => { expect(root?.occupants.join(' ')).toContain('AppFrame') expect(entries.find(entry => entry.key === 'conversation.session')?.declaredBy) .toContain("factory 'conversation.content' (client-ui-conversation)") + const tool = entries.find(entry => entry.key === 'tool.call.toolview')! + const owner = tool.ownerProps.join('\n') + for (const field of ['callId', 'toolName', 'useDisclosure', 'cwd', 'home', 'openFile', 'loadImage', 'inspect']) { + expect(owner).toMatch(new RegExp(`\\b${field}\\??:`)) + } + for (const phase of ['preparing', 'start', 'result']) expect(owner).toContain(`phase: '${phase}'`) + expect(owner).not.toContain('truncated') + expect(tool.ownerPropsReferences).not.toContain('ToolCallCommonProps') + expect(tool.ownerPropsReferences).not.toContain('ToolCallPhaseProps') }) }) diff --git a/scripts/gen-client-catalog.ts b/scripts/gen-client-catalog.ts index e4bb3f0933..0532775af7 100644 --- a/scripts/gen-client-catalog.ts +++ b/scripts/gen-client-catalog.ts @@ -14,6 +14,7 @@ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs' import { dirname, resolve } from 'node:path' +import ts from 'typescript' import { declaredTypes, indexExportedTypes, @@ -306,24 +307,40 @@ function entryOf( } /** - * The owner-props contract at ONE level: the owner declaration(s) themselves, - * plus the names of the shapes their fields reference. Expanding transitively - * pulled the whole session model into four seats (one report exceeded 2400 - * lines), which defeats the purpose of narrowing to a single slot — a registrant - * needs the fields and their documented meaning, not the type graph behind them. + * Owner declarations include the aliases that compose their top-level fields. + * Field value types remain references so one slot does not expand the Session graph. */ function ownerShapes( ownerType: string | undefined, types: ReadonlyMap, ): { declarations: TypeDeclaration[]; references: string[] } { if (ownerType === undefined) return { declarations: [], references: [] } - const declarations = declaredTypes(referencedTypeNames([ownerType], types), types) - const own = new Set(declarations.map(declaration => declaration.name)) + const own = new Map(declaredTypes(referencedTypeNames([ownerType], types), types) + .map(declaration => [declaration.name, declaration])) + for (const declaration of own.values()) { + const source = ts.createSourceFile(declaration.source, declaration.text, ts.ScriptTarget.Latest, true) + const alias = source.statements.find(ts.isTypeAliasDeclaration) + if (alias === undefined) continue + for (const composed of declaredTypes(ownerCompositionNames(alias.type), types)) { + own.set(composed.name, composed) + } + } + const declarations = [...own.values()].sort((left, right) => left.name.localeCompare(right.name)) const references = referencedTypeNames(declarations.map(declaration => declaration.text), types) .filter(name => !own.has(name)) return { declarations, references } } +/** Top-level composition excludes nested field values and generic arguments. */ +function ownerCompositionNames(type: ts.TypeNode): string[] { + if (ts.isUnionTypeNode(type) || ts.isIntersectionTypeNode(type)) return type.types.flatMap(ownerCompositionNames) + if (ts.isParenthesizedTypeNode(type)) return ownerCompositionNames(type.type) + if (ts.isTypeReferenceNode(type) && ts.isIdentifier(type.typeName) && type.typeArguments === undefined) { + return [type.typeName.text] + } + return [] +} + /** How a keyed slot's key domain is constrained, '' for the other kinds. */ function keyDomainOf(declaration: SlotDeclaration, occupants: readonly SlotRegistration[]): string { if (declaration.kind !== 'keyed') return '' From 536764c8b4897eb566579f9b3cc41380d0d5c61a Mon Sep 17 00:00:00 2001 From: imccyu Date: Wed, 23 Sep 2026 15:33:07 +0800 Subject: [PATCH 26/28] test(web): align snapshots with write activity labels --- apps/web/tests/expected/clickable-links-gallery/ui.expected.md | 2 +- snapshots/web/changed-files-turn/ui.expected.md | 2 +- snapshots/web/present-svg/ui.expected.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) 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/snapshots/web/changed-files-turn/ui.expected.md b/snapshots/web/changed-files-turn/ui.expected.md index ed54f74056..7500599d2e 100644 --- a/snapshots/web/changed-files-turn/ui.expected.md +++ b/snapshots/web/changed-files-turn/ui.expected.md @@ -2,7 +2,7 @@ - button "复制" - status: 已完成工作 - button "用时 {{duration}}" [expanded] -- button "修改了文件,执行了命令,已读取文件等" [expanded] +- button "执行了命令,已写入文件,已读取文件等" [expanded] - button "思考" - button "读取 intro.md": - text: 读取 diff --git a/snapshots/web/present-svg/ui.expected.md b/snapshots/web/present-svg/ui.expected.md index 57c6e63f6c..08f503ab05 100644 --- a/snapshots/web/present-svg/ui.expected.md +++ b/snapshots/web/present-svg/ui.expected.md @@ -5,7 +5,7 @@ - button "已完成分析" [expanded] - button "思考" - paragraph: 我来创建一个冯诺依曼架构的 SVG 示意图。 -- button "修改了文件并已调用工具" [expanded] +- button "已写入文件并调用工具" [expanded] - button "写入 von-neumann.svg +83 -0": - text: 写入 - button "von-neumann.svg" From 19675fd061b8def7b60a653b7473a8f30ca7888a Mon Sep 17 00:00:00 2001 From: imccyu Date: Wed, 23 Sep 2026 15:49:59 +0800 Subject: [PATCH 27/28] fix(client): align subagent coordination and wait labels --- .../client/conversation-nodes/README.i18n.yaml | 2 +- .../src/client/conversation-nodes/README.zh.md | 2 +- packages/client/ui-chat/src/client/locale.ts | 6 +++--- .../client/ui-conversation/src/client/locales.ts | 10 +++++----- .../ui-tool/tests/details-models.client.spec.ts | 15 +++++++++++++-- snapshots/web/tool-details/ui.expected.md | 4 ++-- 6 files changed, 25 insertions(+), 14 deletions(-) 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 700b1bba54..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 @@ -3,4 +3,4 @@ # 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: 3b6b82685c763837e706c60d8ea69b896f582ae2 -README.zh.md: d599bcc3fe63ff2e5b7e35fc371af51d066ea25b +README.zh.md: ae43726417becea973b22e2f2a16871cec116584 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 d599bcc3fe..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 @@ -138,7 +138,7 @@ description: "Chat 过程分组与活动摘要的明细业务规则。" | `code` | 正在运行代码 | 运行了代码 | | `webSearch` | 正在搜索网页 | 已搜索网页 | | `webFetch` | 正在访问网页 | 已访问网页 | -| `subagents` | 正在协调子任务 | 已协调子任务 | +| `subagents` | 正在协调子代理 | 已协调子代理 | | `plan` | 正在更新计划 | 更新了计划 | | `questions` | 等待你的操作 | 向用户提出了问题 | | `tools` | 正在调用工具 | 已调用工具 | diff --git a/packages/client/ui-chat/src/client/locale.ts b/packages/client/ui-chat/src/client/locale.ts index 9e188c9dc6..66690dad77 100644 --- a/packages/client/ui-chat/src/client/locale.ts +++ b/packages/client/ui-chat/src/client/locale.ts @@ -15,7 +15,7 @@ export const zh = { 'message.stepProcess.code': '正在运行代码', 'message.stepProcess.webSearch': '正在搜索网页', 'message.stepProcess.webFetch': '正在访问网页', - 'message.stepProcess.subagents': '正在协调子任务', + 'message.stepProcess.subagents': '正在协调子代理', 'message.stepProcess.plan': '正在更新计划', 'message.stepProcess.questions': '等待你的操作', 'message.stepProcess.tools': '正在调用工具', @@ -28,7 +28,7 @@ export const zh = { 'message.stepProcess.prepare.code': '准备运行代码', 'message.stepProcess.prepare.webSearch': '准备搜索网页', 'message.stepProcess.prepare.webFetch': '准备访问网页', - 'message.stepProcess.prepare.subagents': '准备协调子任务', + 'message.stepProcess.prepare.subagents': '准备协调子代理', 'message.stepProcess.prepare.plan': '准备更新计划', 'message.stepProcess.prepare.questions': '准备提问', 'message.stepProcess.prepare.tools': '准备调用工具', @@ -42,7 +42,7 @@ export const zh = { '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': '已调用工具', diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts index 826b998e18..0e224ac3b3 100644 --- a/packages/client/ui-conversation/src/client/locales.ts +++ b/packages/client/ui-conversation/src/client/locales.ts @@ -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': '代理回复', @@ -596,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', 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..b7e5be87b5 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/snapshots/web/tool-details/ui.expected.md b/snapshots/web/tool-details/ui.expected.md index 0014fc70d6..da1ddbfaac 100644 --- a/snapshots/web/tool-details/ui.expected.md +++ b/snapshots/web/tool-details/ui.expected.md @@ -703,10 +703,10 @@ - listitem: - paragraph: task-demo-1 - button "Inspect" -- button "Wait for subagent Teammate activity" [expanded] +- button "Wait for subagent Subagent activity" [expanded] - text: Recorded result - list: - - listitem: Teammate activity Change detected + - listitem: Subagent activity Change detected - button "Inspect" - button "Interrupt agent ui-review" [expanded] - text: Recorded result From 377616937331177f13f77fc86f6958e147fb1c3c Mon Sep 17 00:00:00 2001 From: imccyu Date: Wed, 23 Sep 2026 16:03:34 +0800 Subject: [PATCH 28/28] test(client): align wait agent row title expectation --- packages/client/ui-tool/tests/details-row.client.spec.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 dfd7d536a0..9586990b97 100644 --- a/packages/client/ui-tool/tests/details-row.client.spec.tsx +++ b/packages/client/ui-tool/tests/details-row.client.spec.tsx @@ -95,7 +95,7 @@ describe('DetailsRow', () => { const inspect = vi.fn() const expectedIcon = render().container.querySelector('svg')!.outerHTML const view = render([0]} />) - expect(screen.getByText('Wait for teammates')).toBeTruthy() + expect(screen.getByText('Wait for subagent')).toBeTruthy() expect(view.container.querySelector('svg')?.outerHTML).toBe(expectedIcon) })