From 33810ae7747570d3764c9092dcc74e16fbb3212e Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sun, 2 Aug 2026 14:18:29 +0800 Subject: [PATCH] feat(tool-pwsh): mirror dsh-tool-bash call-for-call minus the sandbox surface --- packages/bash/tool-pwsh/README.i18n.yaml | 4 +- packages/bash/tool-pwsh/README.md | 46 +- packages/bash/tool-pwsh/README.zh.md | 122 +++--- packages/bash/tool-pwsh/package.json | 11 +- packages/bash/tool-pwsh/src/background.ts | 31 ++ packages/bash/tool-pwsh/src/index.ts | 220 +++++----- packages/bash/tool-pwsh/src/render.ts | 81 ++++ .../bash/tool-pwsh/tests/integration.spec.ts | 53 ++- packages/bash/tool-pwsh/tests/loader.spec.ts | 63 +++ packages/bash/tool-pwsh/tests/tools.spec.ts | 401 ++++++++++++++++-- packages/bash/tool-pwsh/tsconfig.json | 8 +- 11 files changed, 821 insertions(+), 219 deletions(-) create mode 100644 packages/bash/tool-pwsh/src/background.ts create mode 100644 packages/bash/tool-pwsh/src/render.ts create mode 100644 packages/bash/tool-pwsh/tests/loader.spec.ts diff --git a/packages/bash/tool-pwsh/README.i18n.yaml b/packages/bash/tool-pwsh/README.i18n.yaml index e16107e42f..102e50d0ce 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: 4f1d62dbf49fef678e3285776c466286535d66da -README.zh.md: bbeece3c648d8b1903eed1a66d2e14774c7ace8c +README.md: b5acc73a68d3b309860554d4c1e8d979eb8d1eec +README.zh.md: 4d678c42194b78da8b4b10e01f8b9e666d6236d8 diff --git a/packages/bash/tool-pwsh/README.md b/packages/bash/tool-pwsh/README.md index 4f1d62dbf4..b5acc73a68 100644 --- a/packages/bash/tool-pwsh/README.md +++ b/packages/bash/tool-pwsh/README.md @@ -2,13 +2,13 @@ English | [中文](README.zh.md) -The model-facing `pwsh` tool registered over the `ctx.bash` executor seam. Intended for Windows compositions where a PowerShell executor (e.g. `@deepseek-ai/dsh-pwsh-local`) backs `ctx.bash`; the tool contract is PowerShell-dialect: native `C:\...` paths and `$env:NAME` variables. Minimal by design — no background tasks, no sandbox escalation, no persistent shell: this is the "works on my Windows machine" profile until the full bash-tool feature set gets a PowerShell twin. +The model-facing `pwsh` tool registered over the `ctx.bash` executor seam. Intended for Windows compositions where a PowerShell executor (e.g. `@deepseek-ai/dsh-pwsh-local`) backs `ctx.bash`; the tool contract is PowerShell-dialect: native `C:\...` paths and `$env:NAME` variables. Behavior mirrors `dsh-tool-bash` call-for-call minus the sandbox surface — foreground and `run_in_background` execution through the generic task runtime, the managed `DSH_*` environment through the shared `bash-env` registry, and the bash marker/truncation rendering story (a clean exit produces no marker). -Requires a loaded executor implementation; the plugin stays pending until `ctx.bash` exists (`inject: ['tools', 'bash', 'systemPrompt']`). +Requires a loaded executor implementation and the `bash-env` plugin; the tool stays pending until both exist (`inject: ['tools', 'bash', 'systemPrompt', 'bashEnv']`). -The package root exposes only the Cordis plugin contract (`name`, `inject`, `Config`, `apply`) plus the pure `renderPwshOutput` helper and its result type; execution and presentation remain implementation details covered by same-package tests. +The package root exposes only the Cordis plugin contract (`name`, `inject`, `Config`, `apply`); result rendering (`src/render.ts`) and background-task adaptation (`src/background.ts`) mirror the bash tool's structure and stay reachable through the package's `./src/*` export. -The plugin also contributes the `tool:pwsh` prompt section (order 105): check the `[exit code: N]` marker on every result and investigate failures before moving on. +The plugin also contributes the `tool:pwsh` prompt section (order 105): non-zero exits are reported as `[exit code: N]` markers, and Windows interruption settles as exit 1 without a signal marker. ## Tools @@ -20,20 +20,23 @@ The plugin also contributes the `tool:pwsh` prompt section (order 105): check th | `description` | string (required) | One-line, active-voice summary of the command (5-10 words), for UI/log display only — no effect on execution. | | `timeoutMs` | number | Timeout override in milliseconds. The executor applies its configured default and cap. | | `workdir` | string | Working directory for this call. Defaults to the calling agent's session cwd (`session.header.cwd`) so each session runs in its own workspace; a relative `workdir` is resolved against that same identity. | +| `run_in_background` | boolean | Return a task id immediately; no timeout applies. | `command`, `workdir`, and `timeoutMs` are resolved against the executor's config defaults via `ctx.bash.resolve()` before execution. The workdir default is applied in the tool layer from the calling agent's `session.header.cwd` BEFORE `resolve()` — the per-session cwd must come from `exec.agent`, since N sessions share one executor; only when no session cwd is available does the executor fall back to its own config / `process.cwd()`. ### Managed shell environment -Every call receives a freshly collected trusted `DSH_*` environment. `DSH_HOME` is the absolute Harness home resolved by [`@deepseek-ai/dsh-paths`](../../util/paths/README.md) (`dshHome` config, then ambient `$DSH_HOME`, then `~/.dsh`) and `DSH_SHELL=1` identifies the managed child. Agent calls additionally receive `DSH_SESSION_ID=agent.session.header.id`. The snapshot passes through the dedicated `BashExecRequest.dshEnv` channel; `process.env` is never modified. +Every foreground and background model pwsh call receives a freshly collected trusted `DSH_*` environment through the shared [`dsh-bash-env`](../bash-env/) registry: `DSH_HOME` (the absolute Harness home), `DSH_SHELL=1`, the agent's `DSH_SESSION_ID`, and `DSH_SESSION_JSONL` when the active persistence backend locates one. Plugins contributing `DSH_*` facts to `ctx.bashEnv` apply to pwsh calls exactly as they do to bash calls. The snapshot passes through the dedicated `BashExecRequest.dshEnv` channel; `process.env` is never modified. The description teaches the generic `$env:DSH_*` convention rather than naming persistence-specific variables. -Result text contains stdout, an optional `[stderr]` section, then applicable timeout, signal, and exit-code markers: `[timed out after ms]`, `[killed by signal: ]`, and `[exit code: N]`, each separated by a newline only when the accumulated text lacks one. Nonzero exit remains a model-interpreted result rather than `isError`. Only infrastructure failures — spawn errors and aborts (`tool call aborted`) — produce `isError`. +Result text contains stdout, an optional `[stderr]` section, then applicable truncation, timeout, signal, and exit markers. A clean exit (0, no signal) produces no marker; an empty body renders as `(no output)`. Truncation links a safe complete spill file or reports it unavailable. Timeout is reported independently of final exit status; nonzero exit remains a model-interpreted result rather than `isError`. Windows reports forced termination as exit 1 without a signal, so `[killed by signal: …]` is POSIX-only there. Only infrastructure failures — spawn errors and aborts (`tool call aborted`) — produce `isError`. -The canonical success is `{ kind: 'foreground', ...BashRunResult }` for a completed foreground process. Programmatic consumers use the typed fields without parsing the rendered text. +The canonical success is `{ kind: 'foreground', ...BashRunResult }` for a completed foreground process or `{ kind: 'background', taskId }` for a published task. The renderer preserves exactly `started background task ` for background acks; programmatic consumers use the typed fields without parsing the rendered text. + +When `run_in_background` is true, this plugin preflights `ctx.tasks.start()` before spawning, registers the calling agent as owner, and adapts the returned `BashProcess` handle into generic cancel/done/incremental-output hooks. The task runtime owns ids, cross-session isolation, completion notices, waiting, and disposal cleanup; this plugin only maps pwsh exit facts into task output and outcome detail. `enableRunInBackground: false` removes the parameter and rejects a forced background call at execution time. ## UI presentation -The tool owns its `presentCall`/`presentResult` render intent. A call is a `terminal` card carrying command, description, and optional cwd; a completed result is a `generic` card with the rendered output in a `console` fence. These presenters are pure and replay-safe. +The tool owns its `presentCall`/`presentResult` render intent. A call is a `terminal` card carrying command, description, and optional cwd; 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. ## Model Experience @@ -46,7 +49,7 @@ Every request in this plugin's registration scope contains the pwsh guidance bel ##### Pwsh guidance ```markdown -Check the [exit code: N] marker on every pwsh result; investigate failures before moving on. +Non-zero exits are reported as `[exit code: N]` markers; investigate failures before moving on. On Windows a killed process settles as `[exit code: 1]` without a signal marker; treat a bare exit 1 after an interruption as a termination, not a command failure. ``` #### Token effect @@ -75,7 +78,7 @@ Prefix-stable while visibility and the tool definition are unchanged. A restrict #### What the model sees -The renderer emits the data-dependent stdout tail, then optional `[stderr]` and the stderr tail. Conditional lines are exactly `[timed out after ms]`, `[killed by signal: ]`, and `[exit code: ]`. +The renderer emits the data-dependent stdout tail, then optional `[stderr]` and the stderr tail. Conditional lines are exactly `[output truncated; full output: ]`, `[timed out after ms]`, `[killed by signal: ]`, and `[exit code: ]` (nonzero exits only); an empty body renders as `(no output)`. #### Token effect @@ -85,11 +88,25 @@ Zero result tokens before a call. Output is bounded per stream, while each emitt Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries. +### Background result + +#### What the model sees + +A background start renders exactly `started background task `; subsequent reads and status flow through the generic `task_output`/`task_kill` tools, including the lossy-read spill notice when in-memory truncation dropped unread bytes. + +#### Token effect + +The ack is a fixed short line; task output is bounded per read. + +#### KV Cache effect + +Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries. + ### Tool errors #### What the model sees -Validation and infrastructure failures are normalized as `Error: `. This package's stable messages are `invalid command: expected a non-empty string`, `invalid description: expected a non-empty string`, `invalid timeoutMs: expected a positive number, got `, and `tool call aborted`. +Validation and infrastructure failures are normalized as `Error: `. This package's stable messages are `invalid command: expected a non-empty string`, `invalid description: expected a non-empty string`, `invalid timeoutMs: expected a positive number, got `, `run_in_background is disabled for this deployment (enableRunInBackground: false)`, `background tasks unavailable: load @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks`, and `tool call aborted`. #### Token effect @@ -101,7 +118,8 @@ Append-only; newly visible content follows the reusable request prefix and does ## Known Limitations and Deferred Work -- **Foreground-only** — no `run_in_background`; long-running work must stay within the executor timeout or wait for the bash-tool twin. -- **No sandbox escalation** — `sandbox_permissions`/`justification` are absent; a confining composition denies through the executor, and escalation waits for the full twin. +- **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. -- **Windows-default roadmap deferred** — defaulting Windows hosts to `pwsh` over `bash`, and pwsh TUI/GUI rendering support, are planned separately and deliberately not part of this package yet. +- **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 bbeece3c64..4d678c4219 100644 --- a/packages/bash/tool-pwsh/README.zh.md +++ b/packages/bash/tool-pwsh/README.zh.md @@ -2,106 +2,124 @@ [English](README.md) | 中文 -面向模型的 `pwsh` 工具,注册在 `ctx.bash` 执行器 seam 之上。面向由 PowerShell 执行器(如 `@deepseek-ai/dsh-pwsh-local`)支撑 `ctx.bash` 的 Windows 组合;工具契约是 PowerShell 方言:原生 `C:\...` 路径与 `$env:NAME` 变量。刻意保持最小——无后台任务、无沙箱升级、无持久 shell:在完整 bash 工具功能集获得 PowerShell 孪生之前,这就是 "works on my Windows machine" 画像。 +注册在 `ctx.bash` 执行器 seam 之上的模型可见 `pwsh` 工具。面向由 PowerShell 执行器(如 `@deepseek-ai/dsh-pwsh-local`)支撑 `ctx.bash` 的 Windows 组合;工具契约是 PowerShell 方言:原生 `C:\...` 路径与 `$env:NAME` 变量。行为与 `dsh-tool-bash` 逐调用对齐、减去 sandbox 面——通过通用任务运行时执行前台与 `run_in_background`、通过共享 `bash-env` 注册表管理 `DSH_*` 环境、以及 bash 的 marker/截断渲染故事(干净退出不产生 marker)。 -需要一个已加载的执行器实现;插件在 `ctx.bash` 存在之前保持 pending(`inject: ['tools', 'bash', 'systemPrompt']`)。 +需要已加载的执行器实现与 `bash-env` 插件;两者都存在前工具保持 pending(`inject: ['tools', 'bash', 'systemPrompt', 'bashEnv']`)。 -包根只暴露 Cordis 插件契约(`name`、`inject`、`Config`、`apply`)以及纯函数 `renderPwshOutput` 及其结果类型;执行与呈现是同一包测试覆盖的实现细节。 +包根只导出 Cordis 插件契约(`name`、`inject`、`Config`、`apply`);结果渲染(`src/render.ts`)与后台任务适配(`src/background.ts`)镜像 bash 工具的结构,并可通过包的 `./src/*` 导出访问。 -该插件还贡献 `tool:pwsh` 提示词段(order 105):检查每个结果上的 `[exit code: N]` 标记,并在继续前调查失败。 +插件还贡献 `tool:pwsh` prompt section(order 105):非零退出以 `[exit code: N]` marker 报告,Windows 上的中断以无 signal 的 exit 1 结算。 ## 工具 ### `pwsh` -| 参数 | 类型 | 说明 | +| Arg | Type | Notes | |---|---|---| -| `command` | string(必填) | 通过 `pwsh -Command` 运行。调用之间不保留状态——用 `workdir`,不要用 `cd`。 | -| `description` | string(必填) | 命令的一句话主动语态摘要(5-10 词),仅用于 UI/日志展示——不影响执行。 | -| `timeoutMs` | number | 毫秒级超时覆盖。执行器应用其配置的默认值与上限。 | -| `workdir` | string | 本次调用的工作目录。默认取调用 agent(智能体)的会话 cwd(`session.header.cwd`),使每个会话在自己的工作区运行;相对 `workdir` 基于同一身份解析。 | +| `command` | string (required) | 通过 `pwsh -Command` 运行。调用之间不保留状态——用 `workdir`,不要用 `cd`。 | +| `description` | string (required) | 命令的一行主动语态摘要(5-10 词),仅用于 UI/日志展示——不影响执行。 | +| `timeoutMs` | number | 超时覆盖值(毫秒)。执行器应用其配置的默认值与上限。 | +| `workdir` | string | 本次调用的工作目录。默认取调用 agent 的会话 cwd(`session.header.cwd`),使每个会话在自己的工作区运行;相对 `workdir` 基于同一身份解析。 | +| `run_in_background` | boolean | 立即返回任务 id;不适用超时。 | -`command`、`workdir` 与 `timeoutMs` 在执行前经 `ctx.bash.resolve()` 按执行器配置默认值解析。workdir 默认值在工具层取自调用 agent 的 `session.header.cwd`,先于 `resolve()` 应用——每个会话的 cwd 必须来自 `exec.agent`,因为 N 个会话共享一个执行器;只有没有会话 cwd 时,执行器才回退到自己的配置 / `process.cwd()`。 +`command`、`workdir` 与 `timeoutMs` 在执行前经 `ctx.bash.resolve()` 按执行器配置默认值解析。workdir 默认值在工具层于 `resolve()` 之前从调用 agent 的 `session.header.cwd` 取得——每次会话的 cwd 必须来自 `exec.agent`,因为 N 个会话共享一个执行器;仅当没有会话 cwd 时执行器才回退到自己的配置 / `process.cwd()`。 -### 受管 shell 环境 +### Managed shell environment -每次调用都会收到一份新收集的受信 `DSH_*` 环境。`DSH_HOME` 是由 [`@deepseek-ai/dsh-paths`](../../util/paths/README.md) 解析的 Harness 绝对主目录(`dshHome` 配置,其次环境变量 `$DSH_HOME`,再其次 `~/.dsh`),`DSH_SHELL=1` 标识受管子进程。agent 调用额外收到 `DSH_SESSION_ID=agent.session.header.id`。该快照经由专用 `BashExecRequest.dshEnv` 通道传递;`process.env` 永不被修改。 +每次前台与后台模型 pwsh 调用都会通过共享的 [`dsh-bash-env`](../bash-env/) 注册表收到一份新收集的受信任 `DSH_*` 环境:`DSH_HOME`(Harness 主目录绝对路径)、`DSH_SHELL=1`、agent 的 `DSH_SESSION_ID`,以及活跃持久化后端定位到 JSONL 时的 `DSH_SESSION_JSONL`。向 `ctx.bashEnv` 贡献 `DSH_*` 事实的插件对 pwsh 调用与 bash 调用一视同仁。快照通过专用的 `BashExecRequest.dshEnv` 通道传递;`process.env` 永不被修改。描述只教授通用的 `$env:DSH_*` 约定,而不是点名持久化相关的变量。 -结果文本包含 stdout、可选的 `[stderr]` 分段,以及适用的超时、信号与退出码标记:`[timed out after ms]`、`[killed by signal: ]` 与 `[exit code: N]`,仅在累积文本缺少换行时才补一个分隔换行。非零退出仍是模型自行解读的结果,而不是 `isError`。只有基础设施失败——spawn 错误与中止(`tool call aborted`)——才产生 `isError`。 +结果文本包含 stdout、可选的 `[stderr]` 段,然后是适用的截断、超时、signal 与退出 marker。干净退出(0、无 signal)不产生 marker;空体渲染为 `(no output)`。截断会链接一个安全的完整 spill 文件,或报告其不可用。超时独立于最终退出状态报告;非零退出仍是模型解读的结果而非 `isError`。Windows 上强制终止以无 signal 的 exit 1 结算,因此 `[killed by signal: …]` 在那里仅存在于 POSIX。只有基础设施失败——spawn 错误与中止(`tool call aborted`)——产生 `isError`。 -规范成功值为已完成前台进程的 `{ kind: 'foreground', ...BashRunResult }`。程序化消费方使用类型化字段,而不解析渲染文本。 +规范成功形态是已完成前台进程的 `{ kind: 'foreground', ...BashRunResult }` 或已发布任务的 `{ kind: 'background', taskId }`。渲染器对后台 ack 精确保留 `started background task `;编程消费者使用类型化字段而不解析渲染文本。 -## UI 呈现 +当 `run_in_background` 为 true 时,本插件在 spawn 前预检 `ctx.tasks.start()`,把调用 agent 注册为 owner,并将返回的 `BashProcess` 句柄适配为通用的 cancel/done/增量输出钩子。任务运行时拥有 id、跨会话隔离、完成通知、等待与清理;本插件只把 pwsh 退出事实映射进任务输出与结果明细。`enableRunInBackground: false` 会移除参数并在执行时拒绝强制的后台调用。 -工具拥有自己的 `presentCall`/`presentResult` 渲染意图。调用是携带命令、描述与可选 cwd 的 `terminal` 卡片;完成结果是 `generic` 卡片,渲染输出放在 `console` 围栏内。这些 presenter 是纯函数且可重放。 +## UI presentation -## 模型体验 +工具拥有自己的 `presentCall`/`presentResult` 呈现意图。调用是携带命令、描述与可选 cwd 的 `terminal` 卡;完成的结果是以 `console` 围栏包裹渲染输出的 `generic` 卡。bash 工具那种带解析退出状态 pill 的 terminal 卡在 pwsh 侧暂无对应——PowerShell 感知的呈现属于路线图工作。这些 presenter 是纯函数且可重放。 -### 系统提示词 +## Model Experience -#### 模型看到的内容 +### System prompt -该插件注册作用域内的每个请求都包含下方 pwsh 指导。作用域工具限制可以隐藏 schema,而不移除这个独立注册的提示词段。 +#### What the model sees -##### Pwsh 指导 +本插件注册作用域内的每个请求都包含下面的 pwsh 指引。作用域工具限制可以隐藏 schema,但不会移除这个独立注册的段落。 + +##### Pwsh guidance ```markdown -Check the [exit code: N] marker on every pwsh result; investigate failures before moving on. +Non-zero exits are reported as `[exit code: N]` markers; investigate failures before moving on. On Windows a killed process settles as `[exit code: 1]` without a signal marker; treat a bare exit 1 after an interruption as a termination, not a command failure. ``` -#### Token 影响 +#### Token effect -插件激活期间每个请求有少量固定输入成本。 +插件激活期间每次请求的固定小额输入成本。 -#### KV Cache 影响 +#### KV Cache effect -注册作用域与提示词文本不变时前缀稳定。插件激活或销毁可能使该提示词段的复用失效。 +注册作用域与 prompt 文本不变时前缀稳定。插件激活或释放可能使该 prompt 段落的复用失效。 -### 工具 schema +### Tool schemas -#### 模型看到的内容 +#### What the model sees -模型看到生成的 [`pwsh` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-pwsh)。agent 作用域的工具限制可以为该 agent 移除定义。 +模型看到生成的 [`pwsh` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-pwsh)。按 agent 作用域的工具限制可以移除该 agent 的定义。 -#### Token 影响 +#### Token effect -工具可见时每个请求有固定的 schema 成本。 +工具可见的每个请求上的固定 schema 成本。 -#### KV Cache 影响 +#### KV Cache effect -可见性与工具定义不变时前缀稳定。限制或配置变更可能从第一个改变的 token 起使复用失效。 +可见性与工具定义不变时前缀稳定。限制或配置变更可能从首个变化 token 起使复用失效。 -### 前台结果 +### Foreground result -#### 模型看到的内容 +#### What the model sees -渲染器输出依赖数据的 stdout 尾部,然后是可选 `[stderr]` 与 stderr 尾部。条件行恰为 `[timed out after ms]`、`[killed by signal: ]` 与 `[exit code: ]`。 +渲染器输出数据相关的 stdout 尾部,然后是可选的 `[stderr]` 与 stderr 尾部。条件行精确为 `[output truncated; full output: ]`、`[timed out after ms]`、`[killed by signal: ]` 与 `[exit code: ]`(仅非零退出);空体渲染为 `(no output)`。 -#### Token 影响 +#### Token effect -调用前零结果 token。输出按流有界,每条已发出行在压缩前保留在历史中。 +调用前零结果 token。每个流的输出有界,而每条已发出的行保留在历史中直到压缩。 -#### KV Cache 影响 +#### KV Cache effect -只追加;新可见内容跟在可复用请求前缀之后,不会使既有 KV-cache 条目失效。 +仅追加;新出现的内容跟随可复用的请求前缀,不会使既有 KV-cache 条目失效。 -### 工具错误 +### Background result -#### 模型看到的内容 +#### What the model sees -校验与基础设施失败被规范化为 `Error: `。本包的稳定消息为 `invalid command: expected a non-empty string`、`invalid description: expected a non-empty string`、`invalid timeoutMs: expected a positive number, got ` 与 `tool call aborted`。 +后台启动精确渲染为 `started background task `;随后的读取与状态通过通用 `task_output`/`task_kill` 工具流转,包括内存截断丢弃未读字节时的 lossy 读取 spill 通知。 -#### Token 影响 +#### Token effect -只有失败的调用会增加这些保留 token;中止的调用不增加命令输出。 +ack 是固定短行;任务输出按读取有界。 -#### KV Cache 影响 +#### KV Cache effect -只追加;新可见内容跟在可复用请求前缀之后,不会使既有 KV-cache 条目失效。 +仅追加;新出现的内容跟随可复用的请求前缀,不会使既有 KV-cache 条目失效。 -## 已知局限与延期工作 +### Tool errors -- **仅前台**——没有 `run_in_background`;长时间运行的工作必须留在执行器超时之内,或等待 bash 工具孪生。 -- **无沙箱升级**——没有 `sandbox_permissions`/`justification`;受约束的组合通过执行器拒绝,升级等待完整孪生。 -- **PowerShell 方言契约**——模型必须写 PowerShell(原生路径、`$env:` 变量),而不是 bash;没有方言翻译。 -- **Windows 默认路线图延期**——让 Windows 主机默认用 `pwsh` 而非 `bash`,以及 pwsh TUI/GUI 渲染支持,都另行规划,刻意不纳入本包。 +#### What the model sees + +校验与基础设施失败规范化为 `Error: `。本包的稳定消息包括 `invalid command: expected a non-empty string`、`invalid description: expected a non-empty string`、`invalid timeoutMs: expected a positive number, got `、`run_in_background is disabled for this deployment (enableRunInBackground: false)`、`background tasks unavailable: load @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks` 与 `tool call aborted`。 + +#### Token effect + +只有失败的调用会新增这些保留 token;被中止的调用不产生命令输出。 + +#### KV Cache effect + +仅追加;新出现的内容跟随可复用的请求前缀,不会使既有 KV-cache 条目失效。 + +## Known Limitations and Deferred Work + +- **无 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/package.json b/packages/bash/tool-pwsh/package.json index 90e12438d8..7f43cd57e8 100644 --- a/packages/bash/tool-pwsh/package.json +++ b/packages/bash/tool-pwsh/package.json @@ -29,11 +29,11 @@ "peerDependencies": { "@deepseek-ai/dsh-agent": "^0.0.1", "@deepseek-ai/dsh-bash": "^0.0.1", + "@deepseek-ai/dsh-bash-env": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", - "@deepseek-ai/dsh-paths": "^0.0.1", - "@deepseek-ai/dsh-session-persistence": "^0.0.1", "@deepseek-ai/dsh-system-prompt": "^0.0.1", + "@deepseek-ai/dsh-tasks": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", "cordis": "^4.0.0-rc.7" }, @@ -43,13 +43,16 @@ "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-bash": "workspace:^", + "@deepseek-ai/dsh-bash-env": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", - "@deepseek-ai/dsh-paths": "workspace:^", + "@deepseek-ai/dsh-loader-smoke": "workspace:^", "@deepseek-ai/dsh-pwsh-local": "workspace:^", - "@deepseek-ai/dsh-session-persistence": "workspace:^", "@deepseek-ai/dsh-subprocess-local": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", + "@deepseek-ai/dsh-tasks": "workspace:^", + "@deepseek-ai/dsh-tasks-local": "workspace:^", + "@deepseek-ai/dsh-tool-tasks": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "cordis": "^4.0.0-rc.7" } diff --git a/packages/bash/tool-pwsh/src/background.ts b/packages/bash/tool-pwsh/src/background.ts new file mode 100644 index 0000000000..5e3464f76b --- /dev/null +++ b/packages/bash/tool-pwsh/src/background.ts @@ -0,0 +1,31 @@ +/** + * Generic-task adaptation for background pwsh process handles — the shell-agnostic + * twin of `dsh-tool-bash`'s background adaptation. + * + * @module @deepseek-ai/dsh-tool-pwsh/background + */ + +import type { BashProcess } from '@deepseek-ai/dsh-bash' + +/* jscpd:ignore-start -- deliberate twin of dsh-tool-bash/background.ts (Agent Note). */ + +/** + * Map a settled background process onto the generic task-outcome vocabulary: + * `killed` stays `killed` (detail: the signal when one is known), everything + * else is `completed` with the exit code as detail. A nonzero command exit is + * reported, not failed, exactly like the foreground rendering. + * @param proc - the settled process handle. + * @returns the outcome for the `ctx.tasks` registration. + */ +export function processOutcome(proc: BashProcess): { status: 'completed' | 'killed'; detail: string } { + // TODO(background-infrastructure-outcome): widen BashProcess with an explicit + // infrastructure-failure outcome, then map spawn failures and + // sandbox.runnerFailed to task `failed`. The current seam aliases a spawn + // failure with a signal-less kill and a runner failure with an ordinary + // wrapper exit; real nonzero command exits must remain `completed`. + if (proc.status === 'killed') { + return { status: 'killed', detail: proc.signal !== null ? `signal: ${proc.signal}` : 'killed before exit' } + } + return { status: 'completed', detail: `exit code: ${proc.exitCode ?? 0}` } +} +/* jscpd:ignore-end */ diff --git a/packages/bash/tool-pwsh/src/index.ts b/packages/bash/tool-pwsh/src/index.ts index 704c35647c..c8a02f0428 100644 --- a/packages/bash/tool-pwsh/src/index.ts +++ b/packages/bash/tool-pwsh/src/index.ts @@ -4,38 +4,48 @@ * `@deepseek-ai/dsh-pwsh-local`) backs `ctx.bash`; the tool contract is * PowerShell-dialect: native `C:\...` paths and `$env:NAME` variables. * - * Minimal by design: no background tasks, no sandbox escalation — this is the - * "works on my Windows machine" profile until the full bash-tool feature set - * gets a PowerShell twin. + * Behavior mirrors `dsh-tool-bash` call-for-call minus the sandbox surface: + * 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. * * @module @deepseek-ai/dsh-tool-pwsh */ import { isAbsolute, resolve as resolvePath } from 'node:path' -import { Context } from 'cordis' +import type { Context } from 'cordis' import z from 'schemastery' import { defineTool, TOOL_ABORTED } from '@deepseek-ai/dsh-tools' -import type { TerminalCallView, ToolExecution, ToolResult, ToolResultView } from '@deepseek-ai/dsh-tools' +import type { TerminalCallView, ToolResult, ToolResultView } from '@deepseek-ai/dsh-tools' import { HarnessError } from '@deepseek-ai/dsh-llm' import type { Agent } from '@deepseek-ai/dsh-agent' -import type {} from '@deepseek-ai/dsh-session-persistence' import type {} from '@deepseek-ai/dsh-system-prompt' -import { DSH_ENV_PREFIX } from '@deepseek-ai/dsh-bash' -import type { BashRunResult, DshEnvironment } from '@deepseek-ai/dsh-bash' -import { DSH_HOME_ENV, resolveDshHome } from '@deepseek-ai/dsh-paths' +import type {} from '@deepseek-ai/dsh-tasks' +import type {} from '@deepseek-ai/dsh-bash-env' +import type { BashRunResult } from '@deepseek-ai/dsh-bash' +import { processOutcome } from './background.ts' +import { renderPwshProcessRead, renderPwshResult } from './render.ts' + +declare module '@deepseek-ai/dsh-tasks' { + interface TaskKindMap { + pwsh: 'pwsh' + } +} export const name = 'tool-pwsh' -export const inject = ['tools', 'bash', 'systemPrompt'] +export const inject = ['tools', 'bash', 'systemPrompt', 'bashEnv'] -/** Plugin config (currently empty; kept as a schema so deployments can grow it). */ +/** Configuration for the pwsh tool. */ export interface Config { - /** DeepSeek Harness home directory exposed as `DSH_HOME`; defaults to `$DSH_HOME` or `~/.dsh`. */ - dshHome?: string + /** Expose `run_in_background` (default true); disabled calls are also rejected. */ + enableRunInBackground?: boolean } /** Runtime configuration schema for the pwsh tool plugin. */ export const Config: z = z.object({ - dshHome: z.string(), + enableRunInBackground: z.boolean().default(true), }) /** Parsed tool args; execute validates value constraints absent from ParameterSchemaSpec. */ @@ -44,6 +54,7 @@ interface PwshToolArgs { description: string timeoutMs?: number workdir?: string + run_in_background?: boolean } /** The canonical foreground result of one pwsh call (the `output.schema` value shape). */ @@ -72,12 +83,18 @@ function validatePwshArgs(args: PwshToolArgs): void { } /* jscpd:ignore-end */ -function pwshDescription(): string { +function pwshDescription(backgroundEnabled: boolean): string { + const background = backgroundEnabled + ? 'Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.' + : 'Background execution is not available; long-running commands must finish within the timeout.' return 'Execute a PowerShell command (`pwsh -Command`) and return its stdout/stderr. ' + 'Each call runs in a fresh pwsh process: no state (cwd, variables, functions) persists between calls — ' + 'pass `workdir` instead of using `cd`. Paths use native Windows form (`C:\\...`); read environment ' + 'variables with `$env:NAME`. Non-zero exits are reported as `[exit code: N]`. ' - + 'Long output is truncated to its tail; the full output is saved to a file whose path is reported when available.' + + 'Current harness environment facts are exposed through managed `$env:DSH_*` variables; inspect them when needed. ' + + 'Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. ' + + 'On Windows a force-killed command settles as `[exit code: 1]` without a signal marker — treat it as an interruption, not a command failure. ' + + background } /** @@ -93,32 +110,7 @@ function resolveWorkdir(modelWorkdir: string | undefined, exec: { agent?: Agent return modelWorkdir } -/** - * The model-facing text of one foreground pwsh result: stdout, a marked - * stderr section, then the applicable timeout, signal, and exit markers — - * each separated by a newline only when the accumulated text lacks one, so a - * trailing newline in stdout never produces a blank line. - * - * @param value - the canonical foreground result (the schema-derived value shape). - * @returns the model-facing text. - */ -function renderPwshOutput(value: RenderablePwshOutput): string { - let rendered = value.stdout.text - const marker = (line: string): void => { - rendered += rendered.length > 0 && !rendered.endsWith('\n') ? `\n${line}` : line - } - if (value.stderr.text.length > 0) marker(`[stderr]\n${value.stderr.text}`) - if (value.timedOut) marker(`[timed out after ${value.timeoutMs}ms]`) - if (value.signal !== null) marker(`[killed by signal: ${value.signal}]`) - if (value.exitCode !== null) marker(`[exit code: ${value.exitCode}]`) - return rendered -} - -/** - * Detach the executor DTO from readonly seam interfaces into plain JSON data. - * @param result - the executor's run outcome. - * @returns the canonical foreground result the tool returns and renders. - */ +/** Detach the executor DTO from readonly seam interfaces into plain JSON data. */ function canonicalPwshResult(result: BashRunResult): PwshForegroundResult { const output = (stream: BashRunResult['stdout']) => ({ text: stream.text, @@ -132,48 +124,32 @@ function canonicalPwshResult(result: BashRunResult): PwshForegroundResult { timedOut: result.timedOut, aborted: result.aborted, timeoutMs: result.timeoutMs, + /* jscpd:ignore-start -- the canonical projection and background-handle shape mirror dsh-tool-bash's by design (Agent Note). */ stdout: output(result.stdout), stderr: output(result.stderr), } } -/** The rendered fields of a foreground result — the schema-derived value shape (no `kind`, plain-string signal). */ -interface RenderablePwshOutput { - exitCode: number | null - signal: string | null - timedOut: boolean - timeoutMs: number - stdout: { text: string } - stderr: { text: string } -} - -/** - * The managed `DSH_*` snapshot for one pwsh call: the harness home, a shell - * marker, and the session identity when an agent is present. - */ -function collectDshEnv(exec: ToolExecution, dshHome: string): DshEnvironment { - const values: Record = { - [DSH_HOME_ENV]: dshHome, - [`${DSH_ENV_PREFIX}SHELL`]: '1', - } - if (exec.agent !== undefined) { - values[`${DSH_ENV_PREFIX}SESSION_ID`] = exec.agent.session.header.id - } - return values -} +/** Canonical background-handle properties shared by the pwsh output union. */ +const BACKGROUND_OUTPUT_PROPERTIES = { + kind: { type: 'string', required: true, const: 'background' }, + taskId: { type: 'string', required: true }, +} as const +/* jscpd:ignore-end */ export function apply(ctx: Context, config: Config = {}): void { - const dshHome = resolveDshHome(config.dshHome) + const backgroundEnabled = config.enableRunInBackground ?? true ctx.systemPrompt.section({ name: 'tool:pwsh', order: 105, - text: 'Check the [exit code: N] marker on every pwsh result; investigate failures before moving on.', + text: 'Non-zero exits are reported as `[exit code: N]` markers; investigate failures before moving on. ' + + 'On Windows a killed process settles as `[exit code: 1]` without a signal marker; treat a bare exit 1 after an interruption as a termination, not a command failure.', }) ctx.tools.register(defineTool({ name: 'pwsh', - description: pwshDescription(), + description: pwshDescription(backgroundEnabled), parameters: { command: { type: 'string', required: true, description: 'The PowerShell command to execute.' }, description: { @@ -185,59 +161,111 @@ export function apply(ctx: Context, config: Config = {}): void { }, timeoutMs: { type: 'number', description: 'Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry.' }, workdir: { type: 'string', description: 'Working directory for this command. Defaults to the session workspace; a relative path is resolved against it.' }, + ...backgroundEnabled ? { + run_in_background: { type: 'boolean' as const, description: 'Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies.' }, + } : {}, }, output: { // The foreground result wire shape mirrors dsh-tool-bash's by contract — // consumers of one must accept the other (see the pwsh-tool-and-executor // Agent Note). - /* jscpd:ignore-start -- deliberate foreground-result schema symmetry with dsh-tool-bash. */ + /* jscpd:ignore-start -- deliberate result-schema symmetry with dsh-tool-bash. */ schema: { - type: 'object', - additionalProperties: false, - properties: { - kind: { type: 'string', required: true, const: 'foreground' }, - exitCode: { required: true, oneOf: [{ type: 'integer' }, { type: 'null' }] }, - signal: { required: true, oneOf: [{ type: 'string' }, { type: 'null' }] }, - timedOut: { type: 'boolean', required: true }, - aborted: { type: 'boolean', required: true }, - timeoutMs: { type: 'number', required: true }, - stdout: { + oneOf: [ + { type: 'object', additionalProperties: false, - required: true, - properties: { - text: { type: 'string', required: true }, - truncated: { type: 'boolean', required: true }, - spillPath: { type: 'string' }, - }, + properties: BACKGROUND_OUTPUT_PROPERTIES, }, - stderr: { + { type: 'object', additionalProperties: false, - required: true, properties: { - text: { type: 'string', required: true }, - truncated: { type: 'boolean', required: true }, - spillPath: { type: 'string' }, + kind: { type: 'string', required: true, const: 'foreground' }, + exitCode: { required: true, oneOf: [{ type: 'integer' }, { type: 'null' }] }, + signal: { required: true, oneOf: [{ type: 'string' }, { type: 'null' }] }, + timedOut: { type: 'boolean', required: true }, + aborted: { type: 'boolean', required: true }, + timeoutMs: { type: 'number', required: true }, + stdout: { + type: 'object', + additionalProperties: false, + required: true, + properties: { + text: { type: 'string', required: true }, + truncated: { type: 'boolean', required: true }, + spillPath: { type: 'string' }, + }, + }, + stderr: { + type: 'object', + additionalProperties: false, + required: true, + properties: { + text: { type: 'string', required: true }, + truncated: { type: 'boolean', required: true }, + spillPath: { type: 'string' }, + }, + }, }, }, - }, + ], }, /* jscpd:ignore-end */ render: (_args, value) => [{ type: 'text', - text: renderPwshOutput(value), + text: value.kind === 'background' + ? `started background task ${value.taskId}` + : renderPwshResult(value), }], }, - /* jscpd:ignore-start -- the foreground execute path mirrors dsh-tool-bash's by design (see the pwsh-tool-and-executor Agent Note). */ + /* jscpd:ignore-start -- the execute path mirrors dsh-tool-bash's by design (see the pwsh-tool-and-executor Agent Note). */ async execute(args: PwshToolArgs, exec) { validatePwshArgs(args) const workdir = resolveWorkdir(args.workdir, exec) - const result = await ctx.bash.run(ctx.bash.resolve({ + const request = { command: args.command, ...workdir !== undefined ? { workdir } : {}, ...args.timeoutMs !== undefined ? { timeoutMs: args.timeoutMs } : {}, - dshEnv: collectDshEnv(exec, dshHome), + dshEnv: ctx.bashEnv.collect(exec), + } + if (args.run_in_background === true) { + // Undeclared keys are allowed, so schema omission also needs enforcement. + if (!backgroundEnabled) { + throw new Error('run_in_background is disabled for this deployment (enableRunInBackground: false)') + } + const tasks = ctx.get('tasks') + if (tasks === undefined) { + throw new Error('background tasks unavailable: load @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks') + } + // The caller owns cancellation until ctx.tasks commits detached ownership. + /* v8 ignore start -- the bash twin's branch is exercised by its sandbox-approval mid-call abort; + pwsh has no approval surface, and the tool registry's pre-dispatch abort check intercepts + already-aborted signals first, so this mirror-only guard has no reachable trigger. */ + if (exec.signal.aborted) { + const error = new HarnessError('tool call aborted', TOOL_ABORTED) + error.name = 'AbortError' + throw error + } + /* v8 ignore end */ + // Task preflight finishes before the starter can spawn a process. + const id = tasks.start({ + kind: 'pwsh', + label: args.command, + ...exec.agent ? { owner: exec.agent } : {}, + run: () => { + const proc = ctx.bash.start(ctx.bash.resolve(request)) + return { + cancel: () => void proc.kill(), + done: proc.done.then(() => processOutcome(proc)), + readOutput: () => renderPwshProcessRead(proc.readOutput()), + } + }, + }) + return { kind: 'background' as const, taskId: id } + } + const result = await ctx.bash.run(ctx.bash.resolve({ + ...request, signal: exec.signal, })) if (result.aborted) { diff --git a/packages/bash/tool-pwsh/src/render.ts b/packages/bash/tool-pwsh/src/render.ts new file mode 100644 index 0000000000..42f4bc696c --- /dev/null +++ b/packages/bash/tool-pwsh/src/render.ts @@ -0,0 +1,81 @@ +/** + * Model-facing result rendering for the pwsh tool — the PowerShell twin of + * `dsh-tool-bash`'s renderer minus the sandbox surface: stdout, a marked + * stderr section, truncation notices with spill paths, then exit-status + * markers. Non-zero exits are reported, not errored — the model decides how to + * react; only infrastructure failures (spawn errors, aborts) surface as + * isError results. + * + * @module @deepseek-ai/dsh-tool-pwsh/render + */ + +import type { BashProcessRead, CollectedOutput } from '@deepseek-ai/dsh-bash' + +/* jscpd:ignore-start -- deliberate twin of dsh-tool-bash/render.ts minus the sandbox surface (Agent Note). */ + +/** Append the truncation notice (with the full-output spill path) to a stream's text. */ +function streamText(output: CollectedOutput): string { + if (!output.truncated) return output.text + return `${output.text}\n[output truncated; full output: ${output.spillPath ?? '(unavailable)'}]` +} + +/** The renderable foreground result shape (the schema-derived value, no `kind`). */ +export interface RenderablePwshResult { + exitCode: number | null + signal: string | null + timedOut: boolean + timeoutMs: number + stdout: CollectedOutput + stderr: CollectedOutput +} + +/** + * Shape one finished run into the text the model sees: stdout, then a marked + * stderr section, then exit-status markers, matching the bash tool's story — + * a clean exit (0, no signal) produces no marker. + * @param result - the completed foreground run from the executor. + * @returns the model-facing text: output body (or `(no output)`), then any timeout/signal/exit markers, each on its own line. + */ +export function renderPwshResult(result: RenderablePwshResult): string { + const out = streamText(result.stdout) + const err = streamText(result.stderr) + + let body = out + if (err.length > 0) { + // Single newline between sections (stdout usually ends with one already). + if (body.length > 0 && !body.endsWith('\n')) body += '\n' + body += `[stderr]\n${err}` + } + if (body.length === 0) body = '(no output)' + + const markers: string[] = [] + // A command may trap the termination and exit 0 after timeout; still report interruption. + if (result.timedOut) markers.push(`[timed out after ${result.timeoutMs}ms]`) + if (result.signal !== null) { + markers.push(`[killed by signal: ${result.signal}]`) + } else if (result.exitCode !== 0) { + markers.push(`[exit code: ${result.exitCode}]`) + } + if (markers.length === 0) return body + + if (!body.endsWith('\n')) body += '\n' + return body + markers.join('\n') +} + +/** + * Shape one background-process read into the `task_output` delta the model + * sees: the incremental delta, plus the lossy-read notice (with full-stream + * spill paths) when in-memory truncation dropped unread bytes. + * @param read - one incremental read from the process handle. + * @returns the delta text with any loss notice appended. + */ +export function renderPwshProcessRead(read: BashProcessRead): string { + const notices: string[] = [] + if (read.lossy) { + const paths = [read.stdoutSpillPath, read.stderrSpillPath].filter((path): path is string => path !== undefined) + notices.push(`[some output was dropped from memory; full output: ${paths.length > 0 ? paths.join(', ') : '(unavailable)'}]`) + } + if (notices.length === 0) return read.delta + return `${read.delta}${read.delta.length > 0 && !read.delta.endsWith('\n') ? '\n' : ''}${notices.join('\n')}` +} +/* jscpd:ignore-end */ diff --git a/packages/bash/tool-pwsh/tests/integration.spec.ts b/packages/bash/tool-pwsh/tests/integration.spec.ts index c703c6aa0e..711f7663b1 100644 --- a/packages/bash/tool-pwsh/tests/integration.spec.ts +++ b/packages/bash/tool-pwsh/tests/integration.spec.ts @@ -2,10 +2,11 @@ * Integration tests: the REAL `@deepseek-ai/dsh-pwsh-local` executor plus the * `pwsh` tool, exercised through `ctx.tools.execute()` with a real PowerShell * process. These verify the world — actual commands run, stdout/stderr come - * back, exit codes render, timeouts abort, and per-session cwd resolution - * works. The suite self-skips when no `pwsh` is on PATH (a CI accommodation - * for hosts without PowerShell); the fake-executor suite (tools.spec.ts) - * carries the coverage gate. + * back, exit codes render, timeouts abort, background tasks settle through the + * generic task runtime, and per-session cwd resolution works. The suite + * self-skips when no `pwsh` is on PATH (a CI accommodation for hosts without + * PowerShell); the fake-executor suite (tools.spec.ts) carries the coverage + * gate. */ import { afterEach, beforeEach, describe, expect, it } from 'vitest' @@ -17,13 +18,18 @@ import { Context } from 'cordis' import { CallId } from '@deepseek-ai/dsh-llm' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { TOOL_ABORTED } from '@deepseek-ai/dsh-tools' +import LocalTaskService from '@deepseek-ai/dsh-tasks-local' +import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks' import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' -import { PwshLocalExecutor } from '@deepseek-ai/dsh-pwsh-local' +import { PwshLocalExecutor, resolvePwshPath } from '@deepseek-ai/dsh-pwsh-local' import * as ToolPwsh from '@deepseek-ai/dsh-tool-pwsh' +import * as BashEnvPlugin from '@deepseek-ai/dsh-bash-env' const testToolSignal = new AbortController().signal -const hasPwsh = spawnSync('pwsh', ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', '$true'], { encoding: 'utf8' }).status === 0 +// The probe follows the executor's own resolution (Program Files installs on +// Windows are found even when bare `pwsh` is not on PATH). +const hasPwsh = spawnSync(resolvePwshPath(), ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', '$true'], { encoding: 'utf8' }).status === 0 /** Normalize PowerShell's platform line endings (CRLF on Windows, LF elsewhere). */ const lf = (text: string): string => text.replace(/\r\n/g, '\n') @@ -54,7 +60,10 @@ describe.skipIf(!hasPwsh)('pwsh tool over the real pwsh executor', () => { ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) + await ctx.plugin(LocalTaskService) + await ctx.plugin(ToolTasks) await ctx.plugin(LocalSubprocessService) + await ctx.plugin(BashEnvPlugin) await ctx.plugin(PwshLocalExecutor, { timeoutMs: 20_000, graceMs: 200 }) await ctx.plugin(ToolPwsh) }) @@ -65,12 +74,12 @@ describe.skipIf(!hasPwsh)('pwsh tool over the real pwsh executor', () => { const agent = () => ({ session: { header: { id: 'session-int', cwd: dir } } }) - it('runs a command and returns stdout with the exit marker', async () => { + it('runs a command and returns stdout with no marker on a clean exit', async () => { const result = await call('pwsh', { command: 'Write-Output hi', description: 'say hi' }, agent()) expect(result.isError).toBe(false) if (result.isError) throw new Error('expected pwsh success') expect(result.value).toMatchObject({ kind: 'foreground', exitCode: 0 }) - expect(lf(text(result))).toBe('hi\n[exit code: 0]') + expect(lf(text(result))).toBe('hi\n') }) it('returns stderr in a marked section and a nonzero exit as a marker, not an error', async () => { @@ -88,7 +97,7 @@ describe.skipIf(!hasPwsh)('pwsh tool over the real pwsh executor', () => { description: 'read greeting', }, agent()) expect(result.isError).toBe(false) - expect(lf(text(result))).toBe('hello pwsh\n[exit code: 0]') + expect(lf(text(result))).toBe('hello pwsh\n') }) it('a per-call timeout kills the run and reports the timed-out marker, not an error', async () => { @@ -116,4 +125,30 @@ describe.skipIf(!hasPwsh)('pwsh tool over the real pwsh executor', () => { expect(result.isError).toBe(true) expect(result.error).toMatchObject({ info: { name: 'AbortError', code: TOOL_ABORTED } }) }) + + it('a background run settles through the REAL task_output tool', async () => { + const started = await call('pwsh', { + command: 'Start-Sleep -Milliseconds 300; Write-Output bg-done', + description: 'background greeting', + run_in_background: true, + }) + expect(started.isError).toBe(false) + if (started.isError) throw new Error('expected background pwsh success') + expect(started.value).toMatchObject({ kind: 'background' }) + const taskId = (started.value as { taskId: string }).taskId + + // The output delta and the terminal status can land in separate reads + // (Windows flushes the child pipe at exit), so collect incrementally — + // the same two-step shape as the bash background suite. + const deadline = Date.now() + 10_000 + let output = '' + while (Date.now() < deadline) { + const read = await call('task_output', { task_id: taskId }) + output += text(read) + if (output.includes('bg-done') && output.includes('[status: completed, exit code: 0]')) break + await new Promise(resolve => setTimeout(resolve, 50)) + } + expect(output).toContain('bg-done') + expect(output).toContain('[status: completed, exit code: 0]') + }) }) diff --git a/packages/bash/tool-pwsh/tests/loader.spec.ts b/packages/bash/tool-pwsh/tests/loader.spec.ts new file mode 100644 index 0000000000..7037162579 --- /dev/null +++ b/packages/bash/tool-pwsh/tests/loader.spec.ts @@ -0,0 +1,63 @@ +/** + * REAL-composition tier (packages/AGENTS.md): boot the examples-owned + * tool-pwsh Loader fixture as a subprocess through the same app/boot path a + * deployment uses, execute real foreground and background pwsh commands + * through the tool registry, and assert the assembled model-visible surface: + * schema, prompt section, and rendered results. Self-skips when no `pwsh` + * executable exists (a CI accommodation for hosts without PowerShell). + */ + +import { readFile } from 'node:fs/promises' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { spawnSync } from 'node:child_process' +import { describe, expect, it } from 'vitest' +import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke' +import { resolvePwshPath } from '@deepseek-ai/dsh-pwsh-local' + +// The probe follows the executor's own resolution (Program Files installs on +// Windows are found even when bare `pwsh` is not on PATH). +const hasPwsh = spawnSync(resolvePwshPath(), ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', '$true'], { encoding: 'utf8' }).status === 0 + +const driver = fileURLToPath(new URL( + '../../../../examples/acp-agent/tests/fixtures/bash/tool-pwsh/driver.ts', + import.meta.url, +)) +const configPath = fileURLToPath(new URL( + '../../../../examples/acp-agent/tests/fixtures/bash/tool-pwsh/cordis.yml', + import.meta.url, +)) +const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url)) + +interface PwshLoaderReport { + schemaHasRunInBackground: boolean + promptHasMarkerSection: boolean + foregroundText: string + backgroundText: string +} + +describe.skipIf(!hasPwsh)('tool-pwsh through a real Loader composition', () => { + it('registers the pwsh surface and renders real foreground and background results', async () => { + let report: PwshLoaderReport | undefined + const { stderr } = await runLoaderSmoke({ + label: 'tool-pwsh loader smoke', + tempDirPrefix: 'tool-pwsh-loader-', + binScript: driver, + libBinScript: driver, + configPath, + tsconfigPath: repoTsconfig, + inspect: async (cwd) => { + report = JSON.parse(await readFile(join(cwd, 'pwsh-loader-report.json'), 'utf8')) as PwshLoaderReport + }, + }) + expect(stderr).not.toContain('UNHANDLED') + expect(report).toBeDefined() + expect(report).toMatchObject({ + schemaHasRunInBackground: true, + promptHasMarkerSection: true, + }) + expect(report?.foregroundText).toBe('loader-ok\n') + expect(report?.backgroundText).toContain('loader-bg-ok') + expect(report?.backgroundText).toContain('[status: completed, exit code: 0]') + }, LOADER_SMOKE_TEST_TIMEOUT_MS) +}) diff --git a/packages/bash/tool-pwsh/tests/tools.spec.ts b/packages/bash/tool-pwsh/tests/tools.spec.ts index 5361130991..9d1eedba08 100644 --- a/packages/bash/tool-pwsh/tests/tools.spec.ts +++ b/packages/bash/tool-pwsh/tests/tools.spec.ts @@ -2,10 +2,11 @@ * Consumer-surface tests for the `pwsh` tool over a FAKE bash executor, * exercised through `ctx.tools.execute()` so nothing bypasses the tool * registry. The fake executor makes every seam outcome scriptable — output - * text, truncation, timeout, abort, nonzero exits — so these tests verify the - * schema, argument validation, workdir derivation, managed `DSH_*` collection, - * abort translation, canonical result projection, rendering, and the UI - * presenters. Real-pwsh behavior is pinned separately in integration.spec.ts. + * text, truncation, timeout, abort, nonzero exits, background handles — so + * these tests verify the schema, argument validation, workdir derivation, + * managed `DSH_*` collection, abort translation, canonical result projection, + * rendering, background task wiring, and the UI presenters. Real-pwsh behavior + * is pinned separately in integration.spec.ts. */ import { describe, expect, it } from 'vitest' @@ -15,23 +16,33 @@ import { tmpdir } from 'node:os' import { join, resolve as resolvePath } from 'node:path' import { CallId } from '@deepseek-ai/dsh-llm' import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt' -import ToolRegistry, { TOOL_ABORTED } from '@deepseek-ai/dsh-tools' +import ToolRegistry, { TOOL_ABORTED, TOOL_ABORTED_BEFORE_DISPATCH } from '@deepseek-ai/dsh-tools' +import LocalTaskService from '@deepseek-ai/dsh-tasks-local' +import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks' +import AgentRegistry from '@deepseek-ai/dsh-agent' +import type { Agent } from '@deepseek-ai/dsh-agent' +import { SessionId } from '@deepseek-ai/dsh-session' import { BashExecutor } from '@deepseek-ai/dsh-bash' import type { BashExecRequest, BashExecSpec, BashProcess, BashRunResult } from '@deepseek-ai/dsh-bash' 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' const testToolSignal = new AbortController().signal /** * A scriptable fake executor: `resolve()` mirrors the real defaulting, `run()` - * returns the armed script, `start()` throws — the pwsh tool must NEVER create - * a background task. + * returns the armed foreground script, `start()` returns the armed background + * handle. */ class FakeBash extends BashExecutor { requests: BashExecRequest[] = [] specs: BashExecSpec[] = [] startCalls = 0 handler: (spec: BashExecSpec) => BashRunResult = () => runResult('') + backgroundHandler: (spec: BashExecSpec) => BashProcess = () => fakeProcess('bg-ok\n') override resolve(request: BashExecRequest): BashExecSpec { this.requests.push(request) @@ -53,9 +64,10 @@ class FakeBash extends BashExecutor { return this.handler(spec) } - override start(): BashProcess { + override start(spec: BashExecSpec): BashProcess { this.startCalls++ - throw new Error('the pwsh tool must never start a background task') + this.specs.push(spec) + return this.backgroundHandler(spec) } } @@ -73,33 +85,95 @@ function runResult(stdout: string, overrides?: Partial): BashRunR } } -async function setup(config: Partial = {}) { +/** A settled successful background handle; overrides script failure shapes. */ +function fakeProcess(delta = 'bg-ok\n'): BashProcess { + let consumed = false + return { + status: 'completed', + exitCode: 0, + signal: null, + done: Promise.resolve(), + readOutput: () => { + if (consumed) return { delta: '', lossy: false } + consumed = true + return { delta, lossy: false } + }, + kill: () => false, + } +} + +/** A running background handle whose kill() settles it as killed (like a real task_kill). */ +function killableProcess(): BashProcess { + let resolveDone: () => void = () => {} + const done = new Promise((resolve) => { resolveDone = resolve }) + const proc: BashProcess = { + status: 'running', + exitCode: null, + signal: null, + done, + readOutput: () => ({ delta: '', lossy: false }), + kill: () => { + if (proc.status !== 'running') return false + proc.status = 'killed' + proc.signal = 'SIGTERM' + resolveDone() + return true + }, + } + return proc +} + +async function setup(toolConfig: Partial = {}, dshHome?: string) { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) + await ctx.plugin(AgentRegistry) + await ctx.plugin(BashEnvPlugin, dshHome === undefined ? {} : { dshHome }) await ctx.plugin(FakeBash) - await ctx.plugin(ToolPwsh, config) + await ctx.plugin(ToolPwsh, toolConfig) const bash = ctx.bash as FakeBash return { ctx, bash } } -/** A stand-in agent whose session header carries the given cwd and id. */ -const agent = (cwd?: string, id = 'session-1') => ({ session: { header: { id, ...cwd !== undefined ? { cwd } : {} } } }) +/** Full harness: the generic task runtime + its control surface, then the pwsh tool. */ +async function setupWithTasks(toolConfig: Partial = {}, dshHome?: string) { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(AgentRegistry) + await ctx.plugin(LocalTaskService) + await ctx.plugin(ToolTasks) + await ctx.plugin(BashEnvPlugin, dshHome === undefined ? {} : { dshHome }) + await ctx.plugin(FakeBash) + await ctx.plugin(ToolPwsh, toolConfig) + const bash = ctx.bash as FakeBash + return { ctx, bash } +} + +/** + * Build a fake {@link Agent} with the shared agent/session identity, give it a + * dedicated lifecycle fiber for `Agent.ctx`, and register it in `ctx.agents`. + */ +function registerFakeAgent(ctx: Context, sessionId: string): Agent { + const scopeFiber = ctx.plugin(() => {}) + const id = SessionId(sessionId) + const agent = { + id, + ctx: scopeFiber.ctx, + session: { id, header: { version: 0, id, createdAt: 0 } }, + } as unknown as Agent + ctx.agents.register(agent) + return agent +} let callCounter = 0 -function call( - ctx: Context, - name: string, - args: unknown, - options: { agent?: object; signal?: AbortSignal } = {}, -) { +function call(ctx: Context, name: string, args: unknown, agent?: Agent) { return ctx.tools.execute({ signal: testToolSignal, callId: CallId(`call-${++callCounter}`), name, arguments: args, - ...options.agent ? { agent: options.agent as never } : {}, - ...options.signal ? { signal: options.signal } : {}, + ...agent ? { agent } : {}, }) } @@ -107,6 +181,23 @@ function text(result: { content: { type: string; text?: string }[] }): string { return result.content.filter(b => b.type === 'text').map(b => b.text).join('') } +async function callUntilText( + ctx: Context, + name: string, + args: unknown, + expected: string, + timeoutMs = 5_000, +): Promise>> { + const deadline = Date.now() + timeoutMs + let last: Awaited> | undefined + while (Date.now() < deadline) { + last = await call(ctx, name, args) + if (text(last).includes(expected)) return last + await new Promise(resolve => setTimeout(resolve, 20)) + } + throw new Error(`tool output did not include ${JSON.stringify(expected)}; last text ${JSON.stringify(last === undefined ? '' : text(last))}`) +} + describe('registration', () => { it('registers the pwsh tool with its prompt section and schema', async () => { const { ctx } = await setup() @@ -118,10 +209,12 @@ describe('registration', () => { description: { type: 'string' }, timeoutMs: { type: 'number' }, workdir: { type: 'string' }, + run_in_background: { type: 'boolean' }, }) expect(schema?.parameters.required).toEqual(['command', 'description']) const prompt = renderPrompt(await ctx.systemPrompt.assemble()) - expect(prompt).toContain('Check the [exit code: N] marker on every pwsh result') + expect(prompt).toContain('Non-zero exits are reported as `[exit code: N]` markers') + expect(prompt).toContain('without a signal marker') }) it('stays pending until ctx.bash exists (inject)', async () => { @@ -136,6 +229,7 @@ describe('registration', () => { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) + await ctx.plugin(BashEnvPlugin) await ctx.plugin(FakeBash) const fiber = await ctx.plugin(ToolPwsh) expect(ctx.tools.schemas()).toHaveLength(1) @@ -157,13 +251,15 @@ describe('argument validation', () => { describe('execution through the bash seam', () => { it('forwards command, session cwd, timeout, and managed DSH_* environment', async () => { const dshHome = mkdtempSync(join(tmpdir(), 'dsh-tool-pwsh-home-')) - const { ctx, bash } = await setup({ dshHome }) + const { ctx, bash } = await setup({}, dshHome) bash.handler = () => runResult('hi\n') + const agent = registerFakeAgent(ctx, 'session-1') + Object.assign(agent.session.header, { cwd: '/sessions/s1' }) const result = await call(ctx, 'pwsh', { command: 'Write-Output hi', description: 'say hi', timeoutMs: 1234, - }, { agent: agent('/sessions/s1') }) + }, agent) expect(result.isError).toBe(false) const request = bash.requests[0] expect(request?.command).toBe('Write-Output hi') @@ -180,9 +276,11 @@ describe('execution through the bash seam', () => { it('resolves a relative workdir against the session cwd, absolute ones verbatim', async () => { const { ctx, bash } = await setup() bash.handler = () => runResult('ok\n') - await call(ctx, 'pwsh', { command: 'pwd', description: 'cwd', workdir: 'sub/dir' }, { agent: agent('/sessions/s1') }) + const agent = registerFakeAgent(ctx, 'session-cwd') + Object.assign(agent.session.header, { cwd: '/sessions/s1' }) + await call(ctx, 'pwsh', { command: 'pwd', description: 'cwd', workdir: 'sub/dir' }, agent) expect(bash.requests[0]?.workdir).toBe(resolvePath('/sessions/s1', 'sub/dir')) - await call(ctx, 'pwsh', { command: 'pwd', description: 'cwd', workdir: resolvePath('/abs/path') }, { agent: agent('/sessions/s1') }) + await call(ctx, 'pwsh', { command: 'pwd', description: 'cwd', workdir: resolvePath('/abs/path') }, agent) expect(bash.requests[1]?.workdir).toBe(resolvePath('/abs/path')) }) @@ -202,7 +300,12 @@ describe('execution through the bash seam', () => { const { ctx, bash } = await setup() const controller = new AbortController() bash.handler = () => runResult('ok\n') - await call(ctx, 'pwsh', { command: 'Write-Output ok', description: 'ok' }, { signal: controller.signal }) + await ctx.tools.execute({ + signal: controller.signal, + callId: CallId('call-signal'), + name: 'pwsh', + arguments: { command: 'Write-Output ok', description: 'ok' }, + }) expect(bash.requests[0]?.signal).toBe(controller.signal) }) @@ -229,19 +332,60 @@ describe('execution through the bash seam', () => { expect(text(result)).toBe('out\n[stderr]\nerr\n[exit code: 2]') }) - it('renders the truncation tail, the exit marker, and a timeout marker from the executor streams', async () => { + it('renders a clean exit without a marker and an empty body as (no output)', async () => { + const { ctx, bash } = await setup() + bash.handler = () => runResult('hi\n') + const clean = await call(ctx, 'pwsh', { command: 'Write-Output hi', description: 'say hi' }) + expect(text(clean)).toBe('hi\n') + + bash.handler = () => runResult('') + const empty = await call(ctx, 'pwsh', { command: 'Write-Output -NoNewline ""', description: 'nothing' }) + expect(text(empty)).toBe('(no output)') + }) + + it('renders stderr-only output without a stdout prefix', async () => { + const { ctx, bash } = await setup() + bash.handler = () => runResult('', { + stderr: { text: 'err\n', truncated: false }, + exitCode: 1, + }) + const result = await call(ctx, 'pwsh', { command: 'fail', description: 'fail' }) + expect(text(result)).toBe('[stderr]\nerr\n[exit code: 1]') + }) + + it('inserts the separating newline before the stderr section when stdout lacks one', async () => { + const { ctx, bash } = await setup() + bash.handler = () => runResult('out', { + stderr: { text: 'err\n', truncated: false }, + exitCode: 1, + }) + const result = await call(ctx, 'pwsh', { command: 'fail', description: 'fail' }) + expect(text(result)).toBe('out\n[stderr]\nerr\n[exit code: 1]') + }) + + it('renders the truncation notice with the spill path, then markers', async () => { const { ctx, bash } = await setup() bash.handler = () => runResult('tail', { stdout: { text: 'tail', truncated: true, spillPath: '/spill/out.log' }, stderr: { text: '', truncated: false }, }) const result = await call(ctx, 'pwsh', { command: 'noisy', description: 'noise' }) - expect(text(result)).toBe('tail\n[exit code: 0]') + expect(text(result)).toBe('tail\n[output truncated; full output: /spill/out.log]') bash.handler = () => runResult('', { timedOut: true, exitCode: null, signal: 'SIGTERM', timeoutMs: 500 }) const timedOut = await call(ctx, 'pwsh', { command: 'slow', description: 'slow' }) // A timeout kill carries both facts, mirroring the bash tool's markers. - expect(text(timedOut)).toBe('[timed out after 500ms]\n[killed by signal: SIGTERM]') + expect(text(timedOut)).toBe('(no output)\n[timed out after 500ms]\n[killed by signal: SIGTERM]') + }) + + it('renders the truncation notice with (unavailable) when no spill path exists', async () => { + const { ctx, bash } = await setup() + bash.handler = () => runResult('tail', { + stdout: { text: 'tail', truncated: true }, + stderr: { text: '', truncated: false }, + }) + const result = await call(ctx, 'pwsh', { command: 'noisy', description: 'noise' }) + expect(text(result)).toBe('tail\n[output truncated; full output: (unavailable)]') }) it('translates an aborted run into the TOOL_ABORTED HarnessError', async () => { @@ -251,15 +395,124 @@ describe('execution through the bash seam', () => { expect(result.isError).toBe(true) expect(result.error).toMatchObject({ info: { name: 'AbortError', code: TOOL_ABORTED } }) }) +}) - it('never starts a background task', async () => { - const { ctx, bash } = await setup() - bash.handler = () => runResult('ok\n') - await call(ctx, 'pwsh', { command: 'Write-Output ok', description: 'ok' }) - bash.handler = () => runResult('', { exitCode: 1 }) - await call(ctx, 'pwsh', { command: 'missing', description: 'missing' }) +describe('background execution through the task runtime', () => { + it('run_in_background acks with the task id, readable through the REAL task_output tool', async () => { + const { ctx } = await setupWithTasks() + const started = await call(ctx, 'pwsh', { command: 'Write-Output bg-ok', description: 'test command', run_in_background: true }) + expect(started.isError).toBe(false) + if (started.isError) throw new Error('expected background pwsh success') + expect(started.value).toEqual({ kind: 'background', taskId: 'pwsh-1' }) + expect(text(started)).toBe('started background task pwsh-1') + + const read = await callUntilText(ctx, 'task_output', { task_id: 'pwsh-1' }, 'bg-ok') + expect(text(read)).toContain('bg-ok') + // A later read reports the terminal outcome in the generic status line. + const final = await callUntilText(ctx, 'task_output', { task_id: 'pwsh-1' }, '[status: completed, exit code: 0]') + expect(final.isError).toBe(false) + }) + + it('a running background task is killable through the REAL task_kill tool', async () => { + const { ctx, bash } = await setupWithTasks() + bash.backgroundHandler = () => killableProcess() + await call(ctx, 'pwsh', { command: 'Start-Sleep -Seconds 60', description: 'test command', run_in_background: true }) + + const killed = await call(ctx, 'task_kill', { task_id: 'pwsh-1' }) + expect(text(killed)).toBe('requested cancellation of task pwsh-1') + // The cancel reached the process handle; the task settles as killed with + // the signal detail mapped by processOutcome. + const final = await call(ctx, 'task_output', { task_id: 'pwsh-1', wait: true }) + expect(text(final)).toContain('[status: killed, signal: SIGTERM]') + }) + + it('a background task started by an agent is registered with that agent as owner', async () => { + const { ctx } = await setupWithTasks() + const agent = registerFakeAgent(ctx, 'sess-owner') + const started = await call(ctx, 'pwsh', { command: 'Start-Sleep -Seconds 60', description: 'test command', run_in_background: true }, agent) + expect(text(started)).toBe('started background task pwsh-1') + + const anon = await call(ctx, 'task_output', { task_id: 'pwsh-1' }) + expect(anon.isError).toBe(true) + expect(text(anon)).toMatch(/belongs to another session/) + + const killed = await call(ctx, 'task_kill', { task_id: 'pwsh-1' }, agent) + expect(killed.isError).toBe(false) + await call(ctx, 'task_output', { task_id: 'pwsh-1', wait: true }, agent) // await settlement — no orphan + }) + + it('fails loud when the task runtime is not loaded', async () => { + const { ctx } = await setup() // no LocalTaskService / ToolTasks + const result = await call(ctx, 'pwsh', { command: 'Start-Sleep -Seconds 60', description: 'test command', run_in_background: true }) + expect(result.isError).toBe(true) + expect(text(result)).toContain('background tasks unavailable: load @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks') + }) + + it('a pre-aborted call is skipped before the process starts', async () => { + const { ctx, bash } = await setupWithTasks() + const controller = new AbortController() + controller.abort() + const result = await ctx.tools.execute({ + callId: CallId('call-pre-aborted'), + name: 'pwsh', + arguments: { command: 'Start-Sleep -Seconds 60', description: 'test command', run_in_background: true }, + signal: controller.signal, + }) + expect(result.isError).toBe(true) + expect(result.error).toEqual({ + message: 'tool call aborted before dispatch', + info: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH }, + }) expect(bash.startCalls).toBe(0) }) + + it('never spawns the process when tasks.start preflight throws (no orphan, by construction)', async () => { + // With no control surface, task preflight fails before the executor can spawn. + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(LocalTaskService) + await ctx.plugin(BashEnvPlugin) + await ctx.plugin(FakeBash) + await ctx.plugin(ToolPwsh) + const bash = ctx.bash as FakeBash + + const result = await call(ctx, 'pwsh', { command: 'Start-Sleep -Seconds 60', description: 'test command', run_in_background: true }) + expect(result.isError).toBe(true) + expect(text(result)).toContain('no control surface is attached') + // Declare-then-execute: the failed preflight means no process ever ran. + expect(bash.startCalls).toBe(0) + }) + + it('enableRunInBackground: false removes the parameter and flips the description', async () => { + const { ctx } = await setup({ enableRunInBackground: false }) + const schema = ctx.tools.schemas().find(s => s.name === 'pwsh')! + expect(Object.keys(schema.parameters.properties as Record)) + .toEqual(['command', 'description', 'timeoutMs', 'workdir']) + expect(schema.description).toContain('Background execution is not available') + expect(schema.description).not.toContain('run_in_background') + + // Schema omission is advertising; execution must also enforce the opt-out. + const forced = await call(ctx, 'pwsh', { command: 'Write-Output hi', description: 'test command', run_in_background: true }) + expect(forced.isError).toBe(true) + expect(text(forced)).toContain('run_in_background is disabled for this deployment') + const foreground = await call(ctx, 'pwsh', { command: 'Write-Output hi', description: 'test command' }) + expect(foreground.isError).toBe(false) + }) + + it('applies the built-in background default when apply() receives a bare config', async () => { + // Bypasses the schemastery defaults on purpose: apply() must stand on its + // own `?? true` fallback when embedded programmatically without the schema. + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(BashEnvPlugin) + await ctx.plugin(FakeBash) + ToolPwsh.apply(ctx, {}) + const schema = ctx.tools.schemas()[0]! + expect(schema.parameters.properties).toHaveProperty('run_in_background') + expect(schema.description).toContain('task_output') + }) }) describe('UI presentation', () => { @@ -267,11 +520,11 @@ describe('UI presentation', () => { 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, { agent: agent('/w') }) + 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[exit code: 0]\n```' }], + content: [{ type: 'text', text: '```console\nhi\n```' }], }) }) @@ -294,3 +547,75 @@ describe('UI presentation', () => { expect(definition?.presentResult?.(args, image as never)).toBeUndefined() }) }) + +describe('renderPwshProcessRead', () => { + const base: BashProcessRead = { delta: 'out\n', lossy: false } + + it('returns the delta verbatim for a lossless read', () => { + expect(renderPwshProcessRead(base)).toBe('out\n') + expect(renderPwshProcessRead({ delta: '', lossy: false })).toBe('') + }) + + it('appends the loss notice with the available spill paths', () => { + expect(renderPwshProcessRead({ ...base, lossy: true, stdoutSpillPath: 'C:\\spill\\out.log' })) + .toBe('out\n[some output was dropped from memory; full output: C:\\spill\\out.log]') + expect(renderPwshProcessRead({ + ...base, + lossy: true, + stdoutSpillPath: 'C:\\spill\\out.log', + stderrSpillPath: 'C:\\spill\\err.log', + })) + .toBe('out\n[some output was dropped from memory; full output: C:\\spill\\out.log, C:\\spill\\err.log]') + }) + + it('reports (unavailable) when a lossy read has no safe spill path', () => { + expect(renderPwshProcessRead({ ...base, lossy: true })) + .toBe('out\n[some output was dropped from memory; full output: (unavailable)]') + }) + + it('an empty lossy delta is the notice alone', () => { + expect(renderPwshProcessRead({ delta: '', lossy: true, stderrSpillPath: 'C:\\spill\\err.log' })) + .toBe('[some output was dropped from memory; full output: C:\\spill\\err.log]') + }) + + it('inserts the separating newline only when the delta lacks one', () => { + expect(renderPwshProcessRead({ delta: 'tail', lossy: true })) + .toBe('tail\n[some output was dropped from memory; full output: (unavailable)]') + expect(renderPwshProcessRead({ delta: 'tail\n', lossy: true })) + .toBe('tail\n[some output was dropped from memory; full output: (unavailable)]') + }) +}) + +describe('processOutcome', () => { + function settled(over: Partial): BashProcess { + return { + status: 'completed', + exitCode: 0, + signal: null, + done: Promise.resolve(), + readOutput: () => ({ delta: '', lossy: false }), + kill: () => false, + ...over, + } + } + + it('maps a signal-killed process to killed with the signal detail', () => { + expect(processOutcome(settled({ status: 'killed', signal: 'SIGTERM' }))) + .toEqual({ status: 'killed', detail: 'signal: SIGTERM' }) + }) + + it('maps a killed process without a recorded signal (kill raced exit / spawn failure)', () => { + expect(processOutcome(settled({ status: 'killed', exitCode: null }))) + .toEqual({ status: 'killed', detail: 'killed before exit' }) + }) + + it('maps a completed process to its exit code', () => { + expect(processOutcome(settled({ exitCode: 3 }))) + .toEqual({ status: 'completed', detail: 'exit code: 3' }) + }) + + it('defensively reads a null exit code as 0 (handle shapes from other executors)', () => { + expect(processOutcome(settled({ exitCode: null }))) + .toEqual({ status: 'completed', detail: 'exit code: 0' }) + }) +}) diff --git a/packages/bash/tool-pwsh/tsconfig.json b/packages/bash/tool-pwsh/tsconfig.json index 2811462193..61b2c69448 100644 --- a/packages/bash/tool-pwsh/tsconfig.json +++ b/packages/bash/tool-pwsh/tsconfig.json @@ -26,14 +26,14 @@ { "path": "../../core/agent" }, - { - "path": "../../session-persistence/session-persistence" - }, { "path": "../../bash/bash" }, { - "path": "../../util/paths" + "path": "../../bash/bash-env" + }, + { + "path": "../../tasks/tasks" }, { "path": "../../core/system-prompt"