refactor: apply repository naming contract

Apply the accepted pre-release package, service, type, directory, and role renames as one repository-wide change.
This commit is contained in:
Tianyi Cui
2026-08-13 00:36:22 +08:00
parent 101df7cf58
commit a2d0f7f411
3281 changed files with 21730 additions and 21592 deletions

View 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/shell/tool-bash-persistent/README.md
README.md: 37c259443cba566350d8ec7963857d5f6ce7396c
README.zh.md: 8f3cf4e4d20aad1537b48644cee1a9d5706f4b3c

View File

@@ -0,0 +1,50 @@
# @deepseek-ai/dsh-tool-bash-persistent
English | [中文](README.zh.md)
Model-facing `bash(command)` backed by one owner-scoped `ctx.terminals` shell. The package owns the tool contract and shell reuse; deployments select the PTY backend and sandbox policy.
## Config
| Key | Default | Meaning |
|---|---:|---|
| `backendType` | `shell` | Registered PTY backend used for each Agent shell. |
| `timeoutMs` | `300000` | Wall-clock limit for one command; timeout closes the shell. |
| `maxOutputChars` | `16000` | Maximum retained command-output characters; fixed diagnostics are added afterward. |
| `description` | Persistent-shell description | Model-facing environment contract. |
## Model Experience
### Tool schema
#### What the model sees
The generated [`bash` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-bash-persistent), including the configured `description`. The plugin contributes no standalone system-prompt section; the deployment owns persona and environment guidance.
#### Token effect
Fixed schema cost while `bash` is visible.
#### KV Cache effect
Prefix-stable while the configured description and schema remain unchanged.
### Tool results
#### What the model sees
Commands share one shell per Agent, so cwd, exported variables, activated environments, functions, and background jobs persist across calls. Results exclude private completion markers and the shell prompt. A nonzero wrapped command appends `[exit code: N]`; a shell that exits before reporting that status instead appends `[shell exited: code N]`, `[shell killed by signal: SIG]`, or `[shell exited]` when the backend supplies neither, then resets and tells the model that the next call starts fresh. Long output keeps the earliest retained prefix plus a clipping notice. If the PTY has already dropped that prefix, the result says so explicitly instead of presenting a tail as complete output. Timeout returns bounded partial output, closes the uncertain shell, and reports the reset.
#### Token effect
Data-dependent. `maxOutputChars` bounds retained command output; fixed clipping, lost-prefix, status, timeout, and reset diagnostics can extend the result.
#### KV Cache effect
Append-only tool results follow the reusable request prefix.
## Known Limitations and Deferred Work
- The tool requires an owning Agent and a real PTY backend.
- Explicit `exit` and timeout discard shell state. Cancellation also resets and discards the result, even when a complete status marker is already observable; the next call starts a fresh shell.
- Environment facts such as network access and package mirrors belong in the configured `description`, not this package's default.

View File

@@ -0,0 +1,50 @@
# @deepseek-ai/dsh-tool-bash-persistent
[English](README.md) | 中文
模型可见的 `bash(command)`,底层复用一个按所有者隔离的 `ctx.terminals` shell。该包拥有工具约定和 shell 复用PTY 后端与沙箱策略由部署选择。
## 配置
| 键 | 默认值 | 含义 |
|---|---:|---|
| `backendType` | `shell` | 每个 Agent shell 使用的已注册 PTY 后端。 |
| `timeoutMs` | `300000` | 单条命令的墙钟时间上限;超时会关闭 shell。 |
| `maxOutputChars` | `16000` | 命令输出最多保留的字符数;固定诊断会在此后追加。 |
| `description` | 持久 shell 描述 | 面向模型的环境约定。 |
## 模型体验
### 工具 schema
#### 模型所见
生成的 [`bash` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-bash-persistent),其中包含配置的 `description`。本插件不贡献独立系统提示词段persona 与环境指导由部署负责。
#### Token 影响
`bash` 可见时产生固定的 schema 成本。
#### KV Cache 影响
配置的描述与 schema 不变时前缀稳定。
### 工具结果
#### 模型所见
每个 Agent 的命令共享一个 shell因此 cwd、导出的环境变量、已激活环境、函数和后台任务会跨调用保留。结果不包含私有完成标记和 shell 提示符。经封装的命令以非零状态结束时,结果会追加 `[exit code: N]`;若 shell 在报告该状态前退出,则改为追加 `[shell exited: code N]``[shell killed by signal: SIG]`,或在后端既未提供退出码也未提供信号时追加 `[shell exited]`;随后重置 shell并告知模型下次调用从新 shell 开始。长输出保留仍可读取的最早前缀并追加截断提示;若 PTY 已丢弃真正的开头,结果会明确说明,而不是把尾部伪装成完整输出。超时返回有界的部分输出、关闭状态不确定的 shell并报告该重置。
#### Token 影响
随数据变化。`maxOutputChars` 限制保留的命令输出;固定的截断、前缀丢失、状态、超时与重置诊断可能使结果更长。
#### KV Cache 影响
工具结果以追加方式位于可复用请求前缀之后。
## 已知限制与延后工作
- 工具需要拥有它的 Agent 和真实 PTY 后端。
- 显式 `exit` 与超时会丢弃 shell 状态。取消同样会重置 shell 并丢弃结果,即使已经能观察到完整状态标记也是如此;下次调用创建新 shell。
- 网络访问、软件包镜像等环境事实应写入配置的 `description`,而非包默认描述。

View File

@@ -0,0 +1,61 @@
{
"name": "@deepseek-ai/dsh-tool-bash-persistent",
"description": "Model-facing owner-scoped persistent Bash tool backed by the Harness PTY service",
"version": "0.0.1-rc.2",
"publishConfig": {
"access": "restricted"
},
"repository": {
"type": "git",
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
"directory": "packages/shell/tool-bash-persistent"
},
"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"
},
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/types/**/*.d.ts"
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-terminal": "workspace:^",
"@deepseek-ai/dsh-timeout": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"@deepseek-ai/cordis": "workspace:^"
},
"dependencies": {
"@deepseek-ai/schemastery": "workspace:^"
},
"devDependencies": {
"@deepseek-ai/cordis-plugin-include": "workspace:^",
"@deepseek-ai/cordis-plugin-loader": "workspace:^",
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-terminal": "workspace:^",
"@deepseek-ai/dsh-terminal-bash": "workspace:^",
"@deepseek-ai/dsh-sandbox": "workspace:^",
"@deepseek-ai/dsh-sandbox-policy": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-subprocess-local": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-timeout": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"@deepseek-ai/cordis": "workspace:^"
}
}

View File

@@ -0,0 +1,445 @@
/**
* Model-facing persistent `bash` tool over the owner-scoped PTY seam.
* @module @deepseek-ai/dsh-tool-bash-persistent
*/
import { randomUUID } from 'node:crypto'
import type { Context } from '@deepseek-ai/cordis'
import z from '@deepseek-ai/schemastery'
import type { Agent } from '@deepseek-ai/dsh-agent'
import type { TerminalReadResult, TerminalSendResult, TerminalSessionId } from '@deepseek-ai/dsh-terminal'
import { deadline, timeoutOf } from '@deepseek-ai/dsh-timeout'
import { defineTool } from '@deepseek-ai/dsh-tools'
// TODO: Replace the file-search advice; arbitrary command output need not come from a searchable file.
const TRUNCATED_MESSAGE = '<response clipped><NOTE>To save on context only part of this file has been shown to you. You should retry this tool after you have searched inside the file with `grep -n` in order to find the line numbers of what you are looking for.</NOTE>'
const LOST_PREFIX_MESSAGE = '<response clipped><NOTE>The beginning of this command output was dropped by the terminal scrollback limit. The following text is the earliest retained output.</NOTE>\n'
const SHELL_RESET_MESSAGE = 'The persistent bash shell was reset; the next bash call starts from the workspace with a fresh current directory and environment.'
const SHELL_PROMPT = '__DSH_PERSISTENT_BASH_PROMPT__ '
const TIMEOUT_CODE = 'PERSISTENT_BASH_TIMEOUT'
// One page is enough to find a just-emitted completion marker; the full
// scrollback is assembled only when a command settles or needs partial output.
const SCROLLBACK_PAGE_LINES = 1_000
const POLL_INTERVAL_MS = 25
const DEFAULT_DESCRIPTION = 'Run commands in a persistent bash shell. State, including the current directory and exported environment variables, persists across calls for this agent.'
interface ResolvedConfig {
backendType: string
timeoutMs: number
maxOutputChars: number
description: string
}
interface CommandMarkers {
start: string
end: string
}
interface RetainedOutput {
text: string
truncated: boolean
}
interface CapturedOutput {
text: string
incomplete: boolean
exitCode?: number
}
interface PersistentShells {
get(owner: Agent, signal: AbortSignal): Promise<TerminalSessionId>
reset(owner: Agent, reason: string): Promise<void>
}
function maybeTruncate(content: string, maxOutputChars: number, incomplete = false): string {
if (content.length <= maxOutputChars && !incomplete) return content
return content.length <= maxOutputChars
? content + TRUNCATED_MESSAGE
: content.slice(0, maxOutputChars) + TRUNCATED_MESSAGE
}
function markers(): CommandMarkers {
const nonce = randomUUID()
return {
start: `__DSH_PERSISTENT_BASH_START_${nonce}__`,
end: `__DSH_PERSISTENT_BASH_END_${nonce}:`,
}
}
function quoteForBash(value: string): string {
return `$'${value
.replaceAll('\\', '\\\\')
.replaceAll("'", "\\'")
.replaceAll('\r', '\\r')
.replaceAll('\n', '\\n')}'`
}
function wrapCommand(command: string, marker: CommandMarkers): string {
// Keep the wrapper on one physical line. An interactive bash prints PS2 for
// embedded newlines before executing the buffer, which would leak terminal
// prompts and marker source text into the model-facing result.
return `printf '%s\\n' ${quoteForBash(marker.start)}; eval -- ${quoteForBash(command)}; __dsh_persistent_bash_status=$?; printf '%s%s\\n' ${quoteForBash(marker.end)} "$__dsh_persistent_bash_status"`
}
function stripPrompt(text: string): string {
let result = text.replace(/\r?\n$/, '')
while (result.endsWith(SHELL_PROMPT)) {
result = result.slice(0, -SHELL_PROMPT.length)
}
return result.endsWith('\n') ? result.slice(0, -1) : result
}
function commandOutput(
snapshot: RetainedOutput,
marker: CommandMarkers,
): CapturedOutput | undefined {
const text = snapshot.text
const end = text.lastIndexOf(marker.end)
const status = /^(\d+)\r?\n/.exec(text.slice(end + marker.end.length))?.[1]
if (status === undefined) return undefined
const startMarker = text.lastIndexOf(marker.start, end)
const start = startMarker < 0 ? 0 : startMarker + marker.start.length
return {
text: stripPrompt(text.slice(start, end).replace(/^\r?\n/, '')),
incomplete: startMarker < 0,
exitCode: Number(status),
}
}
function promptCompleted(result: TerminalSendResult): boolean {
return result.viewport.endsWith(SHELL_PROMPT)
|| result.viewport.endsWith(`${SHELL_PROMPT}\r\n`)
|| result.viewport.endsWith(`${SHELL_PROMPT}\n`)
}
function partialOutput(
snapshot: RetainedOutput,
marker: CommandMarkers,
fallback: string,
fallbackTruncated = false,
): CapturedOutput {
const startMarker = snapshot.text.lastIndexOf(marker.start)
if (startMarker >= 0) {
return {
text: stripPrompt(snapshot.text.slice(startMarker + marker.start.length).replace(/^\r?\n/, '')),
incomplete: false,
}
}
const fallbackStart = fallback.lastIndexOf(marker.start)
const afterStart = fallbackStart < 0
? fallback
: fallback.slice(fallbackStart + marker.start.length).replace(/^\r?\n/, '')
const fallbackEnd = afterStart.lastIndexOf(marker.end)
const beforeEnd = fallbackEnd < 0 ? afterStart : afterStart.slice(0, fallbackEnd)
return {
text: stripPrompt(beforeEnd.replaceAll(SHELL_PROMPT, '')),
incomplete: fallbackTruncated || fallbackStart < 0,
}
}
async function pause(): Promise<void> {
await new Promise(resolve => setTimeout(resolve, POLL_INTERVAL_MS))
}
function nextScrollbackOffset(page: TerminalReadResult, offset: number): number | undefined {
if (page.text.length === 0 || page.lineEnd <= offset) return undefined
return page.lineEnd
}
function retainedScrollback(
ctx: Context,
owner: Agent,
id: TerminalSessionId,
latest = ctx.terminals.read(owner, id, { offset: 0, count: SCROLLBACK_PAGE_LINES }),
): RetainedOutput {
const pages: string[] = latest.text.length === 0 ? [] : [latest.text]
let offset = latest.lineEnd
let truncated = latest.truncated
while (true) {
if (offset >= latest.totalLines) break
const page = ctx.terminals.read(owner, id, { offset, count: SCROLLBACK_PAGE_LINES })
truncated ||= page.truncated
if (page.text.length > 0) pages.unshift(page.text)
const next = nextScrollbackOffset(page, offset)
if (next === undefined || next >= page.totalLines) break
offset = next
}
return { text: pages.join('\n'), truncated }
}
function renderCaptured(output: CapturedOutput, maxOutputChars: number): string {
const rendered = maybeTruncate(output.text, maxOutputChars, output.incomplete)
const withPrefix = output.incomplete && output.text.length > 0
? LOST_PREFIX_MESSAGE + rendered
: rendered
const marker = output.exitCode !== undefined && output.exitCode !== 0
? `[exit code: ${output.exitCode}]`
: undefined
return appendStatusMarker(withPrefix, marker)
}
function appendStatusMarker(content: string, marker: string | undefined): string {
if (marker === undefined) return content
return content.length === 0 ? marker : `${content}\n${marker}`
}
function renderShellExitStatus(
content: string,
exitCode: number | null,
signal: NodeJS.Signals | null,
): string {
const marker = signal !== null
? `[shell killed by signal: ${signal}]`
: exitCode !== null
? `[shell exited: code ${exitCode}]`
: '[shell exited]'
return appendStatusMarker(content, marker)
}
function persistentShells(ctx: Context, config: ResolvedConfig): PersistentShells {
const pending = new WeakMap<Agent, Promise<TerminalSessionId>>()
const live = new Map<Agent, TerminalSessionId>()
const creating = new Set<Promise<TerminalSessionId>>()
const ownerCleanupInstalled = new WeakSet<Agent>()
const lifecycle = new AbortController()
const close = async (owner: Agent, id: TerminalSessionId, reason: string): Promise<void> => {
if (!ctx.terminals.list(owner).some(snapshot => snapshot.sessionId === id)) return
await ctx.terminals.kill(owner, id, reason)
}
ctx.effect(() => async () => {
lifecycle.abort(new Error('tool-bash-persistent disposed during shell creation'))
await Promise.allSettled([...creating])
const closing = [...live].map(async ([owner, id]) => { await close(owner, id, 'tool-bash-persistent disposed') })
await Promise.all(closing)
live.clear()
}, 'tool-bash-persistent shell cleanup')
const reset = async (owner: Agent, reason: string): Promise<void> => {
pending.delete(owner)
const id = live.get(owner)
live.delete(owner)
if (id !== undefined) await close(owner, id, reason)
}
const get = (owner: Agent, signal: AbortSignal): Promise<TerminalSessionId> => {
const existing = pending.get(owner)
if (existing !== undefined) return existing
const combinedSignal = AbortSignal.any([signal, lifecycle.signal])
const creation = (async () => {
try {
const cwd = owner.session.header.cwd
const spawned = await ctx.terminals.spawn(owner, {
type: config.backendType,
...cwd === undefined ? {} : { cwd },
}, combinedSignal)
live.set(owner, spawned.sessionId)
if (!ownerCleanupInstalled.has(owner)) {
ownerCleanupInstalled.add(owner)
owner.ctx.effect(() => () => {
pending.delete(owner)
live.delete(owner)
}, 'tool-bash-persistent owner cache cleanup')
}
const setup = ctx.terminals.startSend(owner, spawned.sessionId, {
text: `stty -echo; PS1=${quoteForBash(SHELL_PROMPT)}`,
submit: true,
signal: combinedSignal,
})
const result = await setup.done
if (result.sessionStatus.kind === 'exited' || result.waitReason === 'timeout') {
throw new Error('persistent bash shell did not accept initialization')
}
return spawned.sessionId
} catch (error: unknown) {
await reset(owner, 'persistent bash initialization failed')
throw error
}
})()
const tracked = creation.finally(() => {
creating.delete(tracked)
})
creating.add(tracked)
pending.set(owner, tracked)
return tracked
}
return { get, reset }
}
async function executeCommand(
ctx: Context,
shells: PersistentShells,
owner: Agent,
command: string,
config: ResolvedConfig,
upstream: AbortSignal,
): Promise<string> {
using commandDeadline = deadline(upstream, config.timeoutMs, TIMEOUT_CODE)
const id = await shells.get(owner, commandDeadline.signal)
const marker = markers()
const wrapped = wrapCommand(command, marker)
let first = true
let fallback = ''
let fallbackTruncated = false
while (true) {
let operation
let result
try {
operation = ctx.terminals.startSend(owner, id, {
text: first ? wrapped : '',
submit: first,
signal: commandDeadline.signal,
})
first = false
result = await operation.done
} catch (error: unknown) {
await shells.reset(owner, 'persistent bash send failed')
throw error
}
const incremental = operation.readOutput()
fallback = incremental.delta.length > 0 ? fallback + incremental.delta : result.viewport
fallbackTruncated ||= incremental.truncated || result.truncated
const latest = ctx.terminals.read(owner, id, { offset: 0, count: SCROLLBACK_PAGE_LINES })
const timedOut = timeoutOf(commandDeadline.signal, TIMEOUT_CODE)
if (timedOut !== undefined) {
const snapshot = retainedScrollback(ctx, owner, id, latest)
const partial = renderCaptured(
partialOutput(snapshot, marker, fallback, fallbackTruncated),
config.maxOutputChars,
)
await shells.reset(owner, 'persistent bash command timed out')
return [
// TODO: Report a timeout only; this signal does not establish an OOM.
`Your command timed out after ${Math.round(timedOut.timeoutMs / 1000)} seconds or experienced an OOM error. Below is partial output:`,
partial,
SHELL_RESET_MESSAGE,
].join('\n')
}
if (commandDeadline.signal.aborted) {
await shells.reset(owner, 'persistent bash command aborted')
commandDeadline.signal.throwIfAborted()
}
if (latest.text.includes(marker.end)) {
const complete = commandOutput(retainedScrollback(ctx, owner, id, latest), marker)
if (complete !== undefined) return renderCaptured(complete, config.maxOutputChars)
}
if (result.sessionStatus.kind === 'exited') {
const snapshot = retainedScrollback(ctx, owner, id, latest)
await shells.reset(owner, 'persistent bash shell exited')
return [
renderShellExitStatus(
renderCaptured(partialOutput(snapshot, marker, fallback, fallbackTruncated), config.maxOutputChars),
result.sessionStatus.exitCode,
result.sessionStatus.signal,
),
SHELL_RESET_MESSAGE,
].filter(part => part.length > 0).join('\n')
}
if (promptCompleted(result)) {
const snapshot = retainedScrollback(ctx, owner, id, latest)
return renderCaptured(
partialOutput(snapshot, marker, fallback, fallbackTruncated),
config.maxOutputChars,
)
}
await pause()
}
}
/**
* Register the model-facing persistent `bash` tool.
* @param ctx - plugin context carrying tools and the owner-scoped PTY service.
* @param config - selected PTY backend and command deadline.
*/
function registerPersistentBash(ctx: Context, config: ResolvedConfig): void {
const shells = persistentShells(ctx, config)
const queues = new WeakMap<Agent, Promise<void>>()
const serialized = async <T>(owner: Agent, operation: () => Promise<T>): Promise<T> => {
const prior = queues.get(owner) ?? Promise.resolve()
const run = prior.then(operation, operation)
const tail = run.then(() => undefined, () => undefined)
queues.set(owner, tail)
try {
return await run
} finally {
if (queues.get(owner) === tail) queues.delete(owner)
}
}
ctx.tools.register(defineTool({
name: 'bash',
description: config.description,
parameters: {
command: {
type: 'string',
required: true,
description: 'The bash command to run. Relative path is preferred in the command.',
},
},
output: {
schema: { type: 'string' },
render: (_args, value) => [{ type: 'text', text: value }],
},
async execute(args, exec) {
if (args.command.trim().length === 0) throw new Error('command must be a non-empty string')
const owner = exec.agent
if (owner === undefined) throw new Error('bash requires an owning agent session')
return serialized(owner, async () => {
exec.signal.throwIfAborted()
return executeCommand(ctx, shells, owner, args.command, config, exec.signal)
})
},
presentCall: args => ({ card: 'terminal', title: args.command }),
}))
}
export const name = 'tool-bash-persistent'
export const inject = ['tools', 'terminals']
/** Configuration for the persistent Bash tool. */
export interface Config {
/** PTY backend used for each owner-isolated persistent shell (default `shell`). */
backendType?: string
/** Wall-clock limit for one command (default 300000). */
timeoutMs?: number
/** Maximum returned command-output characters before clipping (default 16000). */
maxOutputChars?: number
/** Model-facing tool description; deployments may describe their environment. */
description?: string
}
/** Runtime configuration schema for the persistent Bash tool. */
export const Config: z<Config> = z.object({
backendType: z.string().default('shell'),
timeoutMs: z.number().default(300_000),
maxOutputChars: z.number().default(16_000),
description: z.string().default(DEFAULT_DESCRIPTION),
})
/** Register one owner-scoped persistent `bash` tool. */
export function apply(ctx: Context, config: Config): void {
const resolved: ResolvedConfig = {
backendType: config.backendType ?? 'shell',
timeoutMs: config.timeoutMs ?? 300_000,
maxOutputChars: config.maxOutputChars ?? 16_000,
description: config.description ?? DEFAULT_DESCRIPTION,
}
if (resolved.backendType.trim().length === 0) {
throw new Error('tool-bash-persistent: backendType must be non-empty')
}
if (!Number.isSafeInteger(resolved.timeoutMs) || resolved.timeoutMs <= 0) {
throw new Error('tool-bash-persistent: timeoutMs must be a positive safe integer')
}
if (!Number.isSafeInteger(resolved.maxOutputChars) || resolved.maxOutputChars <= 0) {
throw new Error('tool-bash-persistent: maxOutputChars must be a positive safe integer')
}
if (resolved.description.trim().length === 0) {
throw new Error('tool-bash-persistent: description must be non-empty')
}
registerPersistentBash(ctx, resolved)
}

View File

@@ -0,0 +1,31 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-tool-bash-persistent`.
* @module @deepseek-ai/dsh-tool-bash-persistent/invariant
*/
/* jscpd:ignore-start */
import type { Context } from '@deepseek-ai/cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-tool-bash-persistent'
/** Cordis companion plugin name. */
export const name = 'tool-bash-persistent-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/**
* No runtime invariant: the adapter's private owner-to-shell cache has no
* observable event or data relation. Lifecycle tests prove its cleanup without
* adding a public API solely for an invariant.
*/
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 */

View File

@@ -0,0 +1,161 @@
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { pathToFileURL } from 'node:url'
import { afterEach, describe, expect, it } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import Loader from '@deepseek-ai/cordis-plugin-loader'
import Include from '@deepseek-ai/cordis-plugin-include'
import { CallId } from '@deepseek-ai/dsh-llm'
import { Session, SessionId } from '@deepseek-ai/dsh-session'
import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import TerminalSessionService from '@deepseek-ai/dsh-terminal'
import * as TerminalLocal from '@deepseek-ai/dsh-terminal-bash'
import SandboxProvider from '@deepseek-ai/dsh-sandbox'
import type { ConfinedArgv, SandboxPolicy } from '@deepseek-ai/dsh-sandbox'
import SandboxPolicyService from '@deepseek-ai/dsh-sandbox-policy'
import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRuntime from '@deepseek-ai/dsh-tools'
import * as ToolBashPersistent from '@deepseek-ai/dsh-tool-bash-persistent'
let root: string | undefined
let context: Context | undefined
afterEach(async () => {
await context?.fiber.dispose()
context = undefined
if (root !== undefined) await rm(root, { recursive: true, force: true })
root = undefined
})
class PassthroughSandbox extends SandboxProvider {
confine(argv: readonly string[], _policy: SandboxPolicy): ConfinedArgv {
return { argv: [...argv], enforcement: 'full', denialSignatures: [], runnerFailureRules: [] }
}
}
function agent(ctx: Context, cwd: string): Agent {
const id = SessionId('persistent-bash-loader-agent')
const scope = ctx.plugin(() => {})
const session = Session.create(id, [], { version: 0, id, createdAt: 0, cwd })
const value: Agent = {
id,
options: {},
session,
inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }),
status: 'idle',
ctx: scope.ctx,
send: () => {},
followup: () => {},
steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }),
inject: () => {},
cancel() {},
runMaintenance: task => task(new AbortController().signal),
whenIdle: () => Promise.resolve(),
}
ctx.agents.register(value)
return value
}
function text(result: { content: { type: string; text?: string }[] }): string {
return result.content.filter(block => block.type === 'text').map(block => block.text).join('')
}
const suite = process.platform === 'linux' || process.platform === 'darwin' ? describe : describe.skip
suite('persistent Bash through a real cordis.yml Loader composition', () => {
it('preserves cwd and environment across calls', async () => {
root = await mkdtemp(join(tmpdir(), 'dsh-persistent-bash-loader-'))
const configPath = join(root, 'cordis.yml')
await writeFile(configPath, [
"- name: '@deepseek-ai/dsh-agent'",
"- name: '@deepseek-ai/dsh-system-prompt'",
"- name: '@deepseek-ai/dsh-tools'",
"- name: '@deepseek-ai/dsh-terminal'",
"- name: '@deepseek-ai/dsh-test-sandbox'",
"- name: '@deepseek-ai/dsh-sandbox-policy'",
' config:',
' mode: danger-full-access',
` workspaceRoot: ${JSON.stringify(root)}`,
"- name: '@deepseek-ai/dsh-subprocess-local'",
"- name: '@deepseek-ai/dsh-terminal-bash'",
' config:',
' pollIntervalMs: 10',
' exactProbeAfterMs: 20',
' idleSilenceMs: 100',
' handoffGraceMs: 100',
' scrollbackLines: 20000',
' timeoutMs: 2000',
' disposeGraceMs: 500',
"- name: '@deepseek-ai/dsh-tool-bash-persistent'",
' config:',
' timeoutMs: 5000',
'',
].join('\n'))
context = new Context()
context.baseUrl = pathToFileURL(root).href + '/'
await context.plugin(Loader)
context.loader.builtins.include = Include
const modules = new Map<string, unknown>([
['@deepseek-ai/dsh-agent', AgentRegistry],
['@deepseek-ai/dsh-system-prompt', SystemPrompt],
['@deepseek-ai/dsh-tools', ToolRuntime],
['@deepseek-ai/dsh-terminal', TerminalSessionService],
['@deepseek-ai/dsh-test-sandbox', PassthroughSandbox],
['@deepseek-ai/dsh-sandbox-policy', SandboxPolicyService],
['@deepseek-ai/dsh-subprocess-local', LocalSubprocessRuntime],
['@deepseek-ai/dsh-terminal-bash', TerminalLocal],
['@deepseek-ai/dsh-tool-bash-persistent', ToolBashPersistent],
])
context.loader.internal = {
version: 'v2',
async import(specifier: string) {
if (!modules.has(specifier)) throw new Error(`unexpected Loader import: ${specifier}`)
return modules.get(specifier)
},
} as unknown as NonNullable<typeof context.loader.internal>
await context.loader.create({ name: 'cordis:include', config: { path: pathToFileURL(configPath).href } })
await context.loader.await()
const owner = agent(context, root)
const signal = new AbortController().signal
const execute = (id: string, command: string) => context!.tools.execute({
signal,
callId: CallId(id),
name: 'bash',
arguments: { command },
agent: owner,
})
expect(context.tools.schemas().map(schema => schema.name)).toEqual(['bash'])
await execute('state', 'export KEEP=loader; mkdir -p nested; cd nested')
const observed = text(await execute('observe', 'printf "cwd=%s keep=%s\\n" "$PWD" "$KEEP"'))
expect(observed).toContain(`cwd=${join(root, 'nested')} keep=loader`)
expect(observed).not.toContain('DSH_PERSISTENT_BASH')
const multiline = text(await execute(
'multiline',
'value="line one"\nprintf "%s:%s\\n" "$value" "it\'s fine"',
))
expect(multiline).toBe("line one:it's fine")
expect(multiline).not.toContain('DSH_PERSISTENT_BASH')
const heredoc = text(await execute(
'heredoc',
"cat <<'EOF'\nalpha\nbeta\nEOF",
))
expect(heredoc).toBe('alpha\nbeta')
const large = text(await execute('large-output', 'seq 1 12050'))
expect(large.startsWith('1\n2\n3\n')).toBe(true)
expect(large).toContain('<response clipped>')
expect(large).not.toContain('beginning of this command output was dropped')
const exited = text(await execute('exit', 'exit'))
expect(exited).toContain('next bash call starts from the workspace')
expect(text(await execute('after-exit', 'printf "%s\\n" "$PWD"'))).toBe(root)
}, 20_000)
})

View File

@@ -0,0 +1,572 @@
import { afterEach, describe, expect, it } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import { CallId } from '@deepseek-ai/dsh-llm'
import { Session, SessionId } from '@deepseek-ai/dsh-session'
import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import TerminalSessionService from '@deepseek-ai/dsh-terminal'
import type {
TerminalBackend,
TerminalBackendSession,
TerminalReadRequest,
TerminalSendOperation,
TerminalSendRequest,
TerminalSessionStatus,
TerminalSignal,
TerminalWaitReason,
} from '@deepseek-ai/dsh-terminal'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRuntime from '@deepseek-ai/dsh-tools'
import * as ToolBashPersistent from '@deepseek-ai/dsh-tool-bash-persistent'
const contexts: Context[] = []
let callNumber = 0
afterEach(async () => {
for (const ctx of contexts.splice(0)) await ctx.fiber.dispose()
})
function agent(ctx: Context, cwd: string | undefined): Agent {
const id = SessionId(`persistent-bash-owner-${callNumber}`)
const scope = ctx.plugin(() => {})
const session = Session.create(id, [], {
version: 0,
id,
createdAt: 0,
...cwd === undefined ? {} : { cwd },
})
const value: Agent = {
id,
options: {},
session,
inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }),
status: 'idle',
ctx: scope.ctx,
send: () => {},
followup: () => {},
steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }),
inject: () => {},
cancel() {},
runMaintenance: task => task(new AbortController().signal),
whenIdle: () => Promise.resolve(),
}
ctx.agents.register(value)
return value
}
function text(result: { content: { type: string; text?: string }[] }): string {
return result.content.filter(block => block.type === 'text').map(block => block.text).join('')
}
function call(
ctx: Context,
owner: Agent | undefined,
command: string,
signal = new AbortController().signal,
) {
return ctx.tools.execute({
signal,
callId: CallId(`persistent-bash-${++callNumber}`),
name: 'bash',
arguments: { command },
...owner === undefined ? {} : { agent: owner },
})
}
type StubMode =
| 'normal'
| 'prompt-only'
| 'prompt-crlf'
| 'empty-read'
| 'stalled-read'
| 'exit'
| 'signal-exit'
| 'unknown-exit'
| 'wait-for-abort'
| 'end-on-abort'
| 'idle-then-normal'
| 'large'
| 'nonzero'
| 'torn-status'
| 'finish-torn-status'
| 'end-only'
| 'init-exit'
| 'init-timeout'
| 'spawn-error'
| 'send-error'
| 'prompt-after-idle'
| 'incremental-fallback'
| 'empty-page-after-latest'
| 'paged-scrollback'
class StubPtySession implements TerminalBackendSession {
readonly motd = '__DSH_PERSISTENT_BASH_PROMPT__ '
readonly pid = 123
statusValue: TerminalSessionStatus = { kind: 'running' }
scrollback = this.motd
closed: string[] = []
mode: StubMode
sends = 0
pendingText = ''
historyTruncated = false
constructor(mode: StubMode) {
this.mode = mode
}
startSend(request: TerminalSendRequest): TerminalSendOperation {
this.sends += 1
if (request.text.startsWith('stty -echo')) {
if (this.mode === 'init-exit') {
this.statusValue = { kind: 'exited', exitCode: 1, signal: null }
return this.operation(Promise.resolve(this.result('', 'session_exit')))
}
if (this.mode === 'init-timeout') {
return this.operation(Promise.resolve(this.result('', 'timeout')))
}
return this.operation(Promise.resolve(this.result(this.motd, 'stdin_read')))
}
if (this.mode === 'send-error') throw new Error('stub send failed')
if (this.mode === 'wait-for-abort' || this.mode === 'end-on-abort') {
const done = new Promise<ReturnType<StubPtySession['result']>>((resolve) => {
request.signal?.addEventListener('abort', () => {
const start = /__DSH_PERSISTENT_BASH_START_[^_]+(?:-[^_]+)*__/.exec(request.text)?.[0]
const end = /__DSH_PERSISTENT_BASH_END_[^:]+:/.exec(request.text)?.[0]
const output = this.mode === 'end-on-abort'
? `${start ?? ''}\ninterrupted\n${end ?? ''}130\n${this.motd}`
: 'partial output'
this.scrollback += output
resolve(this.result(output, 'stdin_read'))
}, { once: true })
})
return this.operation(done)
}
if (this.mode === 'idle-then-normal') {
this.mode = 'normal'
this.pendingText = request.text
return this.operation(Promise.resolve(this.result('', 'inferred_idle')))
}
if (this.mode === 'prompt-after-idle') {
if (request.text.length > 0) {
const start = /__DSH_PERSISTENT_BASH_START_[^_]+(?:-[^_]+)*__/.exec(request.text)?.[0]
const output = `${start ?? ''}\npartial syntax output\n`
this.scrollback += output
return this.operation(Promise.resolve(this.result(output, 'inferred_idle')))
}
const output = `bash: syntax error\n${this.motd}`
this.scrollback += output
return this.operation(Promise.resolve(this.result(output, 'stdin_read')))
}
if (this.mode === 'prompt-only' || this.mode === 'prompt-crlf') {
const newline = this.mode === 'prompt-crlf' ? '\r\n' : '\n'
const output = `bash: syntax error${newline}${this.motd}${newline}`
this.scrollback += output
return this.operation(Promise.resolve(this.result(output, 'stdin_read')))
}
const sent = request.text.length > 0 ? request.text : this.pendingText
this.pendingText = ''
const start = /__DSH_PERSISTENT_BASH_START_[^_]+(?:-[^_]+)*__/.exec(sent)?.[0]
const end = /__DSH_PERSISTENT_BASH_END_[^:]+:/.exec(sent)?.[0]
if (this.mode === 'incremental-fallback') {
const incremental = `${start ?? ''}\nincrement\n${this.motd}`
return this.operation(Promise.resolve(this.result(this.motd, 'stdin_read')), incremental)
}
if (this.mode === 'torn-status') {
const output = `${start ?? ''}\nhello from stub\n${end ?? ''}`
this.scrollback += output
this.mode = 'finish-torn-status'
return this.operation(Promise.resolve(this.result(output, 'inferred_idle')))
}
if (this.mode === 'finish-torn-status') {
const output = `7\n${this.motd}`
this.scrollback += output
return this.operation(Promise.resolve(this.result(output, 'stdin_read')))
}
if (this.mode === 'end-only') {
const output = `recovered output\n${end ?? ''}0\n${this.motd}`
this.scrollback += output
return this.operation(Promise.resolve(this.result(output, 'stdin_read')))
}
const commandOutput = this.mode === 'large'
? 'x'.repeat(100)
: this.mode === 'nonzero' ? '' : 'hello from stub'
const exitCode = this.mode === 'nonzero' ? 7 : 0
const output = `${start ?? ''}\n${commandOutput}\n${end ?? ''}${exitCode}\n${this.motd}`
this.scrollback += output
if (this.mode === 'exit' || this.mode === 'signal-exit' || this.mode === 'unknown-exit') {
const exitedOutput = `${start ?? ''}\nhello from stub\n`
this.scrollback = this.scrollback.slice(0, -output.length) + exitedOutput
this.statusValue = this.mode === 'signal-exit'
? { kind: 'exited', exitCode: null, signal: 'SIGTERM' }
: this.mode === 'exit'
? { kind: 'exited', exitCode: 9, signal: null }
: { kind: 'exited', exitCode: null, signal: null }
return this.operation(Promise.resolve(this.result(exitedOutput, 'session_exit')))
}
return this.operation(Promise.resolve(this.result(output, 'stdin_read')))
}
read(request: TerminalReadRequest) {
if (this.mode === 'empty-read') {
return { text: '', totalLines: 0, lineBegin: 0, lineEnd: 0, truncated: false }
}
if (this.mode === 'stalled-read') {
return { text: 'stalled', totalLines: 1, lineBegin: 0, lineEnd: 0, truncated: false }
}
if (this.mode === 'empty-page-after-latest' && (request.offset ?? 0) > 0) {
return { text: '', totalLines: 2, lineBegin: 1, lineEnd: 1, truncated: false }
}
const lines = this.scrollback.split('\n')
if (this.mode === 'paged-scrollback') {
const offset = request.offset ?? 0
const end = lines.length - offset
const start = Math.max(0, end - 3)
const returnedLines = end - start
return {
text: lines.slice(start, end).join('\n'),
totalLines: lines.length,
lineBegin: offset,
lineEnd: offset + returnedLines,
truncated: this.historyTruncated,
}
}
return {
text: this.scrollback,
totalLines: this.mode === 'empty-page-after-latest' ? lines.length + 1 : lines.length,
lineBegin: 0,
lineEnd: this.mode === 'empty-page-after-latest' ? 1 : lines.length,
truncated: this.historyTruncated,
}
}
signal(_signal: TerminalSignal) {
return Promise.resolve({ delivered: true as const, targetPgid: 123 })
}
status() {
return this.statusValue
}
async close(reason: string) {
this.closed.push(reason)
this.statusValue = { kind: 'exited', exitCode: 0, signal: null }
}
private result(viewport: string, waitReason: TerminalWaitReason) {
return { viewport, waitReason, sessionStatus: this.statusValue, truncated: false }
}
private operation(done: Promise<ReturnType<StubPtySession['result']>>, delta = ''): TerminalSendOperation {
return {
done,
readOutput: () => ({ delta, truncated: false }),
cancel: () => false,
}
}
}
function stubBackend(initialMode: StubMode = 'normal') {
const sessions: StubPtySession[] = []
const backend: TerminalBackend = {
type: 'stub',
async spawn() {
if (initialMode === 'spawn-error') throw new Error('stub spawn failed')
const session = new StubPtySession(initialMode)
sessions.push(session)
return session
},
}
return { backend, sessions }
}
async function setup(
config: ToolBashPersistent.Config = { backendType: 'stub' },
initialMode: StubMode = 'normal',
) {
const ctx = new Context()
contexts.push(ctx)
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRuntime)
await ctx.plugin(AgentRegistry)
await ctx.plugin(TerminalSessionService)
const stub = stubBackend(initialMode)
ctx.terminals.registerBackend(stub.backend)
const fiber = await ctx.plugin(ToolBashPersistent, config)
return { ctx, stub, fiber, owner: agent(ctx, '/workspace') }
}
describe('tool-bash-persistent', () => {
it('registers a configurable schema and reuses one owner shell', async () => {
const { ctx, owner, stub, fiber } = await setup({
backendType: 'stub',
description: 'deployment-specific persistent shell',
})
const schema = ctx.tools.schemas()[0]
expect(ctx.tools.schemas().map(item => item.name)).toEqual(['bash'])
expect(schema?.description).toBe('deployment-specific persistent shell')
expect(schema?.parameters).toMatchObject({
required: ['command'],
properties: { command: { type: 'string' } },
})
expect(ctx.tools.get('bash')?.presentCall?.({ command: 'pwd' }))
.toEqual({ card: 'terminal', title: 'pwd' })
expect(text(await call(ctx, owner, 'echo one'))).toBe('hello from stub')
expect(text(await call(ctx, owner, 'echo two'))).toBe('hello from stub')
expect(stub.sessions).toHaveLength(1)
expect(stub.sessions[0]?.sends).toBe(3)
const ownerWithoutCwd = agent(ctx, undefined)
expect(text(await call(ctx, ownerWithoutCwd, 'pwd'))).toBe('hello from stub')
expect(stub.sessions).toHaveLength(2)
await fiber.dispose()
expect(ctx.tools.schemas()).toEqual([])
expect(ctx.tools.get('bash')).toBeUndefined()
})
it('handles inferred idle, prompt fallback, shell exit, clipping, and cleanup', async () => {
const { ctx, owner, stub, fiber } = await setup({
backendType: 'stub',
maxOutputChars: 10,
})
await call(ctx, owner, 'warm up')
const session = stub.sessions[0]!
session.mode = 'idle-then-normal'
expect(text(await call(ctx, owner, 'silent then complete'))).toContain('hello from')
session.mode = 'incremental-fallback'
session.scrollback = ''
expect(text(await call(ctx, owner, 'incremental fallback'))).toBe('increment')
session.mode = 'prompt-only'
const promptFallback = text(await call(ctx, owner, 'bad {'))
expect(promptFallback).toContain('bash: synt')
expect(promptFallback).not.toContain('DSH_PERSISTENT_BASH_PROMPT')
session.mode = 'prompt-crlf'
session.scrollback = ''
const crlfPromptFallback = text(await call(ctx, owner, 'bad {'))
expect(crlfPromptFallback).toContain('bash: synt')
expect(crlfPromptFallback).not.toContain('DSH_PERSISTENT_BASH_PROMPT')
session.mode = 'end-only'
session.scrollback = ''
const missingStart = text(await call(ctx, owner, 'recover marker'))
expect(missingStart).toContain('recovered')
expect(missingStart).toContain('beginning of this command output was dropped')
expect(missingStart).toContain('<response clipped>')
session.mode = 'large'
expect(text(await call(ctx, owner, 'large'))).toContain('<response clipped>')
session.mode = 'nonzero'
expect(text(await call(ctx, owner, 'false'))).toBe('[exit code: 7]')
session.mode = 'exit'
const exited = text(await call(ctx, owner, 'exit'))
expect(exited).toContain('hello from')
expect(exited).toContain('[shell exited: code 9]')
expect(exited).not.toContain('[exit code: 9]')
expect(exited).toContain('next bash call starts from the workspace')
expect(session.closed).toContain('persistent bash shell exited')
await call(ctx, owner, 'new shell')
expect(stub.sessions).toHaveLength(2)
const replacement = stub.sessions[1]!
replacement.mode = 'signal-exit'
expect(text(await call(ctx, owner, 'kill shell')))
.toContain('[shell killed by signal: SIGTERM]')
await call(ctx, owner, 'another shell')
expect(stub.sessions).toHaveLength(3)
const externallyClosed = ctx.terminals.list(owner)[0]?.sessionId
expect(externallyClosed).toBeDefined()
await ctx.terminals.kill(owner, externallyClosed!, 'external cleanup')
await fiber.dispose()
expect(stub.sessions[2]?.closed).toEqual(['external cleanup'])
})
it('waits for status digits after a torn completion marker', async () => {
const { ctx, owner, stub } = await setup({ backendType: 'stub', maxOutputChars: 1_000 })
await call(ctx, owner, 'warm up')
stub.sessions[0]!.mode = 'torn-status'
stub.sessions[0]!.scrollback = ''
expect(text(await call(ctx, owner, 'torn status'))).toBe('hello from stub\n[exit code: 7]')
})
it('reports a shell exit when the backend has no code or signal', async () => {
const { ctx, owner, stub } = await setup({ backendType: 'stub' })
await call(ctx, owner, 'warm up')
stub.sessions[0]!.mode = 'unknown-exit'
expect(text(await call(ctx, owner, 'exit without status'))).toContain('[shell exited]')
})
it('marks a short missing-prefix result and tolerates exhausted scrollback pages', async () => {
const { ctx, owner, stub } = await setup({ backendType: 'stub', maxOutputChars: 1_000 })
await call(ctx, owner, 'warm up')
const session = stub.sessions[0]!
session.mode = 'end-only'
session.scrollback = ''
expect(text(await call(ctx, owner, 'missing start')))
.toContain('beginning of this command output was dropped')
session.mode = 'empty-read'
expect(text(await call(ctx, owner, 'empty page'))).toContain('hello from stub')
session.mode = 'stalled-read'
expect(text(await call(ctx, owner, 'stalled page'))).toContain('hello from stub')
session.mode = 'empty-page-after-latest'
expect(text(await call(ctx, owner, 'empty continuation page'))).toContain('hello from stub')
})
it('assembles retained output across backward scrollback pages', async () => {
const { ctx, owner, stub } = await setup({ backendType: 'stub', maxOutputChars: 1_000 })
await call(ctx, owner, 'warm up')
const session = stub.sessions[0]!
session.mode = 'paged-scrollback'
session.scrollback = 'older one\nolder two\nolder three\nolder four\n'
expect(text(await call(ctx, owner, 'paged output'))).toBe('hello from stub')
})
it('sanitizes a prompt fallback reached after multiple polling rounds', async () => {
const { ctx, owner, stub } = await setup({ backendType: 'stub', maxOutputChars: 1_000 })
await call(ctx, owner, 'warm up')
const session = stub.sessions[0]!
session.mode = 'prompt-after-idle'
session.scrollback = ''
const result = text(await call(ctx, owner, 'bad {'))
expect(result).toContain('partial syntax output')
expect(result).toContain('bash: syntax error')
expect(result).not.toContain('DSH_PERSISTENT_BASH_PROMPT')
expect(result).not.toContain('DSH_PERSISTENT_BASH_START')
})
it('does not attribute old scrollback truncation to a complete current command', async () => {
const { ctx, owner, stub } = await setup({ backendType: 'stub', maxOutputChars: 1_000 })
await call(ctx, owner, 'warm up')
stub.sessions[0]!.historyTruncated = true
const result = text(await call(ctx, owner, 'short command'))
expect(result).toBe('hello from stub')
expect(result).not.toContain('<response clipped>')
expect(result).not.toContain('beginning of this command output was dropped')
})
it('closes a timed-out shell and reports bounded partial output', async () => {
const { ctx, owner, stub } = await setup({ backendType: 'stub', timeoutMs: 10 })
await call(ctx, owner, 'warm up')
stub.sessions[0]!.mode = 'wait-for-abort'
const result = await call(ctx, owner, 'hang')
expect(text(result)).toContain('timed out after 0 seconds or experienced an OOM error')
expect(text(result)).toContain('partial output')
expect(text(result)).toContain('next bash call starts from the workspace')
expect(stub.sessions[0]?.closed).toContain('persistent bash command timed out')
})
it.each(['wait-for-abort', 'end-on-abort'] as const)(
'cancels %s work, resets the shell, and releases a queued call',
async (mode) => {
const { ctx, owner, stub } = await setup({ backendType: 'stub', timeoutMs: 5_000 })
await call(ctx, owner, 'warm up')
stub.sessions[0]!.mode = mode
const controller = new AbortController()
const cancelled = call(ctx, owner, 'hang', controller.signal)
const queued = call(ctx, owner, 'after cancellation')
setTimeout(() => {
controller.abort(new Error('caller stopped'))
}, 5)
expect((await cancelled).isError).toBe(true)
expect(text(await queued)).toBe('hello from stub')
expect(stub.sessions[0]?.closed).toContain('persistent bash command aborted')
expect(stub.sessions).toHaveLength(2)
},
)
it.each(['init-exit', 'init-timeout'] as const)(
'fails initialization and closes the unusable shell for %s',
async (mode) => {
const { ctx, owner, stub } = await setup({ backendType: 'stub' }, mode)
expect((await call(ctx, owner, 'pwd')).isError).toBe(true)
expect(stub.sessions[0]?.closed).toContain('persistent bash initialization failed')
},
)
it('clears a failed spawn without trying to close an unpublished shell', async () => {
const { ctx, owner, stub } = await setup({ backendType: 'stub' }, 'spawn-error')
expect((await call(ctx, owner, 'pwd')).isError).toBe(true)
expect(stub.sessions).toHaveLength(0)
})
it('resets a cached shell after startSend fails', async () => {
const { ctx, owner, stub } = await setup()
await call(ctx, owner, 'warm up')
stub.sessions[0]!.mode = 'send-error'
expect((await call(ctx, owner, 'fails')).isError).toBe(true)
expect(stub.sessions[0]?.closed).toContain('persistent bash send failed')
expect(text(await call(ctx, owner, 'recovers'))).toBe('hello from stub')
expect(stub.sessions).toHaveLength(2)
})
it('cancels and awaits a pending shell spawn when the plugin is disposed', async () => {
const ctx = new Context()
contexts.push(ctx)
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRuntime)
await ctx.plugin(AgentRegistry)
await ctx.plugin(TerminalSessionService)
const spawnStarted = Promise.withResolvers<undefined>()
const spawnAborted = Promise.withResolvers<undefined>()
ctx.terminals.registerBackend({
type: 'slow',
spawn: spec => new Promise((_resolve, reject) => {
spawnStarted.resolve(undefined)
spec.signal?.addEventListener('abort', () => {
spawnAborted.resolve(undefined)
const reason: unknown = spec.signal?.reason
reject(reason instanceof Error
? reason
: new Error('slow PTY spawn aborted', { cause: reason }))
}, { once: true })
}),
})
const fiber = await ctx.plugin(ToolBashPersistent, { backendType: 'slow' })
const owner = agent(ctx, '/workspace')
const running = call(ctx, owner, 'pwd')
await spawnStarted.promise
await fiber.dispose()
await spawnAborted.promise
expect((await running).isError).toBe(true)
expect(ctx.terminals.list(owner)).toEqual([])
})
it('rejects invalid config and invalid calls', async () => {
const { ctx, owner, stub } = await setup()
expect((await call(ctx, undefined, 'pwd')).isError).toBe(true)
expect(text(await call(ctx, owner, ' '))).toContain('command must be a non-empty string')
const controller = new AbortController()
controller.abort(new Error('caller stopped'))
expect((await call(ctx, owner, 'pwd', controller.signal)).isError).toBe(true)
expect(stub.sessions).toHaveLength(0)
expect(() => {
ToolBashPersistent.apply(new Context(), { backendType: '' })
}).toThrow('backendType must be non-empty')
expect(() => {
ToolBashPersistent.apply(new Context(), { timeoutMs: 0 })
}).toThrow('timeoutMs must be a positive safe integer')
expect(() => {
ToolBashPersistent.apply(new Context(), { maxOutputChars: 0 })
}).toThrow('maxOutputChars must be a positive safe integer')
expect(() => {
ToolBashPersistent.apply(new Context(), { description: ' ' })
}).toThrow('description must be non-empty')
})
})

View File

@@ -0,0 +1,16 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": ["src"],
"references": [
{ "path": "../../../vendor/cordis" },
{ "path": "../../core/agent" },
{ "path": "../../core/tools" },
{ "path": "../../terminal/terminal" },
{ "path": "../../runtime-diagnostics/invariants" },
{ "path": "../../util/timeout" }
]
}