feat(pwsh): add the pwsh-local executor and the pwsh tool
Windows-native execution foundation: PwshLocalExecutor implements the bash executor seam over ctx.subprocess (pwsh -NoLogo -NoProfile -NonInteractive -Command, one argv element, no quoting layer; resolvePwshPath probes PowerShell 7 / PATH / Windows PowerShell 5.1 as a pure function), and tool-pwsh is the minimal PowerShell-dialect model-facing tool over ctx.bash (foreground only, managed DSH_* env, timeout/signal/exit markers, terminal and generic presenters). Both packages carry full suites (real pwsh, self-skipping without it) at per-file 100% coverage; vitest's Windows exclusion narrows from packages/bash/* to the bash-requiring packages so the pwsh suites run natively on Windows too. The CLI gains the workspace deps and tsconfig projects without mounting either plugin; the Windows-default roadmap is recorded as a proposed Agent Note.
This commit is contained in:
6
packages/bash/tool-pwsh/README.i18n.yaml
Normal file
6
packages/bash/tool-pwsh/README.i18n.yaml
Normal file
@@ -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 packages/bash/tool-pwsh/README.md
|
||||
README.md: 4f1d62dbf49fef678e3285776c466286535d66da
|
||||
README.zh.md: bbeece3c648d8b1903eed1a66d2e14774c7ace8c
|
||||
107
packages/bash/tool-pwsh/README.md
Normal file
107
packages/bash/tool-pwsh/README.md
Normal file
@@ -0,0 +1,107 @@
|
||||
# @deepseek-ai/dsh-tool-pwsh
|
||||
|
||||
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.
|
||||
|
||||
Requires a loaded executor implementation; the plugin stays pending until `ctx.bash` exists (`inject: ['tools', 'bash', 'systemPrompt']`).
|
||||
|
||||
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 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.
|
||||
|
||||
## Tools
|
||||
|
||||
### `pwsh`
|
||||
|
||||
| Arg | Type | Notes |
|
||||
|---|---|---|
|
||||
| `command` | string (required) | Run via `pwsh -Command`. No state persists between calls — use `workdir`, not `cd`. |
|
||||
| `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. |
|
||||
|
||||
`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.
|
||||
|
||||
Result text contains stdout, an optional `[stderr]` section, then applicable timeout, signal, and exit-code markers: `[timed out after <timeoutMs>ms]`, `[killed by signal: <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`.
|
||||
|
||||
The canonical success is `{ kind: 'foreground', ...BashRunResult }` for a completed foreground process. Programmatic consumers use the typed fields without parsing the rendered text.
|
||||
|
||||
## 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.
|
||||
|
||||
## Model Experience
|
||||
|
||||
### System prompt
|
||||
|
||||
#### What the model sees
|
||||
|
||||
Every request in this plugin's registration scope contains the pwsh guidance below. Scoped tool restrictions can hide the schema without removing this independently registered section.
|
||||
|
||||
##### Pwsh guidance
|
||||
|
||||
```markdown
|
||||
Check the [exit code: N] marker on every pwsh result; investigate failures before moving on.
|
||||
```
|
||||
|
||||
#### Token effect
|
||||
|
||||
Small fixed input cost per request while the plugin is active.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
Prefix-stable while the registration scope and prompt text are unchanged. Plugin activation or disposal may invalidate reuse from this prompt section.
|
||||
|
||||
### Tool schemas
|
||||
|
||||
#### What the model sees
|
||||
|
||||
The model sees the generated [`pwsh` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-pwsh). Agent-scoped tool restrictions can remove the definition for that agent.
|
||||
|
||||
#### Token effect
|
||||
|
||||
Fixed schema cost on every request where the tool is visible.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
Prefix-stable while visibility and the tool definition are unchanged. A restriction or config change may invalidate reuse from the first changed token.
|
||||
|
||||
### Foreground result
|
||||
|
||||
#### 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 <timeoutMs>ms]`, `[killed by signal: <signal>]`, and `[exit code: <exitCode>]`.
|
||||
|
||||
#### Token effect
|
||||
|
||||
Zero result tokens before a call. Output is bounded per stream, while each emitted line remains in history until compaction.
|
||||
|
||||
#### 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: <message>`. 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 <value>`, and `tool call aborted`.
|
||||
|
||||
#### Token effect
|
||||
|
||||
Only the failing call adds these retained tokens; an aborted call adds no command output.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries.
|
||||
|
||||
## 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.
|
||||
- **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.
|
||||
107
packages/bash/tool-pwsh/README.zh.md
Normal file
107
packages/bash/tool-pwsh/README.zh.md
Normal file
@@ -0,0 +1,107 @@
|
||||
# @deepseek-ai/dsh-tool-pwsh
|
||||
|
||||
[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` 存在之前保持 pending(`inject: ['tools', 'bash', 'systemPrompt']`)。
|
||||
|
||||
包根只暴露 Cordis 插件契约(`name`、`inject`、`Config`、`apply`)以及纯函数 `renderPwshOutput` 及其结果类型;执行与呈现是同一包测试覆盖的实现细节。
|
||||
|
||||
该插件还贡献 `tool:pwsh` 提示词段(order 105):检查每个结果上的 `[exit code: N]` 标记,并在继续前调查失败。
|
||||
|
||||
## 工具
|
||||
|
||||
### `pwsh`
|
||||
|
||||
| 参数 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `command` | string(必填) | 通过 `pwsh -Command` 运行。调用之间不保留状态——用 `workdir`,不要用 `cd`。 |
|
||||
| `description` | string(必填) | 命令的一句话主动语态摘要(5-10 词),仅用于 UI/日志展示——不影响执行。 |
|
||||
| `timeoutMs` | number | 毫秒级超时覆盖。执行器应用其配置的默认值与上限。 |
|
||||
| `workdir` | string | 本次调用的工作目录。默认取调用 agent(智能体)的会话 cwd(`session.header.cwd`),使每个会话在自己的工作区运行;相对 `workdir` 基于同一身份解析。 |
|
||||
|
||||
`command`、`workdir` 与 `timeoutMs` 在执行前经 `ctx.bash.resolve()` 按执行器配置默认值解析。workdir 默认值在工具层取自调用 agent 的 `session.header.cwd`,先于 `resolve()` 应用——每个会话的 cwd 必须来自 `exec.agent`,因为 N 个会话共享一个执行器;只有没有会话 cwd 时,执行器才回退到自己的配置 / `process.cwd()`。
|
||||
|
||||
### 受管 shell 环境
|
||||
|
||||
每次调用都会收到一份新收集的受信 `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` 永不被修改。
|
||||
|
||||
结果文本包含 stdout、可选的 `[stderr]` 分段,以及适用的超时、信号与退出码标记:`[timed out after <timeoutMs>ms]`、`[killed by signal: <signal>]` 与 `[exit code: N]`,仅在累积文本缺少换行时才补一个分隔换行。非零退出仍是模型自行解读的结果,而不是 `isError`。只有基础设施失败——spawn 错误与中止(`tool call aborted`)——才产生 `isError`。
|
||||
|
||||
规范成功值为已完成前台进程的 `{ kind: 'foreground', ...BashRunResult }`。程序化消费方使用类型化字段,而不解析渲染文本。
|
||||
|
||||
## UI 呈现
|
||||
|
||||
工具拥有自己的 `presentCall`/`presentResult` 渲染意图。调用是携带命令、描述与可选 cwd 的 `terminal` 卡片;完成结果是 `generic` 卡片,渲染输出放在 `console` 围栏内。这些 presenter 是纯函数且可重放。
|
||||
|
||||
## 模型体验
|
||||
|
||||
### 系统提示词
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
该插件注册作用域内的每个请求都包含下方 pwsh 指导。作用域工具限制可以隐藏 schema,而不移除这个独立注册的提示词段。
|
||||
|
||||
##### Pwsh 指导
|
||||
|
||||
```markdown
|
||||
Check the [exit code: N] marker on every pwsh result; investigate failures before moving on.
|
||||
```
|
||||
|
||||
#### Token 影响
|
||||
|
||||
插件激活期间每个请求有少量固定输入成本。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
注册作用域与提示词文本不变时前缀稳定。插件激活或销毁可能使该提示词段的复用失效。
|
||||
|
||||
### 工具 schema
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
模型看到生成的 [`pwsh` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-pwsh)。agent 作用域的工具限制可以为该 agent 移除定义。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
工具可见时每个请求有固定的 schema 成本。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
可见性与工具定义不变时前缀稳定。限制或配置变更可能从第一个改变的 token 起使复用失效。
|
||||
|
||||
### 前台结果
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
渲染器输出依赖数据的 stdout 尾部,然后是可选 `[stderr]` 与 stderr 尾部。条件行恰为 `[timed out after <timeoutMs>ms]`、`[killed by signal: <signal>]` 与 `[exit code: <exitCode>]`。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
调用前零结果 token。输出按流有界,每条已发出行在压缩前保留在历史中。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
只追加;新可见内容跟在可复用请求前缀之后,不会使既有 KV-cache 条目失效。
|
||||
|
||||
### 工具错误
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
校验与基础设施失败被规范化为 `Error: <message>`。本包的稳定消息为 `invalid command: expected a non-empty string`、`invalid description: expected a non-empty string`、`invalid timeoutMs: expected a positive number, got <value>` 与 `tool call aborted`。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
只有失败的调用会增加这些保留 token;中止的调用不增加命令输出。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
只追加;新可见内容跟在可复用请求前缀之后,不会使既有 KV-cache 条目失效。
|
||||
|
||||
## 已知局限与延期工作
|
||||
|
||||
- **仅前台**——没有 `run_in_background`;长时间运行的工作必须留在执行器超时之内,或等待 bash 工具孪生。
|
||||
- **无沙箱升级**——没有 `sandbox_permissions`/`justification`;受约束的组合通过执行器拒绝,升级等待完整孪生。
|
||||
- **PowerShell 方言契约**——模型必须写 PowerShell(原生路径、`$env:` 变量),而不是 bash;没有方言翻译。
|
||||
- **Windows 默认路线图延期**——让 Windows 主机默认用 `pwsh` 而非 `bash`,以及 pwsh TUI/GUI 渲染支持,都另行规划,刻意不纳入本包。
|
||||
56
packages/bash/tool-pwsh/package.json
Normal file
56
packages/bash/tool-pwsh/package.json
Normal file
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-tool-pwsh",
|
||||
"description": "Model-facing pwsh tool over the bash executor seam",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-bash": "^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-tools": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"dependencies": {
|
||||
"schemastery": "^3.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-bash": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-paths": "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-tools": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
}
|
||||
254
packages/bash/tool-pwsh/src/index.ts
Normal file
254
packages/bash/tool-pwsh/src/index.ts
Normal file
@@ -0,0 +1,254 @@
|
||||
/**
|
||||
* Model-facing `pwsh` tool 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 — this is the
|
||||
* "works on my Windows machine" profile until the full bash-tool feature set
|
||||
* gets a PowerShell twin.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-tool-pwsh
|
||||
*/
|
||||
|
||||
import { isAbsolute, resolve as resolvePath } from 'node:path'
|
||||
import { 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 { 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'
|
||||
|
||||
export const name = 'tool-pwsh'
|
||||
export const inject = ['tools', 'bash', 'systemPrompt']
|
||||
|
||||
/** Plugin config (currently empty; kept as a schema so deployments can grow it). */
|
||||
export interface Config {
|
||||
/** DeepSeek Harness home directory exposed as `DSH_HOME`; defaults to `$DSH_HOME` or `~/.dsh`. */
|
||||
dshHome?: string
|
||||
}
|
||||
|
||||
/** Runtime configuration schema for the pwsh tool plugin. */
|
||||
export const Config: z<Config> = z.object({
|
||||
dshHome: z.string(),
|
||||
})
|
||||
|
||||
/** Parsed tool args; execute validates value constraints absent from ParameterSchemaSpec. */
|
||||
interface PwshToolArgs {
|
||||
command: string
|
||||
description: string
|
||||
timeoutMs?: number
|
||||
workdir?: string
|
||||
}
|
||||
|
||||
/** The canonical foreground result of one pwsh call (the `output.schema` value shape). */
|
||||
interface PwshForegroundResult {
|
||||
kind: 'foreground'
|
||||
exitCode: number | null
|
||||
signal: NodeJS.Signals | null
|
||||
timedOut: boolean
|
||||
aborted: boolean
|
||||
timeoutMs: number
|
||||
stdout: { text: string; truncated: boolean; spillPath?: string }
|
||||
stderr: { text: string; truncated: boolean; spillPath?: string }
|
||||
}
|
||||
|
||||
function validatePwshArgs(args: PwshToolArgs): void {
|
||||
if (args.command.trim().length === 0) {
|
||||
throw new Error('invalid command: expected a non-empty string')
|
||||
}
|
||||
if (args.description.trim().length === 0) {
|
||||
throw new Error('invalid description: expected a non-empty string')
|
||||
}
|
||||
if (args.timeoutMs !== undefined && (!Number.isFinite(args.timeoutMs) || args.timeoutMs <= 0)) {
|
||||
throw new Error(`invalid timeoutMs: expected a positive number, got ${JSON.stringify(args.timeoutMs)}`)
|
||||
}
|
||||
}
|
||||
|
||||
function pwshDescription(): string {
|
||||
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.'
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve an explicit workdir first, making a relative one session-workspace-relative;
|
||||
* otherwise use the session header cwd and leave executor defaulting as the fallback.
|
||||
*/
|
||||
function resolveWorkdir(modelWorkdir: string | undefined, exec: { agent?: Agent }): string | undefined {
|
||||
const headerCwd = exec.agent?.session.header.cwd
|
||||
if (modelWorkdir === undefined) return headerCwd
|
||||
if (headerCwd !== undefined && !isAbsolute(modelWorkdir)) {
|
||||
return resolvePath(headerCwd, modelWorkdir)
|
||||
}
|
||||
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.
|
||||
*/
|
||||
function canonicalPwshResult(result: BashRunResult): PwshForegroundResult {
|
||||
const output = (stream: BashRunResult['stdout']) => ({
|
||||
text: stream.text,
|
||||
truncated: stream.truncated,
|
||||
...stream.spillPath !== undefined ? { spillPath: stream.spillPath } : {},
|
||||
})
|
||||
return {
|
||||
kind: 'foreground',
|
||||
exitCode: result.exitCode,
|
||||
signal: result.signal,
|
||||
timedOut: result.timedOut,
|
||||
aborted: result.aborted,
|
||||
timeoutMs: result.timeoutMs,
|
||||
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<string, string> = {
|
||||
[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
|
||||
}
|
||||
|
||||
export function apply(ctx: Context, config: Config = {}): void {
|
||||
const dshHome = resolveDshHome(config.dshHome)
|
||||
|
||||
ctx.systemPrompt.section({
|
||||
name: 'tool:pwsh',
|
||||
order: 105,
|
||||
text: 'Check the [exit code: N] marker on every pwsh result; investigate failures before moving on.',
|
||||
})
|
||||
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'pwsh',
|
||||
description: pwshDescription(),
|
||||
parameters: {
|
||||
command: { type: 'string', required: true, description: 'The PowerShell command to execute.' },
|
||||
description: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
description: 'Clear, concise description of what this command does in active voice, '
|
||||
+ '5-10 words (shown in the UI). Examples: "ls" → "List files in current directory"; '
|
||||
+ '"git status" → "Show working tree status"; "Get-Process" → "List running processes".',
|
||||
},
|
||||
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.' },
|
||||
},
|
||||
output: {
|
||||
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: {
|
||||
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' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
render: (_args, value) => [{
|
||||
type: 'text',
|
||||
text: renderPwshOutput(value),
|
||||
}],
|
||||
},
|
||||
async execute(args: PwshToolArgs, exec) {
|
||||
validatePwshArgs(args)
|
||||
const workdir = resolveWorkdir(args.workdir, exec)
|
||||
const result = await ctx.bash.run(ctx.bash.resolve({
|
||||
command: args.command,
|
||||
...workdir !== undefined ? { workdir } : {},
|
||||
...args.timeoutMs !== undefined ? { timeoutMs: args.timeoutMs } : {},
|
||||
dshEnv: collectDshEnv(exec, dshHome),
|
||||
signal: exec.signal,
|
||||
}))
|
||||
if (result.aborted) {
|
||||
const error = new HarnessError('tool call aborted', TOOL_ABORTED)
|
||||
error.name = 'AbortError'
|
||||
throw error
|
||||
}
|
||||
return canonicalPwshResult(result)
|
||||
},
|
||||
presentCall: (args: PwshToolArgs): TerminalCallView => ({
|
||||
card: 'terminal',
|
||||
title: args.command,
|
||||
description: args.description,
|
||||
...args.workdir !== undefined ? { cwd: args.workdir } : {},
|
||||
}),
|
||||
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\`\`\`` }] }
|
||||
},
|
||||
}))
|
||||
}
|
||||
30
packages/bash/tool-pwsh/src/invariant.ts
Normal file
30
packages/bash/tool-pwsh/src/invariant.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-tool-pwsh`.
|
||||
* @module @deepseek-ai/dsh-tool-pwsh/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-tool-pwsh'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'tool-pwsh-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: this package exposes no independent event sequence or mutable data relation
|
||||
* beyond contracts enforced at its owning seam.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
119
packages/bash/tool-pwsh/tests/integration.spec.ts
Normal file
119
packages/bash/tool-pwsh/tests/integration.spec.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
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 LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local'
|
||||
import { PwshLocalExecutor } from '@deepseek-ai/dsh-pwsh-local'
|
||||
import * as ToolPwsh from '@deepseek-ai/dsh-tool-pwsh'
|
||||
|
||||
const testToolSignal = new AbortController().signal
|
||||
|
||||
const hasPwsh = spawnSync('pwsh', ['-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')
|
||||
|
||||
let dir: string
|
||||
let ctx: Context
|
||||
|
||||
let callCounter = 0
|
||||
function call(name: string, args: unknown, agentObj?: object, signal?: AbortSignal) {
|
||||
return ctx.tools.execute({
|
||||
signal: signal ?? testToolSignal,
|
||||
callId: CallId(`it-${++callCounter}`),
|
||||
name,
|
||||
arguments: args,
|
||||
...agentObj ? { agent: agentObj as never } : {},
|
||||
})
|
||||
}
|
||||
|
||||
function text(result: { content: { type: string; text?: string }[] }): string {
|
||||
return result.content.filter(b => b.type === 'text').map(b => b.text).join('')
|
||||
}
|
||||
|
||||
describe.skipIf(!hasPwsh)('pwsh tool over the real pwsh executor', () => {
|
||||
beforeEach(async () => {
|
||||
dir = await mkdtemp(join(tmpdir(), 'dsh-tool-pwsh-'))
|
||||
await writeFile(join(dir, 'greeting.txt'), 'hello pwsh\n')
|
||||
|
||||
ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(LocalSubprocessService)
|
||||
await ctx.plugin(PwshLocalExecutor, { timeoutMs: 20_000, graceMs: 200 })
|
||||
await ctx.plugin(ToolPwsh)
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
const agent = () => ({ session: { header: { id: 'session-int', cwd: dir } } })
|
||||
|
||||
it('runs a command and returns stdout with the exit marker', 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]')
|
||||
})
|
||||
|
||||
it('returns stderr in a marked section and a nonzero exit as a marker, not an error', async () => {
|
||||
const result = await call('pwsh', {
|
||||
command: '[Console]::Error.WriteLine("boom"); exit 3',
|
||||
description: 'fail loudly',
|
||||
}, agent())
|
||||
expect(result.isError).toBe(false)
|
||||
expect(lf(text(result))).toBe('[stderr]\nboom\n[exit code: 3]')
|
||||
})
|
||||
|
||||
it('resolves relative paths in the session workspace', async () => {
|
||||
const result = await call('pwsh', {
|
||||
command: 'Get-Content greeting.txt',
|
||||
description: 'read greeting',
|
||||
}, agent())
|
||||
expect(result.isError).toBe(false)
|
||||
expect(lf(text(result))).toBe('hello pwsh\n[exit code: 0]')
|
||||
})
|
||||
|
||||
it('a per-call timeout kills the run and reports the timed-out marker, not an error', async () => {
|
||||
const result = await call('pwsh', {
|
||||
command: 'Start-Sleep -Seconds 60',
|
||||
description: 'sleep forever',
|
||||
timeoutMs: 100,
|
||||
}, agent())
|
||||
expect(result.isError).toBe(false)
|
||||
if (result.isError) throw new Error('expected a timed-out foreground result')
|
||||
expect(result.value).toMatchObject({ kind: 'foreground', timedOut: true, aborted: false })
|
||||
// Windows reports the forced termination as exit 1 without a signal;
|
||||
// POSIX reports SIGTERM — the timeout marker is the stable fact.
|
||||
expect(lf(text(result))).toContain('[timed out after 100ms]')
|
||||
})
|
||||
|
||||
it('an upstream cancellation aborts the run', async () => {
|
||||
const controller = new AbortController()
|
||||
const pending = call('pwsh', {
|
||||
command: 'Start-Sleep -Seconds 60',
|
||||
description: 'sleep forever',
|
||||
}, agent(), controller.signal)
|
||||
setTimeout(() => { controller.abort() }, 50)
|
||||
const result = await pending
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.error).toMatchObject({ info: { name: 'AbortError', code: TOOL_ABORTED } })
|
||||
})
|
||||
})
|
||||
296
packages/bash/tool-pwsh/tests/tools.spec.ts
Normal file
296
packages/bash/tool-pwsh/tests/tools.spec.ts
Normal file
@@ -0,0 +1,296 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { mkdtempSync } from 'node:fs'
|
||||
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 { 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'
|
||||
|
||||
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.
|
||||
*/
|
||||
class FakeBash extends BashExecutor {
|
||||
requests: BashExecRequest[] = []
|
||||
specs: BashExecSpec[] = []
|
||||
startCalls = 0
|
||||
handler: (spec: BashExecSpec) => BashRunResult = () => runResult('')
|
||||
|
||||
override resolve(request: BashExecRequest): BashExecSpec {
|
||||
this.requests.push(request)
|
||||
return {
|
||||
command: request.command,
|
||||
workdir: request.workdir ?? process.cwd(),
|
||||
timeoutMs: request.timeoutMs ?? 60_000,
|
||||
stdoutMaxBytes: request.stdoutMaxBytes ?? 64_000,
|
||||
...request.signal ? { signal: request.signal } : {},
|
||||
...request.stdin !== undefined ? { stdin: request.stdin } : {},
|
||||
...request.env !== undefined ? { env: request.env } : {},
|
||||
...request.dshEnv !== undefined ? { dshEnv: request.dshEnv } : {},
|
||||
sandboxPolicy: request.sandboxPolicy,
|
||||
}
|
||||
}
|
||||
|
||||
override async run(spec: BashExecSpec): Promise<BashRunResult> {
|
||||
this.specs.push(spec)
|
||||
return this.handler(spec)
|
||||
}
|
||||
|
||||
override start(): BashProcess {
|
||||
this.startCalls++
|
||||
throw new Error('the pwsh tool must never start a background task')
|
||||
}
|
||||
}
|
||||
|
||||
/** A successful run result over the given stdout; overrides script the failure shapes. */
|
||||
function runResult(stdout: string, overrides?: Partial<BashRunResult>): BashRunResult {
|
||||
return {
|
||||
exitCode: 0,
|
||||
signal: null,
|
||||
timedOut: false,
|
||||
aborted: false,
|
||||
timeoutMs: 60_000,
|
||||
stdout: { text: stdout, truncated: false },
|
||||
stderr: { text: '', truncated: false },
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
async function setup(config: Partial<ToolPwsh.Config> = {}) {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(FakeBash)
|
||||
await ctx.plugin(ToolPwsh, config)
|
||||
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 } : {} } } })
|
||||
|
||||
let callCounter = 0
|
||||
function call(
|
||||
ctx: Context,
|
||||
name: string,
|
||||
args: unknown,
|
||||
options: { agent?: object; signal?: AbortSignal } = {},
|
||||
) {
|
||||
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 } : {},
|
||||
})
|
||||
}
|
||||
|
||||
function text(result: { content: { type: string; text?: string }[] }): string {
|
||||
return result.content.filter(b => b.type === 'text').map(b => b.text).join('')
|
||||
}
|
||||
|
||||
describe('registration', () => {
|
||||
it('registers the pwsh tool with its prompt section and schema', async () => {
|
||||
const { ctx } = await setup()
|
||||
const schema = ctx.tools.schemas().find(s => s.name === 'pwsh')
|
||||
expect(schema).toBeDefined()
|
||||
expect(schema?.description).toContain('PowerShell command')
|
||||
expect(schema?.parameters.properties).toMatchObject({
|
||||
command: { type: 'string' },
|
||||
description: { type: 'string' },
|
||||
timeoutMs: { type: 'number' },
|
||||
workdir: { type: 'string' },
|
||||
})
|
||||
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')
|
||||
})
|
||||
|
||||
it('stays pending until ctx.bash exists (inject)', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(ToolPwsh)
|
||||
expect(ctx.tools.schemas()).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('unregisters everything on fiber disposal (HMR safety)', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(FakeBash)
|
||||
const fiber = await ctx.plugin(ToolPwsh)
|
||||
expect(ctx.tools.schemas()).toHaveLength(1)
|
||||
await fiber.dispose()
|
||||
expect(ctx.tools.schemas()).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('argument validation', () => {
|
||||
it('rejects a blank command or description and a non-positive timeoutMs', async () => {
|
||||
const { ctx } = await setup()
|
||||
expect(text(await call(ctx, 'pwsh', { command: ' ', description: 'd' }))).toContain('expected a non-empty string')
|
||||
expect(text(await call(ctx, 'pwsh', { command: 'Write-Output hi', description: ' ' }))).toContain('expected a non-empty string')
|
||||
expect(text(await call(ctx, 'pwsh', { command: 'Write-Output hi', description: 'd', timeoutMs: -1 })))
|
||||
.toContain('invalid timeoutMs: expected a positive number')
|
||||
})
|
||||
})
|
||||
|
||||
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 })
|
||||
bash.handler = () => runResult('hi\n')
|
||||
const result = await call(ctx, 'pwsh', {
|
||||
command: 'Write-Output hi',
|
||||
description: 'say hi',
|
||||
timeoutMs: 1234,
|
||||
}, { agent: agent('/sessions/s1') })
|
||||
expect(result.isError).toBe(false)
|
||||
const request = bash.requests[0]
|
||||
expect(request?.command).toBe('Write-Output hi')
|
||||
expect(request?.workdir).toBe('/sessions/s1')
|
||||
expect(request?.timeoutMs).toBe(1234)
|
||||
expect(request?.dshEnv).toEqual({
|
||||
DSH_HOME: dshHome,
|
||||
DSH_SHELL: '1',
|
||||
DSH_SESSION_ID: 'session-1',
|
||||
})
|
||||
expect(bash.specs[0]?.workdir).toBe('/sessions/s1')
|
||||
})
|
||||
|
||||
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') })
|
||||
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') })
|
||||
expect(bash.requests[1]?.workdir).toBe(resolvePath('/abs/path'))
|
||||
})
|
||||
|
||||
it('omits workdir and the session id without an agent, so executor defaulting applies', async () => {
|
||||
const { ctx, bash } = await setup()
|
||||
bash.handler = () => runResult('ok\n')
|
||||
await call(ctx, 'pwsh', { command: 'Write-Output ok', description: 'ok' })
|
||||
expect(bash.requests[0]).not.toHaveProperty('workdir')
|
||||
const dshEnv = bash.requests[0]?.dshEnv
|
||||
expect(dshEnv).toBeDefined()
|
||||
expect(dshEnv?.['DSH_SHELL']).toBe('1')
|
||||
expect(dshEnv?.['DSH_HOME']).toEqual(expect.any(String))
|
||||
expect(dshEnv).not.toHaveProperty('DSH_SESSION_ID')
|
||||
})
|
||||
|
||||
it('forwards exec.signal into the resolved request', async () => {
|
||||
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 })
|
||||
expect(bash.requests[0]?.signal).toBe(controller.signal)
|
||||
})
|
||||
|
||||
it('projects the canonical foreground result with stdout, stderr, and exit facts', async () => {
|
||||
const { ctx, bash } = await setup()
|
||||
bash.handler = () => runResult('out\n', {
|
||||
exitCode: 2,
|
||||
stderr: { text: 'err\n', truncated: false },
|
||||
timeoutMs: 5000,
|
||||
})
|
||||
const result = await call(ctx, 'pwsh', { command: 'failing', description: 'fail' })
|
||||
expect(result.isError).toBe(false)
|
||||
if (result.isError) throw new Error('expected pwsh success')
|
||||
expect(result.value).toEqual({
|
||||
kind: 'foreground',
|
||||
exitCode: 2,
|
||||
signal: null,
|
||||
timedOut: false,
|
||||
aborted: false,
|
||||
timeoutMs: 5000,
|
||||
stdout: { text: 'out\n', truncated: false },
|
||||
stderr: { text: 'err\n', truncated: false },
|
||||
})
|
||||
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 () => {
|
||||
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]')
|
||||
|
||||
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]')
|
||||
})
|
||||
|
||||
it('translates an aborted run into the TOOL_ABORTED HarnessError', async () => {
|
||||
const { ctx, bash } = await setup()
|
||||
bash.handler = () => runResult('', { aborted: true, exitCode: null, signal: 'SIGTERM' })
|
||||
const result = await call(ctx, 'pwsh', { command: 'Start-Sleep -Seconds 60', description: 'sleep' })
|
||||
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' })
|
||||
expect(bash.startCalls).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('UI presentation', () => {
|
||||
it('a real execute renders the console view through the tool definition presenter', 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, { agent: agent('/w') })
|
||||
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```' }],
|
||||
})
|
||||
})
|
||||
|
||||
it('the pending call view is a terminal card carrying command, description, and optional cwd', async () => {
|
||||
const { ctx } = await setup()
|
||||
const definition = ctx.tools.get('pwsh')
|
||||
expect(definition?.presentCall?.({ command: 'Get-Process', description: 'List processes' }))
|
||||
.toEqual({ card: 'terminal', title: 'Get-Process', description: 'List processes' })
|
||||
expect(definition?.presentCall?.({ command: 'Get-Process', description: 'List processes', workdir: 'C:\\work' }))
|
||||
.toMatchObject({ cwd: 'C:\\work' })
|
||||
})
|
||||
|
||||
it('presentResult falls back to undefined for multi-block or non-text content', async () => {
|
||||
const { ctx } = await setup()
|
||||
const definition = ctx.tools.get('pwsh')
|
||||
const args = { command: 'Write-Output hi', description: 'say hi' }
|
||||
const multi = { content: [{ type: 'text' as const, text: 'a' }, { type: 'text' as const, text: 'b' }], isError: false }
|
||||
expect(definition?.presentResult?.(args, multi as never)).toBeUndefined()
|
||||
const image = { content: [{ type: 'image' as const, text: 'a' }], isError: false }
|
||||
expect(definition?.presentResult?.(args, image as never)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
45
packages/bash/tool-pwsh/tsconfig.json
Normal file
45
packages/bash/tool-pwsh/tsconfig.json
Normal file
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cosmokit"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/schemastery"
|
||||
},
|
||||
{
|
||||
"path": "../../llm/llm"
|
||||
},
|
||||
{
|
||||
"path": "../../core/tools"
|
||||
},
|
||||
{
|
||||
"path": "../../core/agent"
|
||||
},
|
||||
{
|
||||
"path": "../../session-persistence/session-persistence"
|
||||
},
|
||||
{
|
||||
"path": "../../bash/bash"
|
||||
},
|
||||
{
|
||||
"path": "../../util/paths"
|
||||
},
|
||||
{
|
||||
"path": "../../core/system-prompt"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user