feat: add persistent PTY sessions

This commit is contained in:
NI0317
2026-07-21 16:01:00 +08:00
parent b4750f6333
commit 58cde5103a
71 changed files with 5677 additions and 82 deletions

View File

@@ -0,0 +1,60 @@
# @deepseek-ai/dsh-tool-pty
Six model-facing tools over `ctx.pty`: `pty_spawn`, `pty_send`, `pty_read`, `pty_signal`, `pty_kill`, and `pty_list`. Every operation requires the exact initiating `Agent`, so a model cannot address another agent's terminal even if it learns the id.
`pty_send(run_in_background: true)` reuses `ctx.tasks`; task preflight occurs before any terminal write, completion is collected with `task_output`, and `task_kill` requests `Ctrl-C`. Foreground sends use terminal ACP cards; lifecycle, history, signal, and list calls use generic cards.
## Model Experience
### System prompt
#### What the model sees
The plugin contributes this fixed guidance section:
##### PTY guidance
```markdown
Use PTY only when work needs persistent terminal state or interactive stdin; prefer bash/read/write/edit for bounded one-shot operations. Track every PTY session id and kill sessions that no longer matter. An inferred_idle or timeout result does not prove the foreground command exited.
```
#### Token effect
Small fixed input cost on every request while the plugin is active.
#### KV Cache effect
Prefix-stable while the registration scope and guidance text are unchanged.
### Tool schemas
#### What the model sees
The six generated schemas are listed in the [`dsh-tool-pty` catalog section](../../../docs/tool-catalog.md#deepseek-aidsh-tool-pty). Their fixed schema tokens are present whenever this plugin is active; agent-scoped tool filtering may hide them.
#### Token effect
Fixed schema cost on requests where the tools are visible.
#### KV Cache effect
Prefix-stable while tool visibility and definitions are unchanged.
### Tool results and task context
#### What the model sees
Spawn returns the id and bounded MOTD. Send/read return bounded terminal text plus readiness/history markers. Background mode returns a generic task id. Results remain in session history until compaction; incremental task reads do not repeat consumed output.
#### Token effect
Data-dependent and bounded by the backend; each returned result remains in history until compaction.
#### KV Cache effect
Append-only; new results follow the reusable request prefix.
## Known Limitations and Deferred Work
- No named key sequence, TUI, BEL, resize, auto-start, or cross-agent sharing schema is exposed.
- Background mode requires both `@deepseek-ai/dsh-tasks` and its model-facing control surface.

View File

@@ -0,0 +1,49 @@
{
"name": "@deepseek-ai/dsh-tool-pty",
"description": "Six model-facing persistent PTY tools with owner isolation and generic background-task integration",
"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"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.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-llm": "^0.0.1",
"@deepseek-ai/dsh-pty": "^0.0.1",
"@deepseek-ai/dsh-system-prompt": "^0.0.1",
"@deepseek-ai/dsh-tasks": "^0.0.1",
"@deepseek-ai/dsh-tools": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
"@cordisjs/plugin-include": "workspace:^",
"@cordisjs/plugin-loader": "workspace:^",
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-pty": "workspace:^",
"@deepseek-ai/dsh-pty-local": "workspace:^",
"@deepseek-ai/dsh-sandbox": "workspace:^",
"@deepseek-ai/dsh-sandbox-policy": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-tasks": "workspace:^",
"@deepseek-ai/dsh-tool-tasks": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"cordis": "^4.0.0-rc.7"
}
}

View File

@@ -0,0 +1,225 @@
/**
* Six model-facing persistent PTY tools. Owner identity comes from the exact
* tool execution Agent; generic `ctx.tasks` owns background ids and collection.
* @module @deepseek-ai/dsh-tool-pty
*/
import { Context } from 'cordis'
import type { Agent } from '@deepseek-ai/dsh-agent'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import { PtySessionId } from '@deepseek-ai/dsh-pty'
import type { PtySendResult, PtySessionId as PtySessionIdType, PtySignal } from '@deepseek-ai/dsh-pty'
import type {} from '@deepseek-ai/dsh-tasks'
import { defineTool } from '@deepseek-ai/dsh-tools'
import type { ToolExecutionResult, ToolResult } from '@deepseek-ai/dsh-tools'
import { renderList, renderRead, renderSend, renderSendRead, renderSpawn } from './render.ts'
declare module '@deepseek-ai/dsh-tasks' {
interface TaskKindMap {
'pty-send': 'pty-send'
}
}
/** Cordis plugin name. */
export const name = 'tool-pty'
/** Required capability, registry, and prompt services. */
export const inject = ['pty', 'tools', 'systemPrompt']
interface SpawnArgs {
type: string
name?: string
cwd?: string
}
interface SessionArgs {
sessionId: string
}
interface SendArgs extends SessionArgs {
text: string
submit?: boolean
run_in_background?: boolean
}
interface ReadArgs extends SessionArgs {
offset?: number
count?: number
}
interface SignalArgs extends SessionArgs {
signal: PtySignal
}
function requireAgent(agent: Agent | undefined): Agent {
if (agent === undefined) throw new Error('PTY tools require an initiating agent')
return agent
}
function sessionId(args: SessionArgs): PtySessionIdType {
if (args.sessionId.length === 0) {
throw new Error('sessionId must be a non-empty string')
}
return PtySessionId(args.sessionId)
}
function textResult(text: string): ContentBlock[] {
return [{ type: 'text', text }]
}
function rawResultText(result: ToolResult): string | undefined {
if (result.content.length !== 1) return undefined
const block = result.content[0]
return block?.type === 'text' ? block.text : undefined
}
function sendDetail(result: PtySendResult): string {
return result.sessionStatus.kind === 'running'
? `wait: ${result.waitReason}`
: `session exited: ${result.sessionStatus.exitCode ?? result.sessionStatus.signal ?? 'unknown'}`
}
/** Register all PTY tools and the minimal usage guidance. */
export function apply(ctx: Context): void {
ctx.systemPrompt.section({
name: 'tool:pty',
order: 106,
text: 'Use PTY only when work needs persistent terminal state or interactive stdin; prefer bash/read/write/edit for bounded one-shot operations. Track every PTY session id and kill sessions that no longer matter. An inferred_idle or timeout result does not prove the foreground command exited.',
})
ctx.tools.register(defineTool({
name: 'pty_spawn',
description: 'Create a persistent, owner-isolated PTY session from a registered backend type. Use this for shell or REPL state that must survive across tool calls.',
parameters: {
type: { type: 'string', required: true, description: 'Registered PTY backend type, usually "shell".' },
name: { type: 'string', description: 'Optional owner-local display name such as "main" or "gdb".' },
cwd: { type: 'string', description: 'Initial working directory. Defaults to the deployment workspace root.' },
},
async execute(args: SpawnArgs, exec) {
if (args.type.length === 0) throw new Error('type must be a non-empty string')
const result = await ctx.pty.spawn(requireAgent(exec.agent), {
type: args.type,
...args.name !== undefined ? { name: args.name } : {},
...args.cwd !== undefined ? { cwd: args.cwd } : {},
}, exec.signal)
return textResult(renderSpawn(result))
},
presentCall: (args) => {
const parsed = args
return { card: 'generic', title: `Start PTY ${parsed.name ?? parsed.type}`, kind: 'execute' }
},
}))
ctx.tools.register(defineTool({
name: 'pty_send',
description: 'Send text to a persistent PTY. By default Enter is submitted and the call waits for a prompt, stdin wait, output silence, timeout, or session exit. Background mode returns a task id for task_output/task_kill.',
parameters: {
sessionId: { type: 'string', required: true, description: 'PTY session id returned by pty_spawn or pty_list.' },
text: { type: 'string', required: true, description: 'UTF-8 text to write to the terminal.' },
submit: { type: 'boolean', description: 'Submit Enter after text (default true). Set false for control characters or incomplete REPL input.' },
run_in_background: { type: 'boolean', description: 'Return a task id immediately; collect with task_output or stop with task_kill.' },
},
async execute(args: SendArgs, exec): Promise<ToolExecutionResult> {
const owner = requireAgent(exec.agent)
const id = sessionId(args)
const request = { text: args.text, submit: args.submit ?? true }
if (args.run_in_background === true) {
const tasks = ctx.get('tasks')
if (tasks === undefined) throw new Error('background PTY sends require @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks')
if (exec.signal?.aborted === true) throw new Error('PTY send aborted')
let cancelRequested = false
const taskId = tasks.start({
kind: 'pty-send',
label: `${id}: ${args.text || '(input)'}`,
owner,
run: () => {
const operation = ctx.pty.startSend(owner, id, request)
return {
cancel: () => {
cancelRequested = true
operation.cancel()
},
done: operation.done.then(
result => ({ status: cancelRequested ? 'killed' as const : 'completed' as const, detail: sendDetail(result) }),
(error: unknown) => ({ status: 'failed' as const, detail: String(error) }),
),
readOutput: () => renderSendRead(operation.readOutput()),
}
},
})
return { content: textResult(`started background task ${taskId}`), isError: false }
}
const operation = ctx.pty.startSend(owner, id, { ...request, ...exec.signal ? { signal: exec.signal } : {} })
const result = await operation.done
if (exec.signal?.aborted === true) throw new Error('PTY send aborted')
return { content: textResult(renderSend(result)), isError: false, meta: result }
},
presentCall(args) {
const parsed = args as Partial<SendArgs>
if (parsed.run_in_background === true) {
return { card: 'generic', title: `Send PTY ${parsed.sessionId as string} in background`, kind: 'execute', rawInput: parsed.text }
}
return { card: 'terminal', title: parsed.text || '(send input)', description: `PTY ${parsed.sessionId as string}` }
},
presentResult(args, result) {
if ((args as Partial<SendArgs>).run_in_background === true || result.isError) return undefined
const raw = rawResultText(result)
return raw === undefined ? undefined : { card: 'terminal', output: raw }
},
}))
ctx.tools.register(defineTool({
name: 'pty_read',
description: 'Read a bounded page of retained output from a persistent PTY without sending input.',
parameters: {
sessionId: { type: 'string', required: true, description: 'PTY session id.' },
offset: { type: 'number', description: 'Newest-relative line offset (default 0).' },
count: { type: 'number', description: 'Requested line count (default 500; backend caps apply).' },
},
execute(args: ReadArgs, exec) {
const result = ctx.pty.read(requireAgent(exec.agent), sessionId(args), {
...args.offset !== undefined ? { offset: args.offset } : {},
...args.count !== undefined ? { count: args.count } : {},
})
return Promise.resolve(textResult(renderRead(result)))
},
presentCall: args => ({ card: 'generic', title: `Read PTY ${(args).sessionId}`, kind: 'read', rawInput: args }),
}))
ctx.tools.register(defineTool({
name: 'pty_signal',
description: 'Send an allowed signal to the current foreground process group of a persistent PTY.',
parameters: {
sessionId: { type: 'string', required: true, description: 'PTY session id.' },
signal: { type: 'string', required: true, enum: ['SIGINT', 'SIGTERM', 'SIGKILL', 'SIGTSTP', 'SIGHUP'], description: 'Signal to deliver. Shell-targeted SIGKILL is rejected; use pty_kill.' },
},
async execute(args: SignalArgs, exec) {
const result = await ctx.pty.signal(requireAgent(exec.agent), sessionId(args), args.signal)
return textResult(`delivered ${args.signal} to foreground process group ${result.targetPgid}`)
},
presentCall: args => ({ card: 'generic', title: `Signal PTY ${(args as SignalArgs).sessionId}`, kind: 'execute', rawInput: args }),
}))
ctx.tools.register(defineTool({
name: 'pty_kill',
description: 'Close one persistent PTY and wait until its captured owned process tree is gone.',
parameters: {
sessionId: { type: 'string', required: true, description: 'PTY session id.' },
},
async execute(args: SessionArgs, exec) {
const id = sessionId(args)
const killed = await ctx.pty.kill(requireAgent(exec.agent), id)
return textResult(killed ? `killed PTY session ${id}` : `PTY session ${id} was already closing`)
},
presentCall: args => ({ card: 'generic', title: `Kill PTY ${(args).sessionId}`, kind: 'delete' }),
}))
ctx.tools.register(defineTool({
name: 'pty_list',
description: 'List persistent PTY sessions owned by the current agent.',
parameters: {},
execute(_args: Record<string, never>, exec) {
return Promise.resolve(textResult(renderList(ctx.pty.list(requireAgent(exec.agent)))))
},
presentCall: () => ({ card: 'generic', title: 'List PTY sessions', kind: 'read' }),
}))
}

View File

@@ -0,0 +1,62 @@
/** Model and ACP rendering for persistent PTY tool results. */
import type { PtyReadResult, PtySendRead, PtySendResult, PtySessionSnapshot, PtySpawnResult } from '@deepseek-ai/dsh-pty'
/**
* Render one created session and its bounded MOTD.
* @param result - published spawn result.
* @returns Model-facing session acknowledgement.
*/
export function renderSpawn(result: PtySpawnResult): string {
const label = result.name === undefined ? result.sessionId : `${result.sessionId} (${result.name})`
return `started PTY session ${label} [type: ${result.type}]\n${result.motd || '(no startup output)'}`
}
/**
* Render one settled interactive send.
* @param result - settled send outcome.
* @returns Terminal output plus wait/session markers.
*/
export function renderSend(result: PtySendResult): string {
const output = result.viewport || '(no new output)'
const status = result.sessionStatus.kind === 'running'
? 'running'
: `exited code=${result.sessionStatus.exitCode ?? 'null'} signal=${result.sessionStatus.signal ?? 'null'}`
return `${output}\n[wait: ${result.waitReason}]\n[session: ${status}]${result.truncated ? '\n[output truncated]' : ''}`
}
/**
* Render one incremental background operation read.
* @param read - consuming operation delta.
* @returns Delta plus truncation marker when needed.
*/
export function renderSendRead(read: PtySendRead): string {
return `${read.delta}${read.truncated ? `${read.delta.endsWith('\n') || read.delta.length === 0 ? '' : '\n'}[output truncated]` : ''}`
}
/**
* Render one bounded historical page.
* @param result - retained scrollback page.
* @returns Page text plus pagination and truncation markers.
*/
export function renderRead(result: PtyReadResult): string {
const output = result.text || '(no retained output)'
return `${output}\n[lines: ${result.lineBegin}-${result.lineEnd} of ${result.totalLines}]${result.truncated ? '\n[output truncated]' : ''}`
}
/**
* Render owner-visible live sessions.
* @param sessions - fresh owner-scoped snapshots.
* @returns One line per session or the empty marker.
*/
export function renderList(sessions: PtySessionSnapshot[]): string {
if (sessions.length === 0) return '(no PTY sessions)'
return sessions.map((session) => {
const name = session.name === undefined ? '' : ` (${session.name})`
const pid = session.pid === undefined ? '' : ` pid=${session.pid}`
const status = session.status.kind === 'running'
? 'running'
: `exited code=${session.status.exitCode ?? 'null'} signal=${session.status.signal ?? 'null'}`
return `${session.sessionId}${name} [${session.type}] ${status}${pid}`
}).join('\n')
}

View File

@@ -0,0 +1,119 @@
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 'cordis'
import Loader from '@cordisjs/plugin-loader'
import Include from '@cordisjs/plugin-include'
import { CallId } from '@deepseek-ai/dsh-llm'
import { Session, SessionId } from '@deepseek-ai/dsh-session'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import PtyService from '@deepseek-ai/dsh-pty'
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 * as PtyLocal from '@deepseek-ai/dsh-pty-local'
import * as ToolPty from '@deepseek-ai/dsh-tool-pty'
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: [], runnerFailureSignatures: [] }
}
}
function agent(ctx: Context): Agent {
const scope = ctx.plugin(() => {})
const id = SessionId('pty-loader-agent')
const value: Agent = {
id, options: {}, session: new Session(id), status: 'idle', ctx: scope.ctx,
send() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(),
}
ctx.agents.register(value)
return value
}
function resultText(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('PTY real Loader composition through cordis.yml', () => {
it('boots cordis.yml and preserves shell state across real tool calls', async () => {
root = await mkdtemp(join(tmpdir(), 'dsh-pty-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-pty'",
"- 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-pty-local'",
' config:',
' pollIntervalMs: 10',
' exactProbeAfterMs: 20',
' idleSilenceMs: 250',
' timeoutMs: 2000',
' disposeGraceMs: 500',
"- name: '@deepseek-ai/dsh-tool-pty'",
'',
].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', ToolRegistry],
['@deepseek-ai/dsh-pty', PtyService],
['@deepseek-ai/dsh-test-sandbox', PassthroughSandbox],
['@deepseek-ai/dsh-sandbox-policy', SandboxPolicyService],
['@deepseek-ai/dsh-pty-local', PtyLocal],
['@deepseek-ai/dsh-tool-pty', ToolPty],
])
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)
const spawn = await context.tools.execute({
callId: CallId('spawn'), name: 'pty_spawn', arguments: { type: 'shell', name: 'main', cwd: root }, agent: owner,
})
expect(resultText(spawn)).toContain('started PTY session pty-1 (main)')
await context.tools.execute({
callId: CallId('state'), name: 'pty_send', arguments: { sessionId: 'pty-1', text: 'export KEEP=loader; cd /' }, agent: owner,
})
const read = await context.tools.execute({
callId: CallId('read'), name: 'pty_send', arguments: { sessionId: 'pty-1', text: 'printf "cwd=%s keep=%s\\n" "$PWD" "$KEEP"' }, agent: owner,
})
expect(resultText(read)).toContain('cwd=/ keep=loader')
expect(context.pty.list(owner)).toHaveLength(1)
}, 15_000)
})

View File

@@ -0,0 +1,39 @@
import { describe, expect, it } from 'vitest'
import { PtySessionId } from '@deepseek-ai/dsh-pty'
import { renderList, renderRead, renderSend, renderSendRead, renderSpawn } from '@deepseek-ai/dsh-tool-pty/src/render.ts'
describe('tool-pty rendering', () => {
it('renders spawn with and without names or MOTD', () => {
expect(renderSpawn({ sessionId: PtySessionId('pty-1'), type: 'shell', status: { kind: 'running' }, motd: '' }))
.toBe('started PTY session pty-1 [type: shell]\n(no startup output)')
expect(renderSpawn({ sessionId: PtySessionId('pty-2'), name: 'main', type: 'shell', pid: 2, status: { kind: 'running' }, motd: 'ready' }))
.toContain('pty-2 (main)')
})
it('renders running, exited, empty, and truncated sends', () => {
expect(renderSend({ viewport: '', waitReason: 'timeout', sessionStatus: { kind: 'running' }, truncated: true }))
.toBe('(no new output)\n[wait: timeout]\n[session: running]\n[output truncated]')
expect(renderSend({ viewport: 'bye', waitReason: 'session_exit', sessionStatus: { kind: 'exited', exitCode: null, signal: 'SIGTERM' }, truncated: false }))
.toContain('exited code=null signal=SIGTERM')
expect(renderSend({ viewport: 'bye', waitReason: 'session_exit', sessionStatus: { kind: 'exited', exitCode: 2, signal: null }, truncated: false }))
.toContain('exited code=2 signal=null')
expect(renderSend({ viewport: 'bye', waitReason: 'session_exit', sessionStatus: { kind: 'exited', exitCode: null, signal: null }, truncated: false }))
.toContain('exited code=null signal=null')
expect(renderSendRead({ delta: '', truncated: true })).toBe('[output truncated]')
expect(renderSendRead({ delta: 'x', truncated: true })).toBe('x\n[output truncated]')
expect(renderSendRead({ delta: 'x\n', truncated: true })).toBe('x\n[output truncated]')
expect(renderSendRead({ delta: 'x', truncated: false })).toBe('x')
})
it('renders history and every list status shape', () => {
expect(renderRead({ text: '', totalLines: 0, lineBegin: 0, lineEnd: 0, truncated: true }))
.toBe('(no retained output)\n[lines: 0-0 of 0]\n[output truncated]')
expect(renderList([])).toBe('(no PTY sessions)')
expect(renderList([
{ sessionId: PtySessionId('pty-1'), type: 'shell', status: { kind: 'running' } },
{ sessionId: PtySessionId('pty-2'), name: 'done', type: 'shell', pid: 9, status: { kind: 'exited', exitCode: 2, signal: null } },
{ sessionId: PtySessionId('pty-3'), type: 'shell', status: { kind: 'exited', exitCode: null, signal: 'SIGTERM' } },
{ sessionId: PtySessionId('pty-4'), type: 'shell', status: { kind: 'exited', exitCode: null, signal: null } },
])).toBe('pty-1 [shell] running\npty-2 (done) [shell] exited code=2 signal=null pid=9\npty-3 [shell] exited code=null signal=SIGTERM\npty-4 [shell] exited code=null signal=null')
})
})

View File

@@ -0,0 +1,245 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { CallId } from '@deepseek-ai/dsh-llm'
import { Session, SessionId } from '@deepseek-ai/dsh-session'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import PtyService, { PtySessionId } from '@deepseek-ai/dsh-pty'
import type { PtyBackend, PtyBackendSession, PtySendOperation, PtySendRequest, PtySessionStatus, PtySignal } from '@deepseek-ai/dsh-pty'
import TaskService from '@deepseek-ai/dsh-tasks'
import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks'
import * as ToolPty from '@deepseek-ai/dsh-tool-pty'
function fakeAgent(ctx: Context, rawId: string): Agent {
const scope = ctx.plugin(() => {})
const id = SessionId(rawId)
const agent: Agent = {
id, options: {}, session: new Session(id), status: 'idle', ctx: scope.ctx,
send() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(),
}
ctx.agents.register(agent)
return agent
}
class StubSession implements PtyBackendSession {
readonly motd = 'stub prompt'
readonly pid = 42
statusValue: PtySessionStatus = { kind: 'running' }
operation: PtySendOperation | undefined
autoSettle = true
rejectOperation = false
closeGate: PromiseWithResolvers<undefined> | undefined
startSend(_request: PtySendRequest): PtySendOperation {
let settle!: () => void
let reject!: (error: unknown) => void
let cancelled = false
const done = new Promise<void>((resolve, rejectPromise) => { settle = resolve; reject = rejectPromise }).then(() => ({
viewport: cancelled ? '^C' : 'command output',
waitReason: 'stdin_read' as const,
sessionStatus: this.statusValue,
truncated: false,
}))
const operation: PtySendOperation = {
done,
readOutput: () => ({ delta: 'live output', truncated: false }),
cancel: () => {
if (cancelled) return false
cancelled = true
settle()
return true
},
}
this.operation = operation
if (this.rejectOperation) queueMicrotask(() => { reject(new Error('operation failed')) })
else if (this.autoSettle) queueMicrotask(settle)
return operation
}
read() {
return { text: 'history', totalLines: 1, lineBegin: 0, lineEnd: 1, truncated: false }
}
async signal(signal: PtySignal) {
return { delivered: true as const, targetPgid: signal === 'SIGINT' ? 10 : 11 }
}
status() { return this.statusValue }
async close() {
if (this.closeGate !== undefined) await this.closeGate.promise
this.statusValue = { kind: 'exited', exitCode: 0, signal: null }
}
}
function stubBackend() {
const sessions: StubSession[] = []
const backend: PtyBackend = {
type: 'stub',
async spawn() {
const session = new StubSession()
sessions.push(session)
return session
},
}
return { backend, sessions }
}
async function setup(tasks: boolean) {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(PtyService)
const stub = stubBackend()
ctx.pty.registerBackend(stub.backend)
if (tasks) {
await ctx.plugin(TaskService)
await ctx.plugin(ToolTasks)
}
await ctx.plugin(ToolPty)
return { ctx, stub, agent: fakeAgent(ctx, tasks ? 'with-tasks' : 'foreground') }
}
let callNumber = 0
function call(ctx: Context, name: string, args: unknown, agent?: Agent) {
return ctx.tools.execute({ callId: CallId(`pty-call-${++callNumber}`), name, arguments: args, ...agent ? { agent } : {} })
}
function callWithSignal(ctx: Context, name: string, args: unknown, agent: Agent, signal: AbortSignal) {
return ctx.tools.execute({ callId: CallId(`pty-call-${++callNumber}`), name, arguments: args, agent, signal })
}
function text(result: { content: { type: string; text?: string }[] }): string {
return result.content.filter(block => block.type === 'text').map(block => block.text).join('')
}
describe('tool-pty foreground surface', () => {
it('registers exactly six schemas and drives the full owner-scoped lifecycle', async () => {
const { ctx, agent } = await setup(false)
expect(['pty_spawn', 'pty_send', 'pty_read', 'pty_signal', 'pty_kill', 'pty_list'].every(name => ctx.tools.get(name) !== undefined)).toBe(true)
const spawned = await call(ctx, 'pty_spawn', { type: 'stub', name: 'main' }, agent)
expect(text(spawned)).toContain('started PTY session pty-1 (main)')
expect(text(await call(ctx, 'pty_list', {}, agent))).toContain('pty-1 (main) [stub] running pid=42')
expect(text(await call(ctx, 'pty_read', { sessionId: 'pty-1' }, agent))).toContain('history\n[lines: 0-1 of 1]')
expect(text(await call(ctx, 'pty_signal', { sessionId: 'pty-1', signal: 'SIGINT' }, agent))).toBe('delivered SIGINT to foreground process group 10')
const sent = await call(ctx, 'pty_send', { sessionId: 'pty-1', text: 'echo hi' }, agent)
expect(text(sent)).toContain('command output\n[wait: stdin_read]\n[session: running]')
expect(text(await call(ctx, 'pty_kill', { sessionId: 'pty-1' }, agent))).toBe('killed PTY session pty-1')
expect(text(await call(ctx, 'pty_list', {}, agent))).toBe('(no PTY sessions)')
})
it('fails without an initiating agent and rejects background before writing', async () => {
const { ctx, agent, stub } = await setup(false)
expect((await call(ctx, 'pty_spawn', { type: 'stub' })).isError).toBe(true)
await call(ctx, 'pty_spawn', { type: 'stub' }, agent)
const result = await call(ctx, 'pty_send', { sessionId: 'pty-1', text: 'sleep 1', run_in_background: true }, agent)
expect(result.isError).toBe(true)
expect(stub.sessions[0]?.operation).toBeUndefined()
})
it('validates required values and forwards optional spawn/read arguments', async () => {
const { ctx, agent } = await setup(false)
expect((await call(ctx, 'pty_spawn', { type: '' }, agent)).isError).toBe(true)
expect((await call(ctx, 'pty_send', { sessionId: '', text: 'x' }, agent)).isError).toBe(true)
expect((await call(ctx, 'pty_send', { sessionId: 1, text: 'x' }, agent)).isError).toBe(true)
expect((await call(ctx, 'pty_send', { sessionId: 'pty-1', text: 1 }, agent)).isError).toBe(true)
await call(ctx, 'pty_spawn', { type: 'stub', name: 'named', cwd: '/tmp' }, agent)
expect(text(await call(ctx, 'pty_read', { sessionId: 'pty-1', offset: 2, count: 3 }, agent))).toContain('history')
})
it('declares terminal presentation only for foreground sends', async () => {
const { ctx } = await setup(false)
const definition = ctx.tools.get('pty_send')
expect(definition?.presentCall?.({ sessionId: 'pty-1', text: 'python3' })).toMatchObject({ card: 'terminal', title: 'python3' })
expect(definition?.presentCall?.({ sessionId: 'pty-1', text: 'make', run_in_background: true })).toMatchObject({ card: 'generic' })
expect(definition?.presentCall?.({ sessionId: 'pty-1', text: '' })).toMatchObject({ card: 'terminal', title: '(send input)' })
expect(definition?.presentResult?.({ sessionId: 'pty-1', text: 'x', run_in_background: true }, { content: [], isError: false })).toBeUndefined()
expect(definition?.presentResult?.({ sessionId: 'pty-1', text: 'x' }, { content: [], isError: true })).toBeUndefined()
expect(definition?.presentResult?.({ sessionId: 'pty-1', text: 'x' }, { content: [], isError: false })).toBeUndefined()
expect(definition?.presentResult?.({ sessionId: 'pty-1', text: 'x' }, { content: [{ type: 'text', text: 'a' }, { type: 'text', text: 'b' }], isError: false })).toBeUndefined()
expect(definition?.presentResult?.({ sessionId: 'pty-1', text: 'x' }, { content: [undefined as never], isError: false })).toBeUndefined()
expect(definition?.presentResult?.({ sessionId: 'pty-1', text: 'x' }, { content: [{ type: 'text', text: 'ok' }], isError: false })).toEqual({ card: 'terminal', output: 'ok' })
expect(ctx.tools.get('pty_spawn')?.presentCall?.({ type: 'stub' })).toMatchObject({ card: 'generic', title: 'Start PTY stub' })
expect(ctx.tools.get('pty_spawn')?.presentCall?.({ type: 'stub', name: 'main' })).toMatchObject({ card: 'generic', title: 'Start PTY main' })
expect(ctx.tools.get('pty_read')?.presentCall?.({ sessionId: 'pty-1' })).toMatchObject({ card: 'generic', title: 'Read PTY pty-1' })
expect(ctx.tools.get('pty_signal')?.presentCall?.({ sessionId: 'pty-1', signal: 'SIGINT' })).toMatchObject({ card: 'generic', title: 'Signal PTY pty-1' })
expect(ctx.tools.get('pty_kill')?.presentCall?.({ sessionId: 'pty-1' })).toMatchObject({ card: 'generic', title: 'Kill PTY pty-1' })
expect(ctx.tools.get('pty_list')?.presentCall?.({})).toMatchObject({ card: 'generic', title: 'List PTY sessions' })
})
})
describe('tool-pty task integration', () => {
it('registers a generic task and exposes incremental output', async () => {
const { ctx, agent } = await setup(true)
await call(ctx, 'pty_spawn', { type: 'stub' }, agent)
expect(text(await call(ctx, 'pty_send', { sessionId: 'pty-1', text: 'build', run_in_background: true }, agent))).toBe('started background task pty-send-1')
const output = await call(ctx, 'task_output', { task_id: 'pty-send-1', wait: true }, agent)
expect(text(output)).toContain('live output')
expect(text(output)).toContain('[status: completed, wait: stdin_read]')
})
it('rejects pre-aborted background calls, maps task cancellation, and contains operation failure', async () => {
const { ctx, agent, stub } = await setup(true)
await call(ctx, 'pty_spawn', { type: 'stub' }, agent)
const controller = new AbortController()
controller.abort()
expect((await callWithSignal(ctx, 'pty_send', { sessionId: 'pty-1', text: 'x', run_in_background: true }, agent, controller.signal)).isError).toBe(true)
stub.sessions[0]!.autoSettle = false
expect(text(await call(ctx, 'pty_send', { sessionId: 'pty-1', text: '', run_in_background: true }, agent))).toContain('pty-send-1')
expect(text(await call(ctx, 'task_kill', { task_id: 'pty-send-1' }, agent))).toContain('requested cancellation')
await new Promise(resolve => setTimeout(resolve, 0))
expect(text(await call(ctx, 'task_output', { task_id: 'pty-send-1' }, agent))).toContain('[status: killed')
stub.sessions[0]!.rejectOperation = true
stub.sessions[0]!.autoSettle = false
expect(text(await call(ctx, 'pty_send', { sessionId: 'pty-1', text: 'bad', run_in_background: true }, agent))).toContain('pty-send-2')
await new Promise(resolve => setTimeout(resolve, 0))
expect(text(await call(ctx, 'task_output', { task_id: 'pty-send-2' }, agent))).toContain('[status: failed')
})
it('reports foreground cancellation after the PTY operation settles', async () => {
const { ctx, agent, stub } = await setup(false)
await call(ctx, 'pty_spawn', { type: 'stub' }, agent)
stub.sessions[0]!.autoSettle = false
const controller = new AbortController()
const pending = callWithSignal(ctx, 'pty_send', { sessionId: 'pty-1', text: 'sleep' }, agent, controller.signal)
await Promise.resolve()
controller.abort()
stub.sessions[0]!.operation?.cancel()
expect((await pending).isError).toBe(true)
})
it('renders the already-closing kill result', async () => {
const { ctx, agent, stub } = await setup(false)
await call(ctx, 'pty_spawn', { type: 'stub' }, agent)
stub.sessions[0]!.closeGate = Promise.withResolvers<undefined>()
const first = ctx.pty.kill(agent, PtySessionId('pty-1'))
const second = call(ctx, 'pty_kill', { sessionId: 'pty-1' }, agent)
stub.sessions[0]!.closeGate?.resolve(undefined)
await first
expect(text(await second)).toBe('PTY session pty-1 was already closing')
})
it('renders an exited session detail for background completion', async () => {
const { ctx, agent, stub } = await setup(true)
await call(ctx, 'pty_spawn', { type: 'stub' }, agent)
stub.sessions[0]!.statusValue = { kind: 'exited', exitCode: null, signal: null }
await call(ctx, 'pty_send', { sessionId: 'pty-1', text: 'exit', run_in_background: true }, agent)
const output = await call(ctx, 'task_output', { task_id: 'pty-send-1', wait: true }, agent)
expect(text(output)).toContain('session exited: unknown')
})
})
describe('tool-pty plugin shape', () => {
it('is a named function plugin with no default export', () => {
expect('default' in ToolPty).toBe(false)
expect(ToolPty.name).toBe('tool-pty')
expect(ToolPty.inject).toEqual(['pty', 'tools', 'systemPrompt'])
})
})

View File

@@ -0,0 +1,36 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": [
{
"path": "../../../vendor/cosmokit"
},
{
"path": "../../../vendor/cordis"
},
{
"path": "../pty"
},
{
"path": "../../core/agent"
},
{
"path": "../../llm/llm"
},
{
"path": "../../core/system-prompt"
},
{
"path": "../../core/tools"
},
{
"path": "../../tasks/tasks"
}
]
}