diff --git a/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.i18n.yaml b/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.i18n.yaml index e17e4685e3..eea7ced3d2 100644 --- a/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.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-02-pwsh-tool-bash-parity.md -2026-08-02-pwsh-tool-bash-parity.md: bf40c440b9f7f330412d8949f59c54d541152d45 -2026-08-02-pwsh-tool-bash-parity.zh.md: bc67dac29900eacf9e615e76fe947e3e642a3979 +2026-08-02-pwsh-tool-bash-parity.md: 945d2d5243162fe8e7fb3f76cbc3bcf0b5c2fdee +2026-08-02-pwsh-tool-bash-parity.zh.md: f537e313a0c895927c6e2319b11b98619a70d461 diff --git a/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.md b/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.md index bf40c440b9..945d2d5243 100644 --- a/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.md +++ b/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.md @@ -16,7 +16,7 @@ The first Windows-native foundation shipped `dsh-tool-pwsh` as a deliberately mi - **`run_in_background` is wired through the generic task runtime** exactly like the bash tool: preflight, owner registration, `task_output`/`task_kill` control, and the same outcome mapping. `pwsh-local`'s already-mirrored `start()` handle backs it. - **The `DSH_*` environment is shared, not duplicated**: `BashEnvRegistry` moved out of `dsh-tool-bash` into a new tool-independent `@deepseek-ai/dsh-bash-env` package (`ctx.bashEnv` + built-ins + the session-persistence contributor), and both shell tools inject it. Contributors apply to pwsh calls exactly as they do to bash calls, resolving the bash tool's `FIXME(bash-env-ownership)`. - **Windows reality is pinned where bash has no analog**: every command runs under a UTF-8 output preamble so the Windows PowerShell 5.1 fallback cannot garble non-ASCII output through the UTF-8-decoding collector, and the prompts teach that Windows forced termination settles as exit 1 without a signal marker. -- **Out of scope, unchanged**: sandbox escalation (waits for a Windows-confining executor), persistent PTY shells (backends are Linux/macOS-only; ConPTY is roadmap work), and pwsh-specific TUI/GUI presentation (generic/terminal cards stay; a PowerShell-aware terminal card with an exit pill is roadmap work). +- **Out of scope, unchanged**: sandbox escalation (waits for a Windows-confining executor) and persistent PTY shells (backends are Linux/macOS-only; ConPTY is roadmap work). The pwsh-specific terminal card with an exit pill shipped separately in the [pwsh UI presentation matches bash](2026-08-05-pwsh-ui-bash-parity.md) decision. ## Alternatives considered @@ -33,4 +33,4 @@ The first Windows-native foundation shipped `dsh-tool-pwsh` as a deliberately mi - `@deepseek-ai/dsh-bash-env` is a new shipped package; `dsh-tool-bash`'s `dshHome` config moved there, so compositions mounting the shell tools must also mount `bash-env` (the spine bundles do). - Windows-only semantics (CRLF normalization, forced-termination exit-1/signal-null, POSIX-only self-signal) remain pinned by tests as before. - The pwsh tool's per-file coverage gate rides on the scriptable fake-executor suite (`tests/tools.spec.ts`); the real-pwsh integration and Loader-composition suites self-skip where `pwsh` is absent, mirroring the bash suites' division of labor. -- The roadmap proposal's parity stage is delivered; its remaining stages are the Windows default composition and pwsh TUI/GUI rendering. +- The roadmap proposal's parity stage is delivered; the terminal-card presentation stage shipped in the [pwsh UI presentation matches bash](2026-08-05-pwsh-ui-bash-parity.md) decision (the TUI itself was removed), leaving the Windows default composition as the remaining stage. diff --git a/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.zh.md b/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.zh.md index bc67dac299..f537e313a0 100644 --- a/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.zh.md +++ b/.agents/notes/implemented/feature/2026-08-02-pwsh-tool-bash-parity.zh.md @@ -16,7 +16,7 @@ Status: implemented - **`run_in_background` 经通用任务运行时接线**,与 bash 工具完全一致:预检、owner 注册、`task_output`/`task_kill` 控制与相同的结果映射。其背后是 `pwsh-local` 早已镜像好的 `start()` 句柄。 - **`DSH_*` 环境共享而非复制**:`BashEnvRegistry` 从 `dsh-tool-bash` 迁入新的工具无关包 `@deepseek-ai/dsh-bash-env`(`ctx.bashEnv` + 内置事实 + session-persistence contributor),两个 shell 工具都注入它。contributor 对 pwsh 调用与 bash 调用一视同仁,并消化了 bash 工具的 `FIXME(bash-env-ownership)`。 - **Windows 现实在 bash 无对应处钉死**:每条命令都在 UTF-8 输出 preamble 下运行,使 Windows PowerShell 5.1 兜底无法经 UTF-8 解码的 collector 破坏非 ASCII 输出;prompt 教授 Windows 强制终止以无 signal 的 exit 1 结算。 -- **范围外,不变**:sandbox 升级(等待 Windows-confining 执行器)、持久 PTY shell(后端仅限 Linux/macOS;ConPTY 属路线图)、pwsh 专属 TUI/GUI 呈现(维持 generic/terminal 卡;带退出 pill 的 PowerShell 感知 terminal 卡属路线图)。 +- **范围外,不变**:sandbox 升级(等待 Windows-confining 执行器)与持久 PTY shell(后端仅限 Linux/macOS;ConPTY 属路线图)。带退出 pill 的 pwsh 专属 terminal 卡已随 [pwsh UI 呈现与 bash 对齐](2026-08-05-pwsh-ui-bash-parity.md) 决策另行交付。 ## 备选方案 @@ -33,4 +33,4 @@ Status: implemented - `@deepseek-ai/dsh-bash-env` 成为新的交付包;`dsh-tool-bash` 的 `dshHome` 配置迁往那里,因此挂载 shell 工具的组合也必须挂载 `bash-env`(spine bundle 已如此)。 - Windows 专属语义(CRLF 归一化、强制终止 exit-1/signal-null、仅 POSIX 的自信号)一如既往由测试钉住。 - pwsh 工具的 per-file 覆盖门禁由可脚本化的 fake-executor 套件(`tests/tools.spec.ts`)承担;真实 pwsh 的集成与 Loader 组合套件在无 `pwsh` 的宿主自跳过,与 bash 套件的分工一致。 -- 路线图提案的 parity 阶段已交付;其余阶段是 Windows 默认组合与 pwsh TUI/GUI 渲染。 +- 路线图提案的 parity 阶段已交付;terminal 卡呈现阶段随 [pwsh UI 呈现与 bash 对齐](2026-08-05-pwsh-ui-bash-parity.md) 决策交付(TUI 本身已移除),剩余阶段是 Windows 默认组合。 diff --git a/.agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.i18n.yaml b/.agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.i18n.yaml new file mode 100644 index 0000000000..dcb3a9406b --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.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/feature/2026-08-05-pwsh-ui-bash-parity.md +2026-08-05-pwsh-ui-bash-parity.md: 6bbdb0e6bc69ef1af03a6a9146f83b84754cb2a6 +2026-08-05-pwsh-ui-bash-parity.zh.md: 75f3a3ddec002acaa1755c81114b0f122ab80593 diff --git a/.agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.md b/.agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.md new file mode 100644 index 0000000000..6bbdb0e6bc --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.md @@ -0,0 +1,32 @@ +# Agent Note: pwsh UI presentation matches bash + +Status: implemented + +English | [中文](2026-08-05-pwsh-ui-bash-parity.zh.md) + +## Problem + +The [pwsh tool bash parity decision](../../implemented/feature/2026-08-02-pwsh-tool-bash-parity.md) made `dsh-tool-pwsh` behaviorally interchangeable with `dsh-tool-bash` for execution, markers, and background tasks, but explicitly deferred the human-visible half: a completed pwsh foreground call presented as a generic `console`-fenced card while the bash tool's completed call presented as a terminal card with a parsed exit-status pill. The roadmap that owned this gap ([Windows defaults to pwsh](../../proposed/feature/2026-08-01-windows-pwsh-default.md)) named "pwsh TUI/GUI rendering" as stage 2 — but the TUI package was removed ([`ed30088adb`](https://github.com/deepseek-harness/deepseek-harness/commit/ed30088adb)), leaving the Web surface as the only UI the gap affects. + +## Decision + +`dsh-tool-pwsh`'s `presentResult` now mirrors `dsh-tool-bash`'s call-for-call: a completed foreground result is a `terminal` card whose output body is the marker-free rendered text and whose exit-status pill is the parsed `exitCode`/`signal`; background acknowledgements and `isError` results stay generic `console`-fenced cards; non-single-text-block results stay untouched (`undefined`). + +The parse is shared, not duplicated: `parseExitStatus`/`ParsedExitStatus` moved from `dsh-tool-bash`'s private render module into the `@deepseek-ai/dsh-bash` seam package (exported from its index), and `dsh-tool-bash`'s `render.ts` re-exports it so its source-plane consumers keep one import root. Both tools' renderers emit the same `[exit code: N]` / `[killed by signal: X]` markers, so one seam-owned inverse can never drift between the twins — the same "shared, not duplicated" shape the [bash-env extraction](../../implemented/feature/2026-08-02-pwsh-tool-bash-parity.md) used for the `DSH_*` registry. + +The Web UI needs no per-tool code for the card itself: the client's terminal-card bridge maps any `card: 'terminal'` result view (`terminal-card-model` in `dsh-client-ui-conversation`), so the pwsh presenter change flows through the same rendering path bash already has. The collapsed tool row does get one client classification entry: `classifyTool('pwsh')` now yields the shell-family row (`bash` variant, its own `Pwsh` title) instead of the generic `others` "Tool call" row. A keyless browser lane (`apps/web/tests/pwsh-terminal.e2e.ts`) seeds a session whose pwsh call/result is presented by the real tool on replay — the api-proxy recomputes views from logged args/result content — and pins the terminal card golden, including the exit pill and the run-state dot. + +## Alternatives considered + +**Import `parseExitStatus` from `@deepseek-ai/dsh-tool-bash/src/render.ts`.** Rejected: workspace imports stay external in the built bundles, so `tool-pwsh` would gain a hard runtime dependency on `tool-bash` in every consumer closure (including compositions that deliberately mount the pwsh twin without bash), and a sibling tool depending on its twin for one function inverts the package relationship. The seam move keeps the shared contract on a package both tools already depend on. + +**A new dedicated presentation package (e.g. `@deepseek-ai/dsh-shell-present`).** Rejected: a new package costs manifests, module-graph/catalog regeneration, and README surface for a single pure function; `@deepseek-ai/dsh-bash` is already in both tools' closures and already owns the `BashRunResult` facts the parse reconstructs. + +**Duplicate the parse into `tool-pwsh`'s render module (a third twin).** Rejected: the parity review's core finding was that copied text contracts drift without a shared implementation; the parse and the marker emission must co-evolve in one place, and the parse is exactly the contract the UI pill depends on. + +## Consequences + +- A Windows composition using `dsh-tool-pwsh` now shows its shell calls exactly as bash calls look in the Web UI: cwd-headed terminal card, raw output, exit-status pill, run-state dot, and the red failure treatment on non-zero exits. +- `parseExitStatus` becomes public seam surface on `@deepseek-ai/dsh-bash`; `dsh-tool-bash/src/render.ts` keeps re-exporting it, so no bash-tool consumer changes. +- The roadmap's stage 2 shrinks: the TUI is removed (EOL), and the terminal-card counterpart now ships on the Web surface. The Windows default composition (stage 1) remains the outstanding stage. +- Verification: `dsh-bash` owns the parse edge cases under the per-file coverage gate; `tool-pwsh`'s presenter suite mirrors `tool-bash`'s (clean/non-zero/signal/timeout round-trip, marker-like output, background/error generics, multi-block fallback); the client row-model suite pins the `Pwsh` shell-family row; the web `pwsh-terminal` lane is the assembled keyless scenario. diff --git a/.agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.zh.md b/.agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.zh.md new file mode 100644 index 0000000000..75f3a3ddec --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-05-pwsh-ui-bash-parity.zh.md @@ -0,0 +1,32 @@ +# Agent Note: pwsh UI presentation matches bash + +Status: implemented + +[English](2026-08-05-pwsh-ui-bash-parity.md) | 中文 + +## Problem + +[pwsh 工具与 bash 对齐决策](../../implemented/feature/2026-08-02-pwsh-tool-bash-parity.md) 让 `dsh-tool-pwsh` 在执行、marker 与后台任务上行为可互换,但明确推迟了面向人类的一半:完成的 pwsh 前台调用呈现为通用 `console` 围栏卡片,而 bash 工具的完成调用呈现为带解析退出状态 pill 的 terminal 卡。拥有此缺口的路线图([Windows 默认改用 pwsh](../../proposed/feature/2026-08-01-windows-pwsh-default.md))把 "pwsh TUI/GUI 渲染" 列为阶段 2——但 TUI 包已被移除([`ed30088adb`](https://github.com/deepseek-harness/deepseek-harness/commit/ed30088adb)),Web 表面成为该缺口唯一影响的 UI。 + +## Decision + +`dsh-tool-pwsh` 的 `presentResult` 现在逐调用镜像 `dsh-tool-bash`:完成的前台结果是 `terminal` 卡,输出正文为去 marker 的渲染文本,退出状态 pill 为解析出的 `exitCode`/`signal`;后台 ack 与 `isError` 结果保持通用 `console` 围栏卡片;非单一文本块结果保持不变(`undefined`)。 + +解析是共享而非复制:`parseExitStatus`/`ParsedExitStatus` 从 `dsh-tool-bash` 的私有 render 模块迁入 `@deepseek-ai/dsh-bash` seam 包(由其 index 导出),`dsh-tool-bash` 的 `render.ts` 再导出它,使源平面消费方保持单一导入根。两个工具的渲染器发出相同的 `[exit code: N]` / `[killed by signal: X]` marker,因此一个由 seam 拥有的逆解析永远不会在孪生之间漂移——与 [bash-env 抽取](../../implemented/feature/2026-08-02-pwsh-tool-bash-parity.md) 处理 `DSH_*` 注册表时相同的 "共享而非复制" 形态。 + +Web UI 的卡片本身不需要任何按工具编写的代码:客户端的 terminal 卡桥接(`dsh-client-ui-conversation` 的 `terminal-card-model`)映射任意 `card: 'terminal'` 结果视图,因此 pwsh presenter 变更直接流经 bash 已有的同一渲染路径。折叠的工具行有一处客户端分类条目:`classifyTool('pwsh')` 现在归入 shell 家族行(`bash` variant,自有 `Pwsh` 标题),而非通用的 `others` "Tool call" 行。一条 keyless 浏览器通道(`apps/web/tests/pwsh-terminal.e2e.ts`)播种一个会话,其 pwsh 调用/结果在回放时由真实工具呈现(api-proxy 从已记录的 args/result 内容重新计算视图),并钉住 terminal 卡 golden,包括退出 pill 与运行状态点。 + +## Alternatives considered + +**从 `@deepseek-ai/dsh-tool-bash/src/render.ts` 导入 `parseExitStatus`。** 否决:workspace 导入在构建产物中保持外部引用,因此 `tool-pwsh` 会在每个消费方闭包中新增对 `tool-bash` 的硬运行时依赖(包括刻意只挂 pwsh 孪生、不挂 bash 的组合),且兄弟工具为其单个函数依赖孪生会颠倒包间关系。seam 迁移把共享契约放在两个工具本就依赖的包上。 + +**新建专用呈现包(如 `@deepseek-ai/dsh-shell-present`)。** 否决:为一个纯函数新建包要付出 manifest、module-graph/目录再生成与 README 面的成本;`@deepseek-ai/dsh-bash` 已在两个工具的闭包中,且已拥有该解析重建的 `BashRunResult` 事实。 + +**把解析复制进 `tool-pwsh` 的 render 模块(第三个孪生)。** 否决:parity 评审的核心发现正是"复制的文本契约缺少共享实现就会漂移";解析与 marker 发出必须在同一处共同演化,而解析恰恰是 UI pill 依赖的契约。 + +## Consequences + +- 使用 `dsh-tool-pwsh` 的 Windows 组合现在在 Web UI 中显示的 shell 调用与 bash 调用完全一致:cwd 头的 terminal 卡、原始输出、退出状态 pill、运行状态点,以及非零退出时的红色失败处理。 +- `parseExitStatus` 成为 `@deepseek-ai/dsh-bash` 的公开 seam 表面;`dsh-tool-bash/src/render.ts` 继续再导出它,bash 工具消费方零改动。 +- 路线图阶段 2 收窄:TUI 已移除(EOL),terminal 卡对应物现已在 Web 表面交付。Windows 默认组合(阶段 1)仍是未完成的阶段。 +- 验证:`dsh-bash` 在逐文件覆盖率门禁下拥有解析边界用例;`tool-pwsh` 的 presenter 套件镜像 `tool-bash` 的(干净/非零/信号/超时往返、marker 状输出、后台/错误 generic、多块回退);客户端行模型套件钉住 `Pwsh` shell 家族行;web `pwsh-terminal` 通道是组装后的 keyless 场景。 diff --git a/.agents/notes/proposed/feature/2026-08-01-windows-pwsh-default.i18n.yaml b/.agents/notes/proposed/feature/2026-08-01-windows-pwsh-default.i18n.yaml index 5e7f47f278..7cbe5da0dd 100644 --- a/.agents/notes/proposed/feature/2026-08-01-windows-pwsh-default.i18n.yaml +++ b/.agents/notes/proposed/feature/2026-08-01-windows-pwsh-default.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/proposed/feature/2026-08-01-windows-pwsh-default.md -2026-08-01-windows-pwsh-default.md: a310174b6864bb880070280835ccfd8623e26342 -2026-08-01-windows-pwsh-default.zh.md: 079c1e3cac789a5e3fa4d0bb889026b3fb69f78c +2026-08-01-windows-pwsh-default.md: 5ecf6fc402bc4a4cd25485965d51298d5f52ce0a +2026-08-01-windows-pwsh-default.zh.md: e6fcc733b191386a9222c3173d414448126cac6c diff --git a/.agents/notes/proposed/feature/2026-08-01-windows-pwsh-default.md b/.agents/notes/proposed/feature/2026-08-01-windows-pwsh-default.md index a310174b68..5ecf6fc402 100644 --- a/.agents/notes/proposed/feature/2026-08-01-windows-pwsh-default.md +++ b/.agents/notes/proposed/feature/2026-08-01-windows-pwsh-default.md @@ -13,7 +13,7 @@ The harness's shipped execution profile is bash-first on every platform. Windows Two follow-up stages, each independently shippable. The former stage 2 (bash-tool parity twin) shipped with the [pwsh tool bash parity decision](../../implemented/feature/2026-08-02-pwsh-tool-bash-parity.md): `tool-pwsh` now mirrors `tool-bash` for foreground and background work minus the sandbox surface, shares the `DSH_*` environment through `dsh-bash-env`, and carries a keyless application snapshot of its assembled surface. 1. **Windows default composition** — the shipped CLI compositions mount `dsh-pwsh-local` as the `ctx.bash` executor and `dsh-tool-pwsh` as the model-facing shell tool on Windows hosts (bash unmounted there), while POSIX hosts keep the bash stack. This is a composition/roster decision in `base.cordis.yml` and the surface overlays, gated by platform; it makes the shipped Windows experience PowerShell-native end to end. -2. **pwsh TUI/GUI rendering** — the TUI and Web surfaces render pwsh output with PowerShell-aware presentation (native path display, `$env:` facts), the counterpart of the bash terminal cards. This is where terminal/console rendering conventions get a PowerShell twin. +2. **pwsh GUI rendering** — the Web surface renders pwsh calls with the bash-shaped terminal presentation (terminal card with exit-status pill), the counterpart of the bash terminal cards. Shipped in the [pwsh UI presentation matches bash decision](../../implemented/feature/2026-08-05-pwsh-ui-bash-parity.md) with a keyless web lane; the TUI was removed, so no terminal twin remains. A PowerShell-aware presentation beyond bash parity (native path display, `$env:` facts) remains unclaimed. The stages are deliberately sequenced: composition first (a Windows user gets PowerShell without choosing), then rendering. Nothing in this proposal changes POSIX behavior. @@ -30,10 +30,10 @@ The stages are deliberately sequenced: composition first (a Windows user gets Po - A Windows host running the shipped `dsh` TUI/Web gets `pwsh` as its shell tool and PowerShell as the `ctx.bash` executor without configuration, and `bash` is absent from the model-visible roster there. - POSIX hosts are byte-for-byte unaffected (same roster, same executor). - The shipped-composition e2es assert the platform-gated roster on both families. -- Stage 1 lands with the keyless pwsh-tool snapshot already in place from the parity change; stage 2 lands with TUI/Web rendering snapshots for pwsh output. +- Stage 1 lands with the keyless pwsh-tool snapshot already in place from the parity change; stage 2 landed with the web `pwsh-terminal` rendering lane (the TUI's removal left no terminal surface to snapshot). ## Risks - **Bash-dependent composition rows** — any shipped plugin that assumes `bash` semantics (hook bridges executing shell hooks, workspace tooling) must be audited per stage; the audit may force a staged rollout rather than one switch. - **Windows CI coverage gap** — unit coverage runs on Linux; Windows-only regressions in the pwsh stack surface through the Windows build/static lane and e2es, which must be extended per stage rather than assumed. -- **Rendering conventions** — a PowerShell twin for terminal cards is a UI design decision with snapshot surface; deferring it (stage 2) keeps stage 1 shippable without UI churn. +- **Rendering conventions** — the bash-shaped terminal twin shipped with the Web lane; a PowerShell-aware presentation beyond bash parity (native path display, `$env:` facts) remains a UI design decision with snapshot surface, deferred with stage 1. diff --git a/.agents/notes/proposed/feature/2026-08-01-windows-pwsh-default.zh.md b/.agents/notes/proposed/feature/2026-08-01-windows-pwsh-default.zh.md index 079c1e3cac..e6fcc733b1 100644 --- a/.agents/notes/proposed/feature/2026-08-01-windows-pwsh-default.zh.md +++ b/.agents/notes/proposed/feature/2026-08-01-windows-pwsh-default.zh.md @@ -13,7 +13,7 @@ harness 交付的执行画像在每个平台都是 bash 优先。Windows 主机 两个阶段,各自可独立交付。原阶段 2(bash 工具对等孪生)已随 [pwsh 工具与 bash 对齐决策](../../implemented/feature/2026-08-02-pwsh-tool-bash-parity.md) 交付:`tool-pwsh` 现在在前台与后台工作(减 sandbox 面)上镜像 `tool-bash`,通过 `dsh-bash-env` 共享 `DSH_*` 环境,并携带其组装表面的 keyless 应用快照。 1. **Windows 默认组合**——交付的 CLI 组合在 Windows 主机上挂载 `dsh-pwsh-local` 作为 `ctx.bash` 执行器、`dsh-tool-pwsh` 作为面向模型的 shell 工具(那里不挂载 bash),POSIX 主机保持 bash 栈。这是 `base.cordis.yml` 与 surface 覆盖层里按平台门控的组合/清单决策;它让交付的 Windows 体验端到端 PowerShell 原生。 -2. **pwsh TUI/GUI 渲染**——TUI 与 Web 表面以 PowerShell 感知的呈现渲染 pwsh 输出(原生路径显示、`$env:` 实情),即 bash 终端卡片的对应物。这是终端/控制台渲染约定获得 PowerShell 孪生的地方。 +2. **pwsh GUI 渲染**——Web 表面以 bash 形状的终端呈现渲染 pwsh 调用(带退出状态 pill 的 terminal 卡),即 bash 终端卡片的对应物。已随 [pwsh UI 呈现与 bash 对齐决策](../../implemented/feature/2026-08-05-pwsh-ui-bash-parity.md) 及 keyless web 通道交付;TUI 已移除,不再有终端孪生。超出 bash 对齐的 PowerShell 感知呈现(原生路径显示、`$env:` 实情)仍无人认领。 各阶段刻意排序:先组合(Windows 用户无需选择即获得 PowerShell),再渲染。本提案不改变任何 POSIX 行为。 @@ -30,10 +30,10 @@ harness 交付的执行画像在每个平台都是 bash 优先。Windows 主机 - 运行交付版 `dsh` TUI/Web 的 Windows 主机无需配置即获得 `pwsh` 作为其 shell 工具、PowerShell 作为 `ctx.bash` 执行器,且那里的模型可见清单中没有 `bash`。 - POSIX 主机逐字节不受影响(清单相同,执行器相同)。 - 交付组合 e2e 在两个平台族上断言按平台门控的清单。 -- 阶段 1 落地时,parity 变更带来的 keyless pwsh 工具快照已经就位;阶段 2 附带 pwsh 输出的 TUI/Web 渲染快照落地。 +- 阶段 1 落地时,parity 变更带来的 keyless pwsh 工具快照已经就位;阶段 2 已随 web `pwsh-terminal` 渲染通道落地(TUI 的移除让终端表面无快照可做)。 ## 风险 - **依赖 bash 的组合行**——任何假设 bash 语义的交付插件(执行 shell hooks 的 hooks 桥、工作区工具)必须按阶段审计;审计可能迫使分阶段推出而非一次切换。 - **Windows CI 覆盖缺口**——单元覆盖在 Linux 上运行;pwsh 栈里仅 Windows 的回归通过 Windows 构建/静态通道与 e2e 浮出,必须按阶段扩展而不是想当然。 -- **渲染约定**——终端卡片的 PowerShell 孪生是带快照表面的 UI 设计决策;把它延期(阶段 2)让阶段 1 无需 UI 翻动即可交付。 +- **渲染约定**——bash 形状的终端孪生已随 web 通道交付;超出 bash 对齐的 PowerShell 感知呈现(原生路径显示、`$env:` 实情)仍是带快照表面的 UI 设计决策,随阶段 1 一起延期。 diff --git a/apps/web/tests/pwsh-terminal.e2e.ts b/apps/web/tests/pwsh-terminal.e2e.ts new file mode 100644 index 0000000000..59a8b68210 --- /dev/null +++ b/apps/web/tests/pwsh-terminal.e2e.ts @@ -0,0 +1,99 @@ +// Keyless browser regression for pwsh UI parity with bash: a seeded session +// whose pwsh call/result is presented by the REAL tool-pwsh on replay (the +// api-proxy recomputes presentation views from logged args/result content) +// must render as a bash-shaped terminal card with the parsed exit-status +// pill — not the generic console-fenced card the pwsh presenter used to +// emit. The seed is authored, not recorded: its header line carries no `cwd` +// field (seedSession writes the session cwd itself, and a Windows temp path +// substituted into the header would not round-trip through its JSON parse), +// and no event references the workspace, so the lane replays on any host +// with a usable `pwsh` — the lane mounts the pwsh stack through an overlay +// (the shipped tree keeps the bash stack). +import { spawnSync } from 'node:child_process' +import { readFile } from 'node:fs/promises' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import type { Browser, Page } from 'playwright' +import { chromium } from 'playwright' +import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import { + assertFixtureInventory, captureStableAria, compareOrRefreshGolden, + fixtureUserPrompts, launchWebScaffold, seedSession, webSnapshotMode, + type WebScaffold, +} from './scaffold.ts' +import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts' + +const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/pwsh-terminal', import.meta.url)) +const SEED = join(SNAPSHOT_DIR, 'seed.jsonl') +const TERMINAL_EXPECTED = join(SNAPSHOT_DIR, 'terminal-card.expected.md') +const OVERLAY = fileURLToPath(new URL('./pwsh-terminal.overlay.yml', import.meta.url)) +const PROMPT = 'Run a PowerShell command that fails, then stop.' +const SEED_ID = 'pwsh-terminal-web-e2e' +const MODE = webSnapshotMode() + +// The overlay swaps the shipped bash executor for @deepseek-ai/dsh-pwsh-local; +// a host without a usable `pwsh` cannot boot it, so the lane self-skips, +// mirroring the pwshOnly ACP scenarios. +const HAS_PWSH = spawnSync( + 'pwsh', ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', 'exit 0'], + { stdio: 'ignore' }, +).status === 0 + +describe.skipIf(MODE === 'record' || !HAS_PWSH)('web e2e: pwsh calls render as bash-shaped terminal cards', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + + beforeAll(async () => { + const fixture = await readFile(SEED, 'utf8') + expect(fixtureUserPrompts(fixture), 'seed fixture must carry the single drive prompt').toEqual([PROMPT]) + scaffold = await launchWebScaffold({ extraOverlayPath: OVERLAY }) + await seedSession(scaffold, fixture, SEED_ID) + browser = await chromium.launch() + page = await newEnglishPage(browser) + await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await connectFreshWorkspace(page, scaffold.workspaceCwd) + }, 120_000) + + afterAll(async () => { + await browser?.close() + await scaffold?.close() + }) + + it('renders the seeded pwsh call as a terminal card with the parsed exit pill', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-pwsh-terminal')) + // Open the seeded session through content search: the sidebar groups + // sessions by workspace and its row order is world-dependent, while the + // search index covers the seeded log deterministically. + const search = page.getByPlaceholder('Search name, keywords', { exact: false }) + await search.fill('Run a PowerShell command') + const result = page.getByRole('tree', { name: 'Search results' }).getByRole('treeitem') + await expect.poll(() => result.count(), { timeout: 15_000 }).toBe(1) + await result.click() + await page.getByRole('tab', { name: 'Chat', exact: true }).waitFor({ timeout: 15_000 }) + // The tool row is expand-gated: the settled bash-shaped row carries the + // shell-family variant, and the terminal card lives in the expanded body. + const row = page.locator('[data-tool="pwsh"]').first() + await row.waitFor({ timeout: 15_000 }) + if (await row.getAttribute('aria-expanded') !== 'true') await row.click() + const card = page.locator('[data-terminal]').first() + await card.waitFor({ timeout: 15_000 }) + // The parsed exit pill replaces the `[exit code: 1]` marker in the output + // body — the bash tool's terminal presentation, not the generic fence. + const text = await card.textContent() + expect(text).toContain('exit code 1') + expect(text).toContain('Get-Item : Cannot find path') + expect(text).not.toContain('[exit code: 1]') + const snapshot = (await captureStableAria(page, '[data-terminal]', scaffold.workspaceCwd)) + // normalizeAria collapses the workspace basename with a '/' split, which + // misses Windows temp paths; collapse it here too (a no-op on POSIX) so + // the golden is platform-independent. + .split(scaffold.workspaceCwd.split(/[\\/]/).pop()!).join('{{workspace}}') + .split(SEED_ID).join('{{seededId}}') + await compareOrRefreshGolden(TERMINAL_EXPECTED, snapshot, MODE) + }, 60_000) + + it('guards the lane fixture inventory', async () => { + await assertFixtureInventory(SNAPSHOT_DIR, ['seed.jsonl', 'terminal-card.expected.md']) + }) +}) diff --git a/apps/web/tests/pwsh-terminal.overlay.yml b/apps/web/tests/pwsh-terminal.overlay.yml new file mode 100644 index 0000000000..59830e3274 --- /dev/null +++ b/apps/web/tests/pwsh-terminal.overlay.yml @@ -0,0 +1,20 @@ +# The pwsh terminal-card lane swaps the shipped bash stack for the PowerShell +# twin: the bash executor row is disabled (patches cannot rename a row — `name` +# is a guard) and the pwsh executor + tool are inserted. The permission service +# refuses an unconfined executor by design (presets bundle a sandbox mode), so +# its row is disabled too — this lane renders a seeded session, never a +# permission decision. The seeded scenario renders the logged pwsh call/result +# through the real tools on replay; no command executes, but the composition +# must boot the pwsh executor, so the lane skips on hosts without a usable +# `pwsh`. +- id: bash-sandbox + name: '@deepseek-ai/dsh-bash-sandbox' + disabled: true +- id: permission + name: '@deepseek-ai/dsh-permission' + disabled: true +- insert: + - id: pwsh-local + name: '@deepseek-ai/dsh-pwsh-local' + - id: tool-pwsh + name: '@deepseek-ai/dsh-tool-pwsh' diff --git a/apps/web/tests/snapshots/pwsh-terminal/seed.jsonl b/apps/web/tests/snapshots/pwsh-terminal/seed.jsonl new file mode 100644 index 0000000000..6fb863aec0 --- /dev/null +++ b/apps/web/tests/snapshots/pwsh-terminal/seed.jsonl @@ -0,0 +1,19 @@ +{"type":"session","version":0,"id":"{{sessionId}}","createdAt":1784974100747} +{"type":"turn/start","seq":0,"time":1784974200000,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user","rpcId":"{{rpcId}}"}}}} +{"type":"user/message","seq":1,"time":1784974200001,"data":{"content":[{"type":"text","text":"Run a PowerShell command that fails, then stop."}],"source":{"kind":"user","rpcId":"{{rpcId}}"}},"surfaceOp":"append"} +{"type":"session/title","seq":2,"time":1784974200002,"data":{"title":"Run a PowerShell command","messageSeqs":[1],"source":{"kind":"fallback"}}} +{"type":"step/start","seq":3,"time":1784974200010,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":4,"time":1784974200011,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}","messagePrefix":["{{messagePrefix}}"]},"reason":"initial"}} +{"type":"assistant/chunk","seq":5,"time":1784974200200,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":6,"time":1784974200201,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"Run the failing pwsh command."}}} +{"type":"assistant/chunk","seq":7,"time":1784974200201,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"Run the failing pwsh command."}}}} +{"type":"assistant/chunk","seq":8,"time":1784974200300,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":9,"time":1784974200301,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_pwsh_fail_0001","name":"pwsh","argumentsDelta":"{\"command\": \"Get-Item missing.txt\", \"description\": \"Fail deliberately\"}"}}} +{"type":"assistant/chunk","seq":10,"time":1784974200301,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_pwsh_fail_0001","name":"pwsh","arguments":"{\"command\": \"Get-Item missing.txt\", \"description\": \"Fail deliberately\"}"}}}} +{"type":"assistant/chunk","seq":11,"time":1784974200302,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":96,"outputTokens":64,"cacheReadTokens":0,"reasoningTokens":10}}}} +{"type":"assistant/chunk","seq":12,"time":1784974200302,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":13,"time":1784974200310,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"Run the failing pwsh command."},{"type":"tool-call","id":"call_pwsh_fail_0001","name":"pwsh","arguments":"{\"command\": \"Get-Item missing.txt\", \"description\": \"Fail deliberately\"}"}],"provenance":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"usage":{"inputTokens":96,"outputTokens":64,"cacheReadTokens":0,"reasoningTokens":10}},"sourceEventSeqs":[5,6,7,8,9,10,11,12],"surfaceOp":"append"} +{"type":"tool/call","seq":14,"time":1784974200311,"data":{"turn":1,"step":1,"callId":"call_pwsh_fail_0001","name":"pwsh","arguments":"{\"command\": \"Get-Item missing.txt\", \"description\": \"Fail deliberately\"}"}} +{"type":"tool/result","seq":15,"time":1784974200500,"data":{"turn":1,"step":1,"callId":"call_pwsh_fail_0001","content":[{"type":"text","text":"[stderr]\nGet-Item : Cannot find path 'missing.txt' because it does not exist.\n[exit code: 1]"}],"isError":false},"sourceEventSeqs":[14],"surfaceOp":"append"} +{"type":"step/end","seq":16,"time":1784974200501,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":17,"time":1784974200501,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/apps/web/tests/snapshots/pwsh-terminal/terminal-card.expected.md b/apps/web/tests/snapshots/pwsh-terminal/terminal-card.expected.md new file mode 100644 index 0000000000..cc6c8e01fd --- /dev/null +++ b/apps/web/tests/snapshots/pwsh-terminal/terminal-card.expected.md @@ -0,0 +1,3 @@ +- text: Failed {{workspace}} Get-Item missing.txt exit code 1 +- button "Copy" +- text: "[stderr] Get-Item : Cannot find path 'missing.txt' because it does not exist." diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index ecb4f0db6c..2d8d7ab64d 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -61,7 +61,8 @@ "tests/chat-scroll-contract.e2e.ts", "tests/chat-long-interactions.e2e.ts", "tests/chat-continuous-conversation.e2e.ts", - "tests/complex-history.perf.ts" + "tests/complex-history.perf.ts", + "tests/pwsh-terminal.e2e.ts" ], "references": [ { diff --git a/packages/bash/bash/README.i18n.yaml b/packages/bash/bash/README.i18n.yaml index 4a2c37ad91..690a1992a1 100644 --- a/packages/bash/bash/README.i18n.yaml +++ b/packages/bash/bash/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/bash/bash/README.md -README.md: d7bf746969f52000fe298b65b995b7c631d8001c -README.zh.md: a7c0cac0bce2154362c822c213a44f3c507d541c +README.md: beb6a1ab5aa1709033a562c482265be9e2256c52 +README.zh.md: 2c054f7109438b58c3adef733a27fac18a60009c diff --git a/packages/bash/bash/README.md b/packages/bash/bash/README.md index d7bf746969..beb6a1ab5a 100644 --- a/packages/bash/bash/README.md +++ b/packages/bash/bash/README.md @@ -35,6 +35,8 @@ The per-session sandbox-mode override vocabulary (the `'sandbox/mode'` event, th `stdin` and ordinary `env` are set by in-process plugins (the hooks bridges, native plugins) to feed a hook command its JSON payload and `CLAUDE_PROJECT_DIR`/`CLAUDE_PLUGIN_ROOT` values. `dshEnv` is a separate trusted overlay restricted by type to managed keys; the exported `DSH_ENV_PREFIX` is the single source for that namespace, its `DshEnvironmentKey` template type, executor scrubbing, registry validation, derived built-in names, and model guidance. Model bash uses the current snapshot collected by `ctx.bashEnv`. Implementations remove inherited managed keys, then merge `dshEnv` after ordinary `env`, so an omitted current fact cannot fall back to stale ambient state and an `env` entry cannot displace a managed value. The model-facing tool exposes none of these as parameters. All three remain optional on the resolved spec; absent means no input/overlay. See [the bash-stdin-env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) and [the session environment Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md). +The exported `parseExitStatus` (with `ParsedExitStatus`) is the shared rendering contract half of the shell tools: the inverse of the `[exit code: N]` / `[killed by signal: X]` markers `dsh-tool-bash`'s `renderResult` and `dsh-tool-pwsh`'s `renderPwshResult` append. Both tools' `presentResult` use it to split the rendered text into the terminal card's output body and its exit-status pill; it lives on the seam so the two tools never drift on the marker contract. + ## Model Experience Indirectly, through `dsh-tool-bash`, which turns executor output and sandbox facts into guidance and retained tool-result tokens. diff --git a/packages/bash/bash/README.zh.md b/packages/bash/bash/README.zh.md index a7c0cac0bc..2c054f7109 100644 --- a/packages/bash/bash/README.zh.md +++ b/packages/bash/bash/README.zh.md @@ -35,6 +35,8 @@ `stdin` 与普通 `env` 由同进程插件(hooks 桥接、原生插件)设置,用于向 hook 命令提供其 JSON payload 和 `CLAUDE_PROJECT_DIR`/`CLAUDE_PLUGIN_ROOT` 值。`dshEnv` 是受类型限制、仅允许受管 key 的独立受信任 overlay;导出的 `DSH_ENV_PREFIX` 是该 namespace、其 `DshEnvironmentKey` 模板类型、执行器清理、注册表验证、派生内置名称与模型指引的统一来源。模型 bash 使用 `ctx.bashEnv` 收集的当前快照。实现会移除继承的受管 key,再在普通 `env` 之后合并 `dshEnv`,因此省略的当前事实不会回退到陈旧环境状态,`env` 条目也无法顶掉受管值。面向模型的工具不将这三者中的任何一个公开为参数。这三者在已解析 spec 上仍然可选;缺失表示没有输入/overlay。详见 [bash-stdin-env Agent Note(agent 决策记录)](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) 与 [会话环境 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md)。 +导出的 `parseExitStatus`(连同 `ParsedExitStatus`)是 shell 工具共享渲染契约的另一半:`dsh-tool-bash` 的 `renderResult` 与 `dsh-tool-pwsh` 的 `renderPwshResult` 追加的 `[exit code: N]`/`[killed by signal: X]` marker 的逆解析。两个工具的 `presentResult` 都用它把渲染文本拆成 terminal 卡的输出正文与其退出状态 pill;它放在 seam 上,两个工具便永远不会在 marker 契约上漂移。 + ## 模型体验 通过 `dsh-tool-bash` 间接影响;该工具会将执行器输出与沙箱事实转为指引和保留的工具结果 token。 diff --git a/packages/bash/bash/src/index.ts b/packages/bash/bash/src/index.ts index 4f8ae112a9..904c427ed0 100644 --- a/packages/bash/bash/src/index.ts +++ b/packages/bash/bash/src/index.ts @@ -22,6 +22,8 @@ export type { DshEnvironment, DshEnvironmentKey, } from './types.ts' +export { parseExitStatus } from './render.ts' +export type { ParsedExitStatus } from './render.ts' declare module 'cordis' { interface Context { diff --git a/packages/bash/bash/src/render.ts b/packages/bash/bash/src/render.ts new file mode 100644 index 0000000000..8f7edcbe1a --- /dev/null +++ b/packages/bash/bash/src/render.ts @@ -0,0 +1,42 @@ +/** + * Shared rendering helpers for the shell tools (`dsh-tool-bash`, + * `dsh-tool-pwsh`): the exit-status marker contract the tools' renderers + * emit and the presentation layer parses back. + * @module @deepseek-ai/dsh-bash/render + */ + +/** + * The exit status recovered from a rendered result, with the output body that + * status was split off from. + */ +export type ParsedExitStatus = + & { body: string } + & ({ exitCode: number } | { signal: string }) + +/** + * Split a rendered shell-tool result string into its output body and the + * structured exit status — the inverse of the `[exit code: N]` / + * `[killed by signal: X]` markers the shell tools' renderers append. A killed + * marker yields `signal`; otherwise a non-zero marker yields `exitCode`; + * absent both means a clean exit 0. + * + * The consumed marker is removed from `body` because a terminal presentation + * shows the exit status as its own pill: leaving the marker in the output + * would render the exit twice. Other markers (timeout, sandbox denial) carry + * facts no pill shows, so they stay in the body. + * + * Replay only retains the rendered content text, not the original + * `BashRunResult`, so terminal presentation must recover the exit pill here. + * Requiring a leading newline and the end of the string keeps ordinary output + * that merely ends with marker-like text from matching unless the final line + * is indistinguishable from a real marker. + * @param text - rendered model-facing shell-tool result. + * @returns the marker-free body plus the recovered terminal exit code or signal. + */ +export function parseExitStatus(text: string): ParsedExitStatus { + const signal = /\n\[killed by signal: ([^\]\n]+)\]$/.exec(text) + if (signal?.[1] !== undefined) return { body: text.slice(0, signal.index), signal: signal[1] } + const exit = /\n\[exit code: (\d+)\]$/.exec(text) + if (exit?.[1] !== undefined) return { body: text.slice(0, exit.index), exitCode: Number(exit[1]) } + return { body: text, exitCode: 0 } +} diff --git a/packages/bash/bash/tests/render.spec.ts b/packages/bash/bash/tests/render.spec.ts new file mode 100644 index 0000000000..3a9060d289 --- /dev/null +++ b/packages/bash/bash/tests/render.spec.ts @@ -0,0 +1,36 @@ +/** + * Shared exit-status parse contract: the inverse of the `[exit code: N]` / + * `[killed by signal: X]` markers `dsh-tool-bash` and `dsh-tool-pwsh` append. + * Both tools' presenter suites round-trip their own renderers through this + * parse; this spec pins the parse's own edges (marker-like output, body + * slicing) once, at the seam that owns it. + */ + +import { describe, expect, it } from 'vitest' +import { parseExitStatus } from '../src/render.ts' + +describe('parseExitStatus', () => { + it('recovers a clean exit 0 with the body verbatim when no marker is present', () => { + expect(parseExitStatus('hi\n\n')).toEqual({ body: 'hi\n\n', exitCode: 0 }) + expect(parseExitStatus('')).toEqual({ body: '', exitCode: 0 }) + }) + + it('recovers a non-zero exit and strips only its marker from the body', () => { + expect(parseExitStatus('oops\n[exit code: 3]')).toEqual({ body: 'oops', exitCode: 3 }) + // The marker needs the leading newline and the end of the string, so a + // clean result whose output merely ENDS in marker-like text is not read + // as a failure and the text stays in the body. + expect(parseExitStatus('[exit code: 5]')).toEqual({ body: '[exit code: 5]', exitCode: 0 }) + }) + + it('recovers a signal kill ahead of any non-zero exit marker', () => { + expect(parseExitStatus('gone\n[killed by signal: SIGKILL]')).toEqual({ body: 'gone', signal: 'SIGKILL' }) + // A fake signal marker with no leading newline is output, not a kill. + expect(parseExitStatus('[killed by signal: SIGKILL]')).toEqual({ body: '[killed by signal: SIGKILL]', exitCode: 0 }) + }) + + it('keeps markers no pill shows (timeout) in the body', () => { + expect(parseExitStatus('slow\n[timed out after 100ms]\n[exit code: 143]')) + .toEqual({ body: 'slow\n[timed out after 100ms]', exitCode: 143 }) + }) +}) diff --git a/packages/bash/tool-bash/src/render.ts b/packages/bash/tool-bash/src/render.ts index eabe681c25..64c63eb3be 100644 --- a/packages/bash/tool-bash/src/render.ts +++ b/packages/bash/tool-bash/src/render.ts @@ -95,36 +95,9 @@ export function renderProcessRead( } /** - * The exit status recovered from a rendered result, with the output body that - * status was split off from. + * The exit-status parse is the shared marker-contract half of the shell-tool + * rendering story, owned by `@deepseek-ai/dsh-bash` so `dsh-tool-pwsh` reuses + * it (its renderer emits the same markers). Re-exported here to keep + * `../src/render.ts` a single import root for bash-tool consumers. */ -export type ParsedExitStatus = - & { body: string } - & ({ exitCode: number } | { signal: string }) - -/** - * Split a rendered {@link renderResult} string into its output body and the - * structured exit status — the inverse of the status markers it appends. A - * killed marker yields `signal`; otherwise a non-zero marker yields `exitCode`; - * absent both means a clean exit 0. - * - * The consumed marker is removed from `body` because a terminal presentation - * shows the exit status as its own pill: leaving the marker in the output would - * render the exit twice. Other markers (timeout, sandbox denial) carry facts no - * pill shows, so they stay in the body. - * - * Replay only retains the rendered content text, not the original - * `BashRunResult`, so terminal presentation must recover the exit pill here. - * Requiring a leading newline and the end of the string keeps ordinary output - * that merely ends with marker-like text from matching unless the final line - * is indistinguishable from a real marker. - * @param text - rendered model-facing bash result. - * @returns the marker-free body plus the recovered terminal exit code or signal. - */ -export function parseExitStatus(text: string): ParsedExitStatus { - const signal = /\n\[killed by signal: ([^\]\n]+)\]$/.exec(text) - if (signal?.[1] !== undefined) return { body: text.slice(0, signal.index), signal: signal[1] } - const exit = /\n\[exit code: (\d+)\]$/.exec(text) - if (exit?.[1] !== undefined) return { body: text.slice(0, exit.index), exitCode: Number(exit[1]) } - return { body: text, exitCode: 0 } -} +export { parseExitStatus, type ParsedExitStatus } from '@deepseek-ai/dsh-bash' diff --git a/packages/bash/tool-pwsh/README.i18n.yaml b/packages/bash/tool-pwsh/README.i18n.yaml index 030d24c7c2..2b52e27d8c 100644 --- a/packages/bash/tool-pwsh/README.i18n.yaml +++ b/packages/bash/tool-pwsh/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/bash/tool-pwsh/README.md -README.md: dfe26a63684d61dcdd6f969c2c2261dac79325c7 -README.zh.md: 2344f8477e5b15f2c4d366dd82b46358eacbc1b7 +README.md: 78eb161f77b9524bc577b273abe59db6b931727c +README.zh.md: 0d00b4fa1e6988e57221feadc48b4b80b2d1e80b diff --git a/packages/bash/tool-pwsh/README.md b/packages/bash/tool-pwsh/README.md index dfe26a6368..78eb161f77 100644 --- a/packages/bash/tool-pwsh/README.md +++ b/packages/bash/tool-pwsh/README.md @@ -36,7 +36,7 @@ When `run_in_background` is true, this plugin preflights `ctx.tasks.start()` bef ## UI presentation -The tool owns its `presentCall`/`presentResult` render intent. A foreground call is a `terminal` card carrying command, description, and optional cwd; a `run_in_background` call is a `generic` card with the raw command, mirroring the bash tool's background presentation. A completed result is a `generic` card with the rendered output in a `console` fence. The bash tool's terminal card with its parsed exit-status pill has no pwsh counterpart yet — a PowerShell-aware presentation is roadmap work. These presenters are pure and replay-safe. +The tool owns its `presentCall`/`presentResult` render intent. A foreground call is a `terminal` card carrying command, description, and optional cwd; a `run_in_background` call is a `generic` card with the raw command, mirroring the bash tool's background presentation. A completed foreground result is a `terminal` card too: the exit marker becomes the card's exit-status pill (`exitCode`/`signal`), and the marker-free body is the card's output — exactly the bash tool's terminal-card story, via the shared exit-status parse from `@deepseek-ai/dsh-bash`. Background acks and execution errors stay `generic` cards with the rendered output in a `console` fence. These presenters are pure and replay-safe. ## Model Experience @@ -121,5 +121,4 @@ Append-only; newly visible content follows the reusable request prefix and does - **No sandbox escalation** — `sandbox_permissions`/`justification` are absent; escalation waits for a Windows-confining executor (the bash tool's sandbox surface is not mirrored). - **No persistent shell or PTY** — every call starts a fresh `pwsh -Command`; the PTY backends are Linux/macOS-only today, and a Windows ConPTY persistent shell is roadmap work. - **PowerShell-dialect contract** — the model must write PowerShell (native paths, `$env:` variables), not bash; there is no dialect translation. -- **Generic UI presentation** — results use the generic card; a PowerShell-aware terminal card with exit-status pill is roadmap work. - **Session-cwd identity is not canonicalized** — the workdir base is the session header cwd as-is, unlike the bash tool's sandbox-root-canonicalized identity; only the sandbox-less case applies here. diff --git a/packages/bash/tool-pwsh/README.zh.md b/packages/bash/tool-pwsh/README.zh.md index 2344f8477e..0d00b4fa1e 100644 --- a/packages/bash/tool-pwsh/README.zh.md +++ b/packages/bash/tool-pwsh/README.zh.md @@ -36,7 +36,7 @@ ## UI presentation -工具拥有自己的 `presentCall`/`presentResult` 呈现意图。前台调用是携带命令、描述与可选 cwd 的 `terminal` 卡;`run_in_background` 调用是携带原始命令的 `generic` 卡,镜像 bash 工具的后台呈现。完成的结果是以 `console` 围栏包裹渲染输出的 `generic` 卡。bash 工具那种带解析退出状态 pill 的 terminal 卡在 pwsh 侧暂无对应——PowerShell 感知的呈现属于路线图工作。这些 presenter 是纯函数且可重放。 +工具拥有自己的 `presentCall`/`presentResult` 呈现意图。前台调用是携带命令、描述与可选 cwd 的 `terminal` 卡;`run_in_background` 调用是携带原始命令的 `generic` 卡,镜像 bash 工具的后台呈现。完成的前台结果同样是 `terminal` 卡:退出 marker 变成卡片的退出状态 pill(`exitCode`/`signal`),去 marker 的正文成为卡片输出——与 bash 工具的 terminal 卡故事完全一致,经由 `@deepseek-ai/dsh-bash` 的共享退出状态解析。后台 ack 与执行错误保持 `generic` 卡,以 `console` 围栏包裹渲染输出。这些 presenter 是纯函数且可重放。 ## Model Experience @@ -121,5 +121,4 @@ ack 是固定短行;任务输出按读取有界。 - **无 sandbox 升级** — 没有 `sandbox_permissions`/`justification`;升级等待 Windows-confining 执行器(bash 工具的 sandbox 面不被镜像)。 - **无持久 shell 或 PTY** — 每次调用都启动全新的 `pwsh -Command`;PTY 后端目前仅限 Linux/macOS,Windows ConPTY 持久 shell 属于路线图工作。 - **PowerShell 方言契约** — 模型必须写 PowerShell(原生路径、`$env:` 变量),而不是 bash;没有方言翻译。 -- **通用 UI 呈现** — 结果使用 generic 卡;带退出状态 pill 的 PowerShell 感知 terminal 卡属于路线图工作。 - **会话 cwd 身份不做规范化** — workdir 基座直接取会话头 cwd 原值,不同于 bash 工具经 sandbox-root 规范化的身份;此处只涉及无 sandbox 场景。 diff --git a/packages/bash/tool-pwsh/src/index.ts b/packages/bash/tool-pwsh/src/index.ts index 9423fe36e6..a68d5d2e35 100644 --- a/packages/bash/tool-pwsh/src/index.ts +++ b/packages/bash/tool-pwsh/src/index.ts @@ -8,8 +8,9 @@ * foreground and `run_in_background` execution (background handles register * with the generic `ctx.tasks` runtime), the managed `DSH_*` environment * through the shared `bash-env` registry, and the bash marker/truncation - * rendering story. UI presentation stays on the existing generic/terminal - * cards; a pwsh-specific rendering twin is roadmap work. + * rendering story. UI presentation mirrors the bash tool's too: a completed + * foreground call is a terminal card with the parsed exit-status pill, using + * the shared exit-status parse from `@deepseek-ai/dsh-bash`. * * @module @deepseek-ai/dsh-tool-pwsh */ @@ -25,6 +26,7 @@ import type {} from '@deepseek-ai/dsh-system-prompt' import type {} from '@deepseek-ai/dsh-tasks' import type {} from '@deepseek-ai/dsh-bash-env' import type { BashRunResult } from '@deepseek-ai/dsh-bash' +import { parseExitStatus } from '@deepseek-ai/dsh-bash' import { processOutcome } from './background.ts' import { renderPwshProcessRead, renderPwshResult } from './render.ts' @@ -297,10 +299,20 @@ export function apply(ctx: Context, config: Config = {}): void { } }, /* jscpd:ignore-end */ - presentResult: (_args: unknown, result: ToolResult): ToolResultView | undefined => { + /* jscpd:ignore-start -- the completed-result presentation mirrors presentBashResult's by design (Agent Note). */ + presentResult: (args: unknown, result: ToolResult): ToolResultView | undefined => { const block = result.content.length === 1 ? result.content[0] : undefined if (block === undefined || block.type !== 'text') return undefined - return { card: 'generic', content: [{ type: 'text', text: `\`\`\`console\n${block.text.replace(/\n+$/, '')}\n\`\`\`` }] } + const raw = block.text + const isBackground = typeof args === 'object' && args !== null && (args as { run_in_background?: unknown }).run_in_background === true + // Background acknowledgements and errors have no terminal exit status. + if (isBackground || result.isError) { + return { card: 'generic', content: [{ type: 'text', text: `\`\`\`console\n${raw.replace(/\n+$/, '')}\n\`\`\`` }] } + } + // The exit marker becomes the card's exit pill, so it leaves the output body. + const { body, ...exit } = parseExitStatus(raw) + return { card: 'terminal', output: body, ...exit } }, + /* jscpd:ignore-end */ })) } diff --git a/packages/bash/tool-pwsh/tests/tools.spec.ts b/packages/bash/tool-pwsh/tests/tools.spec.ts index 218099326f..71e3124e7e 100644 --- a/packages/bash/tool-pwsh/tests/tools.spec.ts +++ b/packages/bash/tool-pwsh/tests/tools.spec.ts @@ -28,7 +28,7 @@ import * as ToolPwsh from '@deepseek-ai/dsh-tool-pwsh' import * as BashEnvPlugin from '@deepseek-ai/dsh-bash-env' import type { BashProcessRead } from '@deepseek-ai/dsh-bash' import { processOutcome } from '../src/background.ts' -import { renderPwshProcessRead } from '../src/render.ts' +import { renderPwshProcessRead, renderPwshResult } from '../src/render.ts' const testToolSignal = new AbortController().signal @@ -516,16 +516,16 @@ describe('background execution through the task runtime', () => { }) describe('UI presentation', () => { - it('a real execute renders the console view through the tool definition presenter', async () => { + it('a real execute presents a completed foreground run as a terminal card with the parsed exit pill', async () => { const { ctx, bash } = await setup() bash.handler = () => runResult('hi\n') const args = { command: 'Write-Output hi', description: 'say hi' } const result = await call(ctx, 'pwsh', args) const view = ctx.tools.get('pwsh')?.presentResult?.(args, result) - expect(view).toEqual({ - card: 'generic', - content: [{ type: 'text', text: '```console\nhi\n```' }], - }) + // A terminal result keeps the RAW bytes (newlines intact) a terminal + // renderer needs; a clean run renders no exit marker, so the body is the + // raw output with a clean exit-0 pill, mirroring the bash tool. + expect(view).toEqual({ card: 'terminal', output: 'hi\n', exitCode: 0 }) }) it('the pending call view is a terminal card carrying command, description, and optional cwd', async () => { @@ -553,6 +553,83 @@ describe('UI presentation', () => { }) }) + it('presentResult: a non-zero exit and a signal kill parse into exitCode / signal', async () => { + const { ctx } = await setup() + const present = ctx.tools.get('pwsh') + const args = { command: 'x', description: 'x' } + expect(present?.presentResult?.(args, { content: [{ type: 'text', text: 'oops\n[exit code: 3]' }], isError: false })) + .toEqual({ card: 'terminal', output: 'oops', exitCode: 3 }) + expect(present?.presentResult?.(args, { content: [{ type: 'text', text: 'gone\n[killed by signal: SIGKILL]' }], isError: false })) + .toEqual({ card: 'terminal', output: 'gone', signal: 'SIGKILL' }) + }) + + it('presentResult: markers a pill CANNOT show (timeout) stay in the terminal output', async () => { + const { ctx } = await setup() + const args = { command: 'x', description: 'x' } + expect(ctx.tools.get('pwsh')?.presentResult?.( + args, + { content: [{ type: 'text', text: 'slow\n[timed out after 100ms]\n[exit code: 143]' }], isError: false }, + )).toEqual({ card: 'terminal', output: 'slow\n[timed out after 100ms]', exitCode: 143 }) + }) + + it('presentResult exit parse is the inverse of renderPwshResult markers (round-trip)', async () => { + const { ctx } = await setup() + const present = ctx.tools.get('pwsh')! + const base = { + aborted: false, + timeoutMs: 1000, + stdout: { text: 'out', truncated: false }, + stderr: { text: '', truncated: false }, + } + const cases = [ + { result: { ...base, exitCode: 0, signal: null, timedOut: false }, expect: { exitCode: 0 } }, + { result: { ...base, exitCode: 7, signal: null, timedOut: false }, expect: { exitCode: 7 } }, + { result: { ...base, exitCode: null, signal: 'SIGTERM' as const, timedOut: false }, expect: { signal: 'SIGTERM' } }, + // A trapped-timeout run that exits 0 has no signal/exit marker → reads as exit 0 (it did exit 0). + { result: { ...base, exitCode: 0, signal: null, timedOut: true }, expect: { exitCode: 0 } }, + ] + for (const c of cases) { + const rendered = renderPwshResult(c.result) + const out = present.presentResult!({ command: 'x', description: 'x' }, { content: [{ type: 'text', text: rendered }], isError: false }) + // Drop card + output; the remaining fields are the parsed exit. + const { card: _c, output, ...exit } = out as { card: string; output?: string; exitCode?: number; signal?: string } + expect(exit).toEqual(c.expect) + // Whatever the parse consumed is gone from the body, so a card with an + // exit pill never shows the same status twice. + expect(output).not.toMatch(/\[exit code: \d+\]|\[killed by signal: /) + } + }) + + it('presentResult: a clean exit-0 whose output ENDS in marker-like text is NOT read as a failure', async () => { + const { ctx } = await setup() + const args = { command: 'Write-Output "[exit code: 5]"', description: 'print' } + // A successful command may print marker-like text. A clean result appends no marker or + // newline; parsing requires the leading newline emitted for real markers, so this stays exit 0. + const out = ctx.tools.get('pwsh')!.presentResult!(args, { content: [{ type: 'text', text: '[exit code: 5]' }], isError: false }) + expect(out).toEqual({ card: 'terminal', output: '[exit code: 5]', exitCode: 0 }) + // Same for a fake signal marker with no leading newline. + const sig = ctx.tools.get('pwsh')!.presentResult!(args, { content: [{ type: 'text', text: '[killed by signal: SIGKILL]' }], isError: false }) + expect(sig).toEqual({ card: 'terminal', output: '[killed by signal: SIGKILL]', exitCode: 0 }) + }) + + it('presentResult: a run_in_background ack is a generic card and carries no exit pill', async () => { + const { ctx } = await setup() + const result = ctx.tools.get('pwsh')!.presentResult!( + { command: 'Start-Sleep -Seconds 60', description: 'long wait', run_in_background: true }, + { content: [{ type: 'text', text: 'started background task pwsh-1' }], isError: false }, + ) + expect(result).toEqual({ card: 'generic', content: [{ type: 'text', text: '```console\nstarted background task pwsh-1\n```' }] }) + }) + + it('presentResult: an isError result is a generic card (no real process exit to report)', async () => { + const { ctx } = await setup() + const out = ctx.tools.get('pwsh')!.presentResult!( + { command: 'x', description: 'x' }, + { content: [{ type: 'text', text: 'tool call aborted' }], isError: true }, + ) + expect(out).toEqual({ card: 'generic', content: [{ type: 'text', text: '```console\ntool call aborted\n```' }] }) + }) + it('presentResult falls back to undefined for multi-block or non-text content', async () => { const { ctx } = await setup() const definition = ctx.tools.get('pwsh') diff --git a/packages/client/ui-conversation/src/client/contract/tool-call-model.ts b/packages/client/ui-conversation/src/client/contract/tool-call-model.ts index 41688c3de9..9ae5c4c85b 100644 --- a/packages/client/ui-conversation/src/client/contract/tool-call-model.ts +++ b/packages/client/ui-conversation/src/client/contract/tool-call-model.ts @@ -31,6 +31,9 @@ export const VARIANT_TITLES: Record = { /** Known tool name -> variant. */ const TOOL_VARIANTS: Record = { bash: 'bash', + // The PowerShell twin is a shell tool: the bash row family (icon, colors) + // with its own title from TOOL_TITLES, not the generic `others` row. + pwsh: 'bash', read: 'read', web_fetch: 'read', web_search: 'search', @@ -49,6 +52,7 @@ const TOOL_TITLES: Record = { cordis_inspect: 'Inspect', cordis_mount: 'Mount temporary Plugin', cordis_unmount: 'Unmount temporary Plugin', + pwsh: 'Pwsh', } /** diff --git a/packages/client/ui-conversation/tests/chat-tool-row.spec.tsx b/packages/client/ui-conversation/tests/chat-tool-row.spec.tsx index b312d1bd9a..810a810fbb 100644 --- a/packages/client/ui-conversation/tests/chat-tool-row.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-tool-row.spec.tsx @@ -59,6 +59,7 @@ const result = (over?: Partial): ToolResultNode => ({ describe('tool-call-model', () => { it('classifies known tools and falls back to others', () => { expect(classifyTool('bash')).toBe('bash') + expect(classifyTool('pwsh')).toBe('bash') expect(classifyTool('read')).toBe('read') expect(classifyTool('web_fetch')).toBe('read') expect(classifyTool('web_search')).toBe('search') @@ -71,6 +72,12 @@ describe('tool-call-model', () => { expect(classifyTool('todo_write')).toBe('others') }) + it('gives the pwsh shell row the bash family treatment with its own title', () => { + const m = toolRowModel('pwsh', running()) + expect(m.variant).toBe('bash') + expect(m.title).toBe('Pwsh') + }) + it('derives state across running/ok/error/interrupted', () => { expect(toolRowModel('bash', running()).state).toBe('running') expect(toolRowModel('bash', result()).state).toBe('ok') diff --git a/tsconfig.host.json b/tsconfig.host.json index c3eac58af5..ef5a65fe70 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -49,6 +49,7 @@ "apps/web/tests/chat-long-interactions.e2e.ts", "apps/web/tests/chat-continuous-conversation.e2e.ts", "apps/web/tests/complex-history.perf.ts", + "apps/web/tests/pwsh-terminal.e2e.ts", "apps/web/stress-tests/reasoning-chunks.stress.ts", "apps/cli/tests/**/*.ts", "examples/*/src/**/*.ts",