Merge pull request #1624 from deepseek-harness/feat/pwsh-ui-parity
feat(pwsh): render pwsh calls as bash-shaped terminal cards in the Web UI
This commit is contained in:
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/bash/bash/README.md
|
||||
README.md: e459037205730455cc9bd3afb248d3a5541ce241
|
||||
README.zh.md: d5acab202d81b72d8524b291a0b6550ce45eae2f
|
||||
README.md: 88f519a21a0889d6b7649502c51077940c23709f
|
||||
README.zh.md: 294044692133da8baa57583146352e84c1ff9946
|
||||
|
||||
@@ -35,6 +35,8 @@ The per-session sandbox-mode override vocabulary (the `'sandbox/mode'` event, th
|
||||
|
||||
`stdin` and ordinary `env` are set by in-process plugins (the hooks bridges, native plugins) to feed a hook command its JSON payload and `CLAUDE_PROJECT_DIR`/`CLAUDE_PLUGIN_ROOT` values. `dshEnv` is a separate trusted overlay restricted by type to managed keys; the exported `DSH_ENV_PREFIX` is the single source for that namespace, its `DshEnvironmentKey` template type, executor scrubbing, registry validation, derived built-in names, and model guidance. Model bash uses the current snapshot collected by `ctx.bashEnv`. Implementations remove inherited managed keys, then merge `dshEnv` after ordinary `env`, so an omitted current fact cannot fall back to stale ambient state and an `env` entry cannot displace a managed value. The model-facing tool exposes none of these as parameters. All three remain optional on the resolved spec; absent means no input/overlay. See [the bash-stdin-env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) and [the session environment Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md).
|
||||
|
||||
The exported `parseExitStatus` (with `ParsedExitStatus`) is the shared rendering contract half of the shell tools: the inverse of the `[exit code: N]` / `[killed by signal: X]` markers `dsh-tool-bash`'s `renderResult` and `dsh-tool-pwsh`'s `renderPwshResult` append. Both tools' `presentResult` use it to split the rendered text into the terminal card's output body and its exit-status pill; it lives on the seam so the two tools never drift on the marker contract.
|
||||
|
||||
## Model Experience
|
||||
|
||||
Indirectly, through `dsh-tool-bash`, which turns executor output and sandbox facts into guidance and retained tool-result tokens.
|
||||
|
||||
@@ -35,6 +35,8 @@
|
||||
|
||||
`stdin` 与普通 `env` 由同进程插件(hooks 桥接、原生插件)设置,用于向 hook 命令提供其 JSON payload 和 `CLAUDE_PROJECT_DIR`/`CLAUDE_PLUGIN_ROOT` 值。`dshEnv` 是受类型限制、仅允许受管 key 的独立受信任 overlay;导出的 `DSH_ENV_PREFIX` 是该 namespace、其 `DshEnvironmentKey` 模板类型、执行器清理、注册表验证、派生内置名称与模型指引的统一来源。模型 bash 使用 `ctx.bashEnv` 收集的当前快照。实现会移除继承的受管 key,再在普通 `env` 之后合并 `dshEnv`,因此省略的当前事实不会回退到陈旧环境状态,`env` 条目也无法顶掉受管值。面向模型的工具不将这三者中的任何一个公开为参数。这三者在已解析 spec 上仍然可选;缺失表示没有输入/overlay。详见 [bash-stdin-env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) 与 [会话环境 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md)。
|
||||
|
||||
导出的 `parseExitStatus`(连同 `ParsedExitStatus`)是 shell 工具共享渲染契约的另一半:`dsh-tool-bash` 的 `renderResult` 与 `dsh-tool-pwsh` 的 `renderPwshResult` 追加的 `[exit code: N]`/`[killed by signal: X]` marker 的逆解析。两个工具的 `presentResult` 都用它把渲染文本拆成 terminal 卡的输出正文与其退出状态 pill;它放在 seam 上,两个工具便永远不会在 marker 契约上漂移。
|
||||
|
||||
## 模型体验
|
||||
|
||||
通过 `dsh-tool-bash` 间接影响;该工具会将执行器输出与沙箱事实转为指引和保留的工具结果 token。
|
||||
|
||||
@@ -22,6 +22,8 @@ export type {
|
||||
DshEnvironment,
|
||||
DshEnvironmentKey,
|
||||
} from './types.ts'
|
||||
export { parseExitStatus } from './render.ts'
|
||||
export type { ParsedExitStatus } from './render.ts'
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
|
||||
42
packages/bash/bash/src/render.ts
Normal file
42
packages/bash/bash/src/render.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Shared rendering helpers for the shell tools (`dsh-tool-bash`,
|
||||
* `dsh-tool-pwsh`): the exit-status marker contract the tools' renderers
|
||||
* emit and the presentation layer parses back.
|
||||
* @module @deepseek-ai/dsh-bash/render
|
||||
*/
|
||||
|
||||
/**
|
||||
* The exit status recovered from a rendered result, with the output body that
|
||||
* status was split off from.
|
||||
*/
|
||||
export type ParsedExitStatus =
|
||||
& { body: string }
|
||||
& ({ exitCode: number } | { signal: string })
|
||||
|
||||
/**
|
||||
* Split a rendered shell-tool result string into its output body and the
|
||||
* structured exit status — the inverse of the `[exit code: N]` /
|
||||
* `[killed by signal: X]` markers the shell tools' renderers append. A killed
|
||||
* marker yields `signal`; otherwise a non-zero marker yields `exitCode`;
|
||||
* absent both means a clean exit 0.
|
||||
*
|
||||
* The consumed marker is removed from `body` because a terminal presentation
|
||||
* shows the exit status as its own pill: leaving the marker in the output
|
||||
* would render the exit twice. Other markers (timeout, sandbox denial) carry
|
||||
* facts no pill shows, so they stay in the body.
|
||||
*
|
||||
* Replay only retains the rendered content text, not the original
|
||||
* `BashRunResult`, so terminal presentation must recover the exit pill here.
|
||||
* Requiring a leading newline and the end of the string keeps ordinary output
|
||||
* that merely ends with marker-like text from matching unless the final line
|
||||
* is indistinguishable from a real marker.
|
||||
* @param text - rendered model-facing shell-tool result.
|
||||
* @returns the marker-free body plus the recovered terminal exit code or signal.
|
||||
*/
|
||||
export function parseExitStatus(text: string): ParsedExitStatus {
|
||||
const signal = /\n\[killed by signal: ([^\]\n]+)\]$/.exec(text)
|
||||
if (signal?.[1] !== undefined) return { body: text.slice(0, signal.index), signal: signal[1] }
|
||||
const exit = /\n\[exit code: (\d+)\]$/.exec(text)
|
||||
if (exit?.[1] !== undefined) return { body: text.slice(0, exit.index), exitCode: Number(exit[1]) }
|
||||
return { body: text, exitCode: 0 }
|
||||
}
|
||||
36
packages/bash/bash/tests/render.spec.ts
Normal file
36
packages/bash/bash/tests/render.spec.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Shared exit-status parse contract: the inverse of the `[exit code: N]` /
|
||||
* `[killed by signal: X]` markers `dsh-tool-bash` and `dsh-tool-pwsh` append.
|
||||
* Both tools' presenter suites round-trip their own renderers through this
|
||||
* parse; this spec pins the parse's own edges (marker-like output, body
|
||||
* slicing) once, at the seam that owns it.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { parseExitStatus } from '../src/render.ts'
|
||||
|
||||
describe('parseExitStatus', () => {
|
||||
it('recovers a clean exit 0 with the body verbatim when no marker is present', () => {
|
||||
expect(parseExitStatus('hi\n\n')).toEqual({ body: 'hi\n\n', exitCode: 0 })
|
||||
expect(parseExitStatus('')).toEqual({ body: '', exitCode: 0 })
|
||||
})
|
||||
|
||||
it('recovers a non-zero exit and strips only its marker from the body', () => {
|
||||
expect(parseExitStatus('oops\n[exit code: 3]')).toEqual({ body: 'oops', exitCode: 3 })
|
||||
// The marker needs the leading newline and the end of the string, so a
|
||||
// clean result whose output merely ENDS in marker-like text is not read
|
||||
// as a failure and the text stays in the body.
|
||||
expect(parseExitStatus('[exit code: 5]')).toEqual({ body: '[exit code: 5]', exitCode: 0 })
|
||||
})
|
||||
|
||||
it('recovers a signal kill ahead of any non-zero exit marker', () => {
|
||||
expect(parseExitStatus('gone\n[killed by signal: SIGKILL]')).toEqual({ body: 'gone', signal: 'SIGKILL' })
|
||||
// A fake signal marker with no leading newline is output, not a kill.
|
||||
expect(parseExitStatus('[killed by signal: SIGKILL]')).toEqual({ body: '[killed by signal: SIGKILL]', exitCode: 0 })
|
||||
})
|
||||
|
||||
it('keeps markers no pill shows (timeout) in the body', () => {
|
||||
expect(parseExitStatus('slow\n[timed out after 100ms]\n[exit code: 143]'))
|
||||
.toEqual({ body: 'slow\n[timed out after 100ms]', exitCode: 143 })
|
||||
})
|
||||
})
|
||||
@@ -95,36 +95,9 @@ export function renderProcessRead(
|
||||
}
|
||||
|
||||
/**
|
||||
* The exit status recovered from a rendered result, with the output body that
|
||||
* status was split off from.
|
||||
* The exit-status parse is the shared marker-contract half of the shell-tool
|
||||
* rendering story, owned by `@deepseek-ai/dsh-bash` so `dsh-tool-pwsh` reuses
|
||||
* it (its renderer emits the same markers). Re-exported here to keep
|
||||
* `../src/render.ts` a single import root for bash-tool consumers.
|
||||
*/
|
||||
export type ParsedExitStatus =
|
||||
& { body: string }
|
||||
& ({ exitCode: number } | { signal: string })
|
||||
|
||||
/**
|
||||
* Split a rendered {@link renderResult} string into its output body and the
|
||||
* structured exit status — the inverse of the status markers it appends. A
|
||||
* killed marker yields `signal`; otherwise a non-zero marker yields `exitCode`;
|
||||
* absent both means a clean exit 0.
|
||||
*
|
||||
* The consumed marker is removed from `body` because a terminal presentation
|
||||
* shows the exit status as its own pill: leaving the marker in the output would
|
||||
* render the exit twice. Other markers (timeout, sandbox denial) carry facts no
|
||||
* pill shows, so they stay in the body.
|
||||
*
|
||||
* Replay only retains the rendered content text, not the original
|
||||
* `BashRunResult`, so terminal presentation must recover the exit pill here.
|
||||
* Requiring a leading newline and the end of the string keeps ordinary output
|
||||
* that merely ends with marker-like text from matching unless the final line
|
||||
* is indistinguishable from a real marker.
|
||||
* @param text - rendered model-facing bash result.
|
||||
* @returns the marker-free body plus the recovered terminal exit code or signal.
|
||||
*/
|
||||
export function parseExitStatus(text: string): ParsedExitStatus {
|
||||
const signal = /\n\[killed by signal: ([^\]\n]+)\]$/.exec(text)
|
||||
if (signal?.[1] !== undefined) return { body: text.slice(0, signal.index), signal: signal[1] }
|
||||
const exit = /\n\[exit code: (\d+)\]$/.exec(text)
|
||||
if (exit?.[1] !== undefined) return { body: text.slice(0, exit.index), exitCode: Number(exit[1]) }
|
||||
return { body: text, exitCode: 0 }
|
||||
}
|
||||
export { parseExitStatus, type ParsedExitStatus } from '@deepseek-ai/dsh-bash'
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/bash/tool-pwsh/README.md
|
||||
README.md: dfe26a63684d61dcdd6f969c2c2261dac79325c7
|
||||
README.zh.md: 87a30130c2f4c56be34199dca39399c45eb4b323
|
||||
README.md: 78eb161f77b9524bc577b273abe59db6b931727c
|
||||
README.zh.md: 17696fe6d908838aaaca12e8179f2ad9cb780210
|
||||
|
||||
@@ -36,7 +36,7 @@ When `run_in_background` is true, this plugin preflights `ctx.tasks.start()` bef
|
||||
|
||||
## UI presentation
|
||||
|
||||
The tool owns its `presentCall`/`presentResult` render intent. A foreground call is a `terminal` card carrying command, description, and optional cwd; a `run_in_background` call is a `generic` card with the raw command, mirroring the bash tool's background presentation. A completed result is a `generic` card with the rendered output in a `console` fence. The bash tool's terminal card with its parsed exit-status pill has no pwsh counterpart yet — a PowerShell-aware presentation is roadmap work. These presenters are pure and replay-safe.
|
||||
The tool owns its `presentCall`/`presentResult` render intent. A foreground call is a `terminal` card carrying command, description, and optional cwd; a `run_in_background` call is a `generic` card with the raw command, mirroring the bash tool's background presentation. A completed foreground result is a `terminal` card too: the exit marker becomes the card's exit-status pill (`exitCode`/`signal`), and the marker-free body is the card's output — exactly the bash tool's terminal-card story, via the shared exit-status parse from `@deepseek-ai/dsh-bash`. Background acks and execution errors stay `generic` cards with the rendered output in a `console` fence. These presenters are pure and replay-safe.
|
||||
|
||||
## Model Experience
|
||||
|
||||
@@ -121,5 +121,4 @@ Append-only; newly visible content follows the reusable request prefix and does
|
||||
- **No sandbox escalation** — `sandbox_permissions`/`justification` are absent; escalation waits for a Windows-confining executor (the bash tool's sandbox surface is not mirrored).
|
||||
- **No persistent shell or PTY** — every call starts a fresh `pwsh -Command`; the PTY backends are Linux/macOS-only today, and a Windows ConPTY persistent shell is roadmap work.
|
||||
- **PowerShell-dialect contract** — the model must write PowerShell (native paths, `$env:` variables), not bash; there is no dialect translation.
|
||||
- **Generic UI presentation** — results use the generic card; a PowerShell-aware terminal card with exit-status pill is roadmap work.
|
||||
- **Session-cwd identity is not canonicalized** — the workdir base is the session header cwd as-is, unlike the bash tool's sandbox-root-canonicalized identity; only the sandbox-less case applies here.
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
|
||||
## UI presentation
|
||||
|
||||
工具拥有自己的 `presentCall`/`presentResult` 呈现意图。前台调用是携带命令、描述与可选 cwd 的 `terminal` 卡;`run_in_background` 调用是携带原始命令的 `generic` 卡,镜像 bash 工具的后台呈现。完成的结果是以 `console` 围栏包裹渲染输出的 `generic` 卡。bash 工具那种带解析退出状态 pill 的 terminal 卡在 pwsh 侧暂无对应——PowerShell 感知的呈现属于路线图工作。这些 presenter 是纯函数且可重放。
|
||||
工具拥有自己的 `presentCall`/`presentResult` 呈现意图。前台调用是携带命令、描述与可选 cwd 的 `terminal` 卡;`run_in_background` 调用是携带原始命令的 `generic` 卡,镜像 bash 工具的后台呈现。完成的前台结果同样是 `terminal` 卡:退出 marker 变成卡片的退出状态 pill(`exitCode`/`signal`),去 marker 的正文成为卡片输出——与 bash 工具的 terminal 卡故事完全一致,经由 `@deepseek-ai/dsh-bash` 的共享退出状态解析。后台 ack 与执行错误保持 `generic` 卡,以 `console` 围栏包裹渲染输出。这些 presenter 是纯函数且可重放。
|
||||
|
||||
## Model Experience
|
||||
|
||||
@@ -121,5 +121,4 @@ ack 是固定短行;任务输出按读取有界。
|
||||
- **无 sandbox 升级** — 没有 `sandbox_permissions`/`justification`;升级等待 Windows-confining 执行器(bash 工具的 sandbox 面不被镜像)。
|
||||
- **无持久 shell 或 PTY** — 每次调用都启动全新的 `pwsh -Command`;PTY 后端目前仅限 Linux/macOS,Windows ConPTY 持久 shell 属于路线图工作。
|
||||
- **PowerShell 方言契约** — 模型必须写 PowerShell(原生路径、`$env:` 变量),而不是 bash;没有方言翻译。
|
||||
- **通用 UI 呈现** — 结果使用 generic 卡;带退出状态 pill 的 PowerShell 感知 terminal 卡属于路线图工作。
|
||||
- **会话 cwd 身份不做规范化** — workdir 基座直接取会话头 cwd 原值,不同于 bash 工具经 sandbox-root 规范化的身份;此处只涉及无 sandbox 场景。
|
||||
|
||||
@@ -8,8 +8,9 @@
|
||||
* foreground and `run_in_background` execution (background handles register
|
||||
* with the generic `ctx.tasks` runtime), the managed `DSH_*` environment
|
||||
* through the shared `bash-env` registry, and the bash marker/truncation
|
||||
* rendering story. UI presentation stays on the existing generic/terminal
|
||||
* cards; a pwsh-specific rendering twin is roadmap work.
|
||||
* rendering story. UI presentation mirrors the bash tool's too: a completed
|
||||
* foreground call is a terminal card with the parsed exit-status pill, using
|
||||
* the shared exit-status parse from `@deepseek-ai/dsh-bash`.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-tool-pwsh
|
||||
*/
|
||||
@@ -25,6 +26,7 @@ import type {} from '@deepseek-ai/dsh-system-prompt'
|
||||
import type {} from '@deepseek-ai/dsh-tasks'
|
||||
import type {} from '@deepseek-ai/dsh-bash-env'
|
||||
import type { BashRunResult } from '@deepseek-ai/dsh-bash'
|
||||
import { parseExitStatus } from '@deepseek-ai/dsh-bash'
|
||||
import { processOutcome } from './background.ts'
|
||||
import { renderPwshProcessRead, renderPwshResult } from './render.ts'
|
||||
|
||||
@@ -297,10 +299,20 @@ export function apply(ctx: Context, config: Config = {}): void {
|
||||
}
|
||||
},
|
||||
/* jscpd:ignore-end */
|
||||
presentResult: (_args: unknown, result: ToolResult): ToolResultView | undefined => {
|
||||
/* jscpd:ignore-start -- the completed-result presentation mirrors presentBashResult's by design (Agent Note). */
|
||||
presentResult: (args: unknown, result: ToolResult): ToolResultView | undefined => {
|
||||
const block = result.content.length === 1 ? result.content[0] : undefined
|
||||
if (block === undefined || block.type !== 'text') return undefined
|
||||
return { card: 'generic', content: [{ type: 'text', text: `\`\`\`console\n${block.text.replace(/\n+$/, '')}\n\`\`\`` }] }
|
||||
const raw = block.text
|
||||
const isBackground = typeof args === 'object' && args !== null && (args as { run_in_background?: unknown }).run_in_background === true
|
||||
// Background acknowledgements and errors have no terminal exit status.
|
||||
if (isBackground || result.isError) {
|
||||
return { card: 'generic', content: [{ type: 'text', text: `\`\`\`console\n${raw.replace(/\n+$/, '')}\n\`\`\`` }] }
|
||||
}
|
||||
// The exit marker becomes the card's exit pill, so it leaves the output body.
|
||||
const { body, ...exit } = parseExitStatus(raw)
|
||||
return { card: 'terminal', output: body, ...exit }
|
||||
},
|
||||
/* jscpd:ignore-end */
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ import * as ToolPwsh from '@deepseek-ai/dsh-tool-pwsh'
|
||||
import * as BashEnvPlugin from '@deepseek-ai/dsh-bash-env'
|
||||
import type { BashProcessRead } from '@deepseek-ai/dsh-bash'
|
||||
import { processOutcome } from '../src/background.ts'
|
||||
import { renderPwshProcessRead } from '../src/render.ts'
|
||||
import { renderPwshProcessRead, renderPwshResult } from '../src/render.ts'
|
||||
|
||||
const testToolSignal = new AbortController().signal
|
||||
|
||||
@@ -516,16 +516,16 @@ describe('background execution through the task runtime', () => {
|
||||
})
|
||||
|
||||
describe('UI presentation', () => {
|
||||
it('a real execute renders the console view through the tool definition presenter', async () => {
|
||||
it('a real execute presents a completed foreground run as a terminal card with the parsed exit pill', async () => {
|
||||
const { ctx, bash } = await setup()
|
||||
bash.handler = () => runResult('hi\n')
|
||||
const args = { command: 'Write-Output hi', description: 'say hi' }
|
||||
const result = await call(ctx, 'pwsh', args)
|
||||
const view = ctx.tools.get('pwsh')?.presentResult?.(args, result)
|
||||
expect(view).toEqual({
|
||||
card: 'generic',
|
||||
content: [{ type: 'text', text: '```console\nhi\n```' }],
|
||||
})
|
||||
// A terminal result keeps the RAW bytes (newlines intact) a terminal
|
||||
// renderer needs; a clean run renders no exit marker, so the body is the
|
||||
// raw output with a clean exit-0 pill, mirroring the bash tool.
|
||||
expect(view).toEqual({ card: 'terminal', output: 'hi\n', exitCode: 0 })
|
||||
})
|
||||
|
||||
it('the pending call view is a terminal card carrying command, description, and optional cwd', async () => {
|
||||
@@ -553,6 +553,83 @@ describe('UI presentation', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('presentResult: a non-zero exit and a signal kill parse into exitCode / signal', async () => {
|
||||
const { ctx } = await setup()
|
||||
const present = ctx.tools.get('pwsh')
|
||||
const args = { command: 'x', description: 'x' }
|
||||
expect(present?.presentResult?.(args, { content: [{ type: 'text', text: 'oops\n[exit code: 3]' }], isError: false }))
|
||||
.toEqual({ card: 'terminal', output: 'oops', exitCode: 3 })
|
||||
expect(present?.presentResult?.(args, { content: [{ type: 'text', text: 'gone\n[killed by signal: SIGKILL]' }], isError: false }))
|
||||
.toEqual({ card: 'terminal', output: 'gone', signal: 'SIGKILL' })
|
||||
})
|
||||
|
||||
it('presentResult: markers a pill CANNOT show (timeout) stay in the terminal output', async () => {
|
||||
const { ctx } = await setup()
|
||||
const args = { command: 'x', description: 'x' }
|
||||
expect(ctx.tools.get('pwsh')?.presentResult?.(
|
||||
args,
|
||||
{ content: [{ type: 'text', text: 'slow\n[timed out after 100ms]\n[exit code: 143]' }], isError: false },
|
||||
)).toEqual({ card: 'terminal', output: 'slow\n[timed out after 100ms]', exitCode: 143 })
|
||||
})
|
||||
|
||||
it('presentResult exit parse is the inverse of renderPwshResult markers (round-trip)', async () => {
|
||||
const { ctx } = await setup()
|
||||
const present = ctx.tools.get('pwsh')!
|
||||
const base = {
|
||||
aborted: false,
|
||||
timeoutMs: 1000,
|
||||
stdout: { text: 'out', truncated: false },
|
||||
stderr: { text: '', truncated: false },
|
||||
}
|
||||
const cases = [
|
||||
{ result: { ...base, exitCode: 0, signal: null, timedOut: false }, expect: { exitCode: 0 } },
|
||||
{ result: { ...base, exitCode: 7, signal: null, timedOut: false }, expect: { exitCode: 7 } },
|
||||
{ result: { ...base, exitCode: null, signal: 'SIGTERM' as const, timedOut: false }, expect: { signal: 'SIGTERM' } },
|
||||
// A trapped-timeout run that exits 0 has no signal/exit marker → reads as exit 0 (it did exit 0).
|
||||
{ result: { ...base, exitCode: 0, signal: null, timedOut: true }, expect: { exitCode: 0 } },
|
||||
]
|
||||
for (const c of cases) {
|
||||
const rendered = renderPwshResult(c.result)
|
||||
const out = present.presentResult!({ command: 'x', description: 'x' }, { content: [{ type: 'text', text: rendered }], isError: false })
|
||||
// Drop card + output; the remaining fields are the parsed exit.
|
||||
const { card: _c, output, ...exit } = out as { card: string; output?: string; exitCode?: number; signal?: string }
|
||||
expect(exit).toEqual(c.expect)
|
||||
// Whatever the parse consumed is gone from the body, so a card with an
|
||||
// exit pill never shows the same status twice.
|
||||
expect(output).not.toMatch(/\[exit code: \d+\]|\[killed by signal: /)
|
||||
}
|
||||
})
|
||||
|
||||
it('presentResult: a clean exit-0 whose output ENDS in marker-like text is NOT read as a failure', async () => {
|
||||
const { ctx } = await setup()
|
||||
const args = { command: 'Write-Output "[exit code: 5]"', description: 'print' }
|
||||
// A successful command may print marker-like text. A clean result appends no marker or
|
||||
// newline; parsing requires the leading newline emitted for real markers, so this stays exit 0.
|
||||
const out = ctx.tools.get('pwsh')!.presentResult!(args, { content: [{ type: 'text', text: '[exit code: 5]' }], isError: false })
|
||||
expect(out).toEqual({ card: 'terminal', output: '[exit code: 5]', exitCode: 0 })
|
||||
// Same for a fake signal marker with no leading newline.
|
||||
const sig = ctx.tools.get('pwsh')!.presentResult!(args, { content: [{ type: 'text', text: '[killed by signal: SIGKILL]' }], isError: false })
|
||||
expect(sig).toEqual({ card: 'terminal', output: '[killed by signal: SIGKILL]', exitCode: 0 })
|
||||
})
|
||||
|
||||
it('presentResult: a run_in_background ack is a generic card and carries no exit pill', async () => {
|
||||
const { ctx } = await setup()
|
||||
const result = ctx.tools.get('pwsh')!.presentResult!(
|
||||
{ command: 'Start-Sleep -Seconds 60', description: 'long wait', run_in_background: true },
|
||||
{ content: [{ type: 'text', text: 'started background task pwsh-1' }], isError: false },
|
||||
)
|
||||
expect(result).toEqual({ card: 'generic', content: [{ type: 'text', text: '```console\nstarted background task pwsh-1\n```' }] })
|
||||
})
|
||||
|
||||
it('presentResult: an isError result is a generic card (no real process exit to report)', async () => {
|
||||
const { ctx } = await setup()
|
||||
const out = ctx.tools.get('pwsh')!.presentResult!(
|
||||
{ command: 'x', description: 'x' },
|
||||
{ content: [{ type: 'text', text: 'tool call aborted' }], isError: true },
|
||||
)
|
||||
expect(out).toEqual({ card: 'generic', content: [{ type: 'text', text: '```console\ntool call aborted\n```' }] })
|
||||
})
|
||||
|
||||
it('presentResult falls back to undefined for multi-block or non-text content', async () => {
|
||||
const { ctx } = await setup()
|
||||
const definition = ctx.tools.get('pwsh')
|
||||
|
||||
Reference in New Issue
Block a user