refactor(pty): rename model-facing tools to terminal_* and harden teardown
Rename the six model-facing tools pty_* -> terminal_* and align every description, guidance section, ACP card title, and rendered result to terminal terminology. Package and service internals keep their technical PTY names (PtyService, "unknown PTY session", node-pty). Harden the local backend teardown: - a failed close is retryable: drop the memoized rejection so a later terminal_close re-runs against the live process table - service disposal clears the backend, reservation, and owner-cleanup registries even when a close fails - stop readiness polling before teardown so an in-flight send settles as session_exit instead of a mis-inferred wait reason - bound the sanitizer's pending buffer against unterminated escape runs Update the tool catalog, package READMEs, the bilingual Agent Note, and the acp/headless pty-tools snapshots to match.
This commit is contained in:
@@ -23,7 +23,7 @@ describe('gen-tool-catalog collectToolCatalog', () => {
|
||||
it('boots every shipped tool package and harvests its model-facing schemas', async () => {
|
||||
const catalog = await collectToolCatalog()
|
||||
const names = catalog.flatMap(entry => entry.schemas.map(s => s.name)).sort()
|
||||
expect(names).toEqual(['ask_user_question', 'bash', 'cordis_inspect', 'cordis_mount', 'cordis_unmount', 'edit', 'glob', 'grep', 'pty_kill', 'pty_list', 'pty_read', 'pty_send', 'pty_signal', 'pty_spawn', 'read', 'run_code', 'skill', 'subagent', 'task_kill', 'task_list', 'task_output', 'todo_write', 'web_fetch', 'web_search', 'workflow', 'write'])
|
||||
expect(names).toEqual(['ask_user_question', 'bash', 'cordis_inspect', 'cordis_mount', 'cordis_unmount', 'edit', 'glob', 'grep', 'read', 'run_code', 'skill', 'subagent', 'task_kill', 'task_list', 'task_output', 'terminal_close', 'terminal_list', 'terminal_open', 'terminal_read', 'terminal_send', 'terminal_signal', 'todo_write', 'web_fetch', 'web_search', 'workflow', 'write'])
|
||||
// Every tool carries a JSON-Schema `parameters` object (what the model sees).
|
||||
for (const entry of catalog) {
|
||||
for (const schema of entry.schemas) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# pty/ — persistent PTY capability family
|
||||
|
||||
Persistent, owner-scoped terminal sessions for workflows that require state across tool calls or interactive stdin. PTY complements the one-shot bash and filesystem tools; it does not replace their stronger per-operation contracts.
|
||||
`PTY` stands for **Pseudo-Terminal**(伪终端). This capability provides persistent, owner-scoped terminal sessions for workflows that require state across tool calls or interactive stdin. PTY complements the one-shot bash and filesystem tools; it does not replace their stronger per-operation contracts.
|
||||
|
||||
| Package | Role | ctx key |
|
||||
|---|---|---|
|
||||
|
||||
@@ -6,7 +6,7 @@ Local `node-pty` backend for `ctx.pty`. It starts an interactive shell under the
|
||||
|
||||
The plugin injects `pty`, `sandbox`, and `sandboxPolicy`, then registers the configured backend type (`shell`). `danger-full-access` starts the shell directly; confined modes wrap the exact shell argv through `ctx.sandbox`. The current session-level sandbox override is resolved at spawn and remains fixed for the PTY lifetime.
|
||||
|
||||
Linux readiness combines a private bash prompt marker, foreground-process-group syscall inspection, silence fallback, and absolute timeout. macOS uses the prompt marker plus silence/timeout because it has no `/proc` syscall surface. Unrecognized or unreadable process state is never a positive exact-idle signal. During unpublished startup, a fallback requires observed output; zero-output silence cannot publish an empty session, and timeout rejects the spawn.
|
||||
Linux readiness combines a foreground-verified private bash prompt marker, foreground-process-group syscall inspection, silence fallback, and absolute timeout. macOS uses the verified prompt marker plus silence/timeout because it has no `/proc` syscall surface. Unrecognized or unreadable process state is never a positive exact-idle signal. During unpublished startup, a fallback requires observed output; zero-output silence cannot publish an empty session, and timeout rejects the spawn. Incomplete terminal-control sequences are bounded by `maxReadBytes` and discarded through their terminator after crossing that limit.
|
||||
|
||||
## Model Experience
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
/** Streaming terminal-control sanitizer for the line-oriented first release. */
|
||||
|
||||
import { Buffer } from 'node:buffer'
|
||||
|
||||
/** OSC marker emitted by the controlled bash before each prompt. */
|
||||
export const PROMPT_MARKER_PREFIX = '133;D;'
|
||||
|
||||
@@ -16,6 +18,10 @@ export interface SanitizedChunk {
|
||||
*/
|
||||
export class TerminalSanitizer {
|
||||
private pending = ''
|
||||
private discardMode: 'osc' | 'csi' | undefined
|
||||
private discardOscEscape = false
|
||||
|
||||
constructor(private readonly maxPendingBytes: number) {}
|
||||
|
||||
/**
|
||||
* Consume one decoded `node-pty` data chunk.
|
||||
@@ -23,7 +29,7 @@ export class TerminalSanitizer {
|
||||
* @returns Printable text and whether the private prompt marker completed.
|
||||
*/
|
||||
push(chunk: string): SanitizedChunk {
|
||||
this.pending += chunk
|
||||
this.pending += this.discardPrefix(chunk)
|
||||
let text = ''
|
||||
let prompt = false
|
||||
let index = 0
|
||||
@@ -75,6 +81,7 @@ export class TerminalSanitizer {
|
||||
index = escape + 2
|
||||
}
|
||||
this.pending = this.pending.slice(index)
|
||||
this.enforcePendingBound()
|
||||
return { text: normalizeTerminalText(text), prompt }
|
||||
}
|
||||
|
||||
@@ -85,8 +92,54 @@ export class TerminalSanitizer {
|
||||
flush(): string {
|
||||
const text = this.pending.startsWith('\x1b') ? '' : this.pending
|
||||
this.pending = ''
|
||||
this.discardMode = undefined
|
||||
this.discardOscEscape = false
|
||||
return normalizeTerminalText(text)
|
||||
}
|
||||
|
||||
private enforcePendingBound(): void {
|
||||
if (Buffer.byteLength(this.pending) <= this.maxPendingBytes) return
|
||||
this.discardMode = this.pending[1] === ']' ? 'osc' : 'csi'
|
||||
this.pending = ''
|
||||
}
|
||||
|
||||
private discardPrefix(chunk: string): string {
|
||||
if (this.discardMode === undefined) return chunk
|
||||
if (this.discardMode === 'csi') {
|
||||
for (let index = 0; index < chunk.length; index += 1) {
|
||||
const code = chunk.charCodeAt(index)
|
||||
if (code >= 0x40 && code <= 0x7e) {
|
||||
this.discardMode = undefined
|
||||
return chunk.slice(index + 1)
|
||||
}
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
let index = 0
|
||||
if (this.discardOscEscape) {
|
||||
this.discardOscEscape = false
|
||||
if (chunk.startsWith('\\')) {
|
||||
this.discardMode = undefined
|
||||
return chunk.slice(1)
|
||||
}
|
||||
}
|
||||
while (index < chunk.length) {
|
||||
if (chunk[index] === '\x07') {
|
||||
this.discardMode = undefined
|
||||
return chunk.slice(index + 1)
|
||||
}
|
||||
if (chunk[index] === '\x1b') {
|
||||
if (chunk[index + 1] === '\\') {
|
||||
this.discardMode = undefined
|
||||
return chunk.slice(index + 2)
|
||||
}
|
||||
if (index + 1 === chunk.length) this.discardOscEscape = true
|
||||
}
|
||||
index += 1
|
||||
}
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -138,7 +138,7 @@ function signalName(number: number | undefined): NodeJS.Signals | null {
|
||||
export class LocalPtySession implements PtyBackendSession {
|
||||
motd = ''
|
||||
readonly pid: number
|
||||
private readonly sanitizer = new TerminalSanitizer()
|
||||
private readonly sanitizer: TerminalSanitizer
|
||||
private readonly scrollback: BoundedTextBuffer
|
||||
private readonly exitPromise: PromiseWithResolvers<void> = Promise.withResolvers<void>()
|
||||
private readonly dataDisposable: IDisposable
|
||||
@@ -148,6 +148,7 @@ export class LocalPtySession implements PtyBackendSession {
|
||||
private activeTimer: NodeJS.Timeout | undefined
|
||||
private activeAbort: (() => void) | undefined
|
||||
private promptSeen = false
|
||||
private shellPgid: number | undefined
|
||||
private initializing = false
|
||||
private lastOutputAt = Date.now()
|
||||
private closePromise: Promise<void> | undefined
|
||||
@@ -158,6 +159,7 @@ export class LocalPtySession implements PtyBackendSession {
|
||||
private readonly config: ResolvedConfig,
|
||||
) {
|
||||
this.pid = terminal.pid
|
||||
this.sanitizer = new TerminalSanitizer(config.maxReadBytes)
|
||||
this.scrollback = new BoundedTextBuffer(config.scrollbackMaxBytes, config.scrollbackLines)
|
||||
this.dataDisposable = terminal.onData((data) => { this.onData(data) })
|
||||
this.exitDisposable = terminal.onExit(({ exitCode, signal }) => {
|
||||
@@ -253,7 +255,7 @@ export class LocalPtySession implements PtyBackendSession {
|
||||
const pgid = this.inspector.foregroundPgid(this.pid)
|
||||
if (pgid === undefined) throw new Error(`cannot resolve foreground process group for PTY ${this.pid}`)
|
||||
if (signal === 'SIGKILL' && pgid === this.pid) {
|
||||
throw new Error('refusing to SIGKILL the PTY shell; use pty_kill')
|
||||
throw new Error('refusing to SIGKILL the PTY shell; use terminal_close')
|
||||
}
|
||||
this.inspector.signalGroup(pgid, signal)
|
||||
return { delivered: true, targetPgid: pgid }
|
||||
@@ -273,8 +275,12 @@ export class LocalPtySession implements PtyBackendSession {
|
||||
const sanitized = this.sanitizer.push(data)
|
||||
this.appendOutput(sanitized.text)
|
||||
if (sanitized.prompt) {
|
||||
this.promptSeen = true
|
||||
this.lastOutputAt = Date.now()
|
||||
const foregroundPgid = this.inspector.foregroundPgid(this.pid)
|
||||
if (this.shellPgid === undefined) this.shellPgid = foregroundPgid
|
||||
if (foregroundPgid !== undefined && foregroundPgid === this.shellPgid) {
|
||||
this.promptSeen = true
|
||||
this.lastOutputAt = Date.now()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -319,9 +325,13 @@ export class LocalPtySession implements PtyBackendSession {
|
||||
operation.settle(waitReason, this.statusValue, scrollbackTruncated)
|
||||
}
|
||||
|
||||
private clearActive(): void {
|
||||
private stopPolling(): void {
|
||||
if (this.activeTimer !== undefined) clearInterval(this.activeTimer)
|
||||
this.activeTimer = undefined
|
||||
}
|
||||
|
||||
private clearActive(): void {
|
||||
this.stopPolling()
|
||||
this.activeAbort?.()
|
||||
this.activeAbort = undefined
|
||||
this.active = undefined
|
||||
@@ -329,6 +339,10 @@ export class LocalPtySession implements PtyBackendSession {
|
||||
|
||||
private async closeOnce(reason: string): Promise<void> {
|
||||
this.dataDisposable.dispose()
|
||||
// Stop readiness polling but retain the active operation: teardown settles
|
||||
// it as session_exit below, so an in-flight send is never mis-settled as
|
||||
// stdin_read/inferred_idle/timeout during the grace period.
|
||||
this.stopPolling()
|
||||
const members = this.inspector.processTree(this.pid)
|
||||
for (const member of members) {
|
||||
try {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { normalizeTerminalText, TerminalSanitizer } from '@deepseek-ai/dsh-pty-l
|
||||
|
||||
describe('TerminalSanitizer', () => {
|
||||
it('removes split CSI and owned OSC prompt markers', () => {
|
||||
const sanitizer = new TerminalSanitizer()
|
||||
const sanitizer = new TerminalSanitizer(64)
|
||||
expect(sanitizer.push('red\x1b[3')).toEqual({ text: 'red', prompt: false })
|
||||
expect(sanitizer.push('1m text\x1b[0m\r\n')).toEqual({ text: ' text\n', prompt: false })
|
||||
expect(sanitizer.push('\x1b]133;')).toEqual({ text: '', prompt: false })
|
||||
@@ -11,7 +11,7 @@ describe('TerminalSanitizer', () => {
|
||||
})
|
||||
|
||||
it('drops unrelated OSC, short escapes, BEL, and incomplete trailing escape', () => {
|
||||
const sanitizer = new TerminalSanitizer()
|
||||
const sanitizer = new TerminalSanitizer(64)
|
||||
expect(sanitizer.push('a\x1b]0;title\x1b\\b\x1b7c\x07')).toEqual({ text: 'abc', prompt: false })
|
||||
expect(sanitizer.push('tail\x1b')).toEqual({ text: 'tail', prompt: false })
|
||||
expect(sanitizer.flush()).toBe('')
|
||||
@@ -24,4 +24,39 @@ describe('TerminalSanitizer', () => {
|
||||
it('normalizes CRLF and standalone carriage returns', () => {
|
||||
expect(normalizeTerminalText('a\r\nb\rc\x07')).toBe('a\nb\nc')
|
||||
})
|
||||
|
||||
it('bounds and discards unterminated control sequences through their terminators', () => {
|
||||
const oscBel = new TerminalSanitizer(8)
|
||||
expect(oscBel.push(`\x1b]0;${'x'.repeat(16)}`)).toEqual({ text: '', prompt: false })
|
||||
expect(oscBel.push('more\x07tail')).toEqual({ text: 'tail', prompt: false })
|
||||
|
||||
const oscSt = new TerminalSanitizer(8)
|
||||
oscSt.push(`\x1b]0;${'x'.repeat(16)}`)
|
||||
expect(oscSt.push('more\x1b')).toEqual({ text: '', prompt: false })
|
||||
expect(oscSt.push('\\tail')).toEqual({ text: 'tail', prompt: false })
|
||||
|
||||
const oscDirectSt = new TerminalSanitizer(8)
|
||||
oscDirectSt.push(`\x1b]0;${'x'.repeat(16)}`)
|
||||
expect(oscDirectSt.push('more\x1b\\tail')).toEqual({ text: 'tail', prompt: false })
|
||||
|
||||
const oscFalseSt = new TerminalSanitizer(8)
|
||||
oscFalseSt.push(`\x1b]0;${'x'.repeat(16)}`)
|
||||
oscFalseSt.push('\x1b')
|
||||
expect(oscFalseSt.push('more')).toEqual({ text: '', prompt: false })
|
||||
expect(oscFalseSt.push('\x07tail')).toEqual({ text: 'tail', prompt: false })
|
||||
|
||||
const oscNonTerminatingEscape = new TerminalSanitizer(8)
|
||||
oscNonTerminatingEscape.push(`\x1b]0;${'x'.repeat(16)}`)
|
||||
expect(oscNonTerminatingEscape.push('more\x1bxmore\x07tail')).toEqual({ text: 'tail', prompt: false })
|
||||
|
||||
const csi = new TerminalSanitizer(8)
|
||||
expect(csi.push(`\x1b[${'1'.repeat(16)}`)).toEqual({ text: '', prompt: false })
|
||||
expect(csi.push('123')).toEqual({ text: '', prompt: false })
|
||||
expect(csi.push('mtext')).toEqual({ text: 'text', prompt: false })
|
||||
|
||||
const flushed = new TerminalSanitizer(8)
|
||||
flushed.push(`\x1b]0;${'x'.repeat(16)}`)
|
||||
expect(flushed.flush()).toBe('')
|
||||
expect(flushed.push('text')).toEqual({ text: 'text', prompt: false })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -124,9 +124,9 @@ describe('LocalPtySession readiness and output', () => {
|
||||
vi.useFakeTimers()
|
||||
const terminal = new FakeTerminal()
|
||||
const inspector = new FakeInspector()
|
||||
inspector.pgid = undefined
|
||||
const session = new LocalPtySession(terminal.asPty(), inspector, config())
|
||||
await initialize(session, terminal)
|
||||
inspector.pgid = undefined
|
||||
|
||||
const inferred = session.startSend({ text: 'sleep', submit: false })
|
||||
terminal.emitData('working')
|
||||
@@ -238,6 +238,27 @@ describe('LocalPtySession readiness and output', () => {
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
await timedOut
|
||||
})
|
||||
|
||||
it('trusts prompt markers only while the startup shell owns the foreground group', async () => {
|
||||
vi.useFakeTimers()
|
||||
const terminal = new FakeTerminal()
|
||||
const inspector = new FakeInspector()
|
||||
const session = new LocalPtySession(terminal.asPty(), inspector, config())
|
||||
await initialize(session, terminal)
|
||||
|
||||
const operation = session.startSend({ text: 'run', submit: true })
|
||||
let settled = false
|
||||
void operation.done.then(() => { settled = true })
|
||||
inspector.pgid = 789
|
||||
terminal.emitData('\x1b]133;D;0\x07spoofed')
|
||||
await vi.advanceTimersByTimeAsync(10)
|
||||
expect(settled).toBe(false)
|
||||
|
||||
inspector.pgid = 456
|
||||
terminal.emitData('\x1b]133;D;0\x07dsh> ')
|
||||
await vi.advanceTimersByTimeAsync(10)
|
||||
expect((await operation.done).waitReason).toBe('stdin_read')
|
||||
})
|
||||
})
|
||||
|
||||
describe('LocalPtySession bounds, signals, and teardown', () => {
|
||||
@@ -278,7 +299,7 @@ describe('LocalPtySession bounds, signals, and teardown', () => {
|
||||
const session = new LocalPtySession(terminal.asPty(), inspector, config())
|
||||
expect(await session.signal('SIGINT')).toEqual({ delivered: true, targetPgid: 456 })
|
||||
inspector.pgid = terminal.pid
|
||||
await expect(session.signal('SIGKILL')).rejects.toThrow('use pty_kill')
|
||||
await expect(session.signal('SIGKILL')).rejects.toThrow('use terminal_close')
|
||||
inspector.pgid = undefined
|
||||
await expect(session.signal('SIGTERM')).rejects.toThrow('cannot resolve')
|
||||
})
|
||||
@@ -297,6 +318,23 @@ describe('LocalPtySession bounds, signals, and teardown', () => {
|
||||
expect(() => session.startSend({ text: '', submit: false })).toThrow('closing')
|
||||
})
|
||||
|
||||
it('settles an active send as session_exit when closed mid-operation', async () => {
|
||||
vi.useFakeTimers()
|
||||
const terminal = new FakeTerminal()
|
||||
const session = new LocalPtySession(terminal.asPty(), new FakeInspector(), config({ disposeGraceMs: 50 }))
|
||||
await initialize(session, terminal)
|
||||
const operation = session.startSend({ text: 'run', submit: true })
|
||||
// The shell returns to its prompt while the send is active; a running
|
||||
// readiness poll would otherwise mis-settle this as stdin_read once close
|
||||
// begins, so teardown must stop polling before its grace period.
|
||||
terminal.emitData('\x1b]133;D;0\x07dsh> ')
|
||||
terminal.throwKill = true
|
||||
const closing = session.close('mid-send')
|
||||
await vi.advanceTimersByTimeAsync(60)
|
||||
expect((await operation.done).waitReason).toBe('session_exit')
|
||||
await closing
|
||||
})
|
||||
|
||||
it('waits for SIGKILL recipients to leave the process table after the shell exits', async () => {
|
||||
vi.useFakeTimers()
|
||||
const terminal = new FakeTerminal()
|
||||
|
||||
@@ -331,12 +331,18 @@ export class PtyService extends Service {
|
||||
private async disposeAll(): Promise<void> {
|
||||
this.disposing = true
|
||||
const records = [...this.sessions.values()]
|
||||
await this.closeRecords(records, 'PTY service disposed')
|
||||
this.backends.clear()
|
||||
this.reservedNames.clear()
|
||||
const cleanups = [...this.ownerCleanups.values()]
|
||||
this.ownerCleanups.clear()
|
||||
await Promise.all(cleanups.map(cleanup => Promise.resolve(cleanup())))
|
||||
// Teardown is best-effort: a close failure still clears registries and runs
|
||||
// owner cleanups before the aggregated error propagates, so one stuck
|
||||
// session cannot orphan backends, reservations, or owner detachers.
|
||||
try {
|
||||
await this.closeRecords(records, 'PTY service disposed')
|
||||
} finally {
|
||||
this.backends.clear()
|
||||
this.reservedNames.clear()
|
||||
const cleanups = [...this.ownerCleanups.values()]
|
||||
this.ownerCleanups.clear()
|
||||
await Promise.all(cleanups.map(cleanup => Promise.resolve(cleanup())))
|
||||
}
|
||||
}
|
||||
|
||||
private async closeRecords(records: SessionRecord[], reason: string): Promise<void> {
|
||||
|
||||
@@ -127,7 +127,7 @@ export interface PtySessionSnapshot {
|
||||
|
||||
/** Backend-owned live session retained by {@link PtyService}. */
|
||||
export interface PtyBackendSession {
|
||||
/** Initial bounded terminal output returned from `pty_spawn`. */
|
||||
/** Initial bounded terminal output returned from `terminal_open`. */
|
||||
readonly motd: string
|
||||
/** Top-level process id when one exists. */
|
||||
readonly pid?: number
|
||||
|
||||
@@ -343,4 +343,25 @@ describe('PtyService ownership and lifecycle', () => {
|
||||
await disposePtyService(ctx)
|
||||
await expect(service.spawn(owner, { type: 'stub' })).rejects.toMatchObject({ code: 'SERVICE_DISPOSING' })
|
||||
})
|
||||
|
||||
it('clears registries and runs owner cleanups even when a session close fails', async () => {
|
||||
const ctx = await harness()
|
||||
const service = ctx.pty
|
||||
const b = backend()
|
||||
service.registerBackend(b.provider)
|
||||
const owner = stubAgent(ctx, 'owner')
|
||||
ctx.agents.register(owner)
|
||||
await service.spawn(owner, { type: 'stub' })
|
||||
b.sessions[0]!.rejectClose = true
|
||||
const internal = service as unknown as {
|
||||
disposeAll(): Promise<void>
|
||||
backends: Map<string, unknown>
|
||||
ownerCleanups: Map<Agent, unknown>
|
||||
}
|
||||
// Teardown surfaces the close failure, but its finally still clears the
|
||||
// backend and owner-cleanup registries instead of orphaning them.
|
||||
await expect(internal.disposeAll()).rejects.toThrow('failed to close 1 PTY session')
|
||||
expect(internal.backends.size).toBe(0)
|
||||
expect(internal.ownerCleanups.size).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# @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.
|
||||
Six model-facing tools over `ctx.pty`: `terminal_open`, `terminal_send`, `terminal_read`, `terminal_signal`, `terminal_close`, and `terminal_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.
|
||||
`terminal_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
|
||||
|
||||
@@ -12,10 +12,10 @@ Six model-facing tools over `ctx.pty`: `pty_spawn`, `pty_send`, `pty_read`, `pty
|
||||
|
||||
The plugin contributes this fixed guidance section:
|
||||
|
||||
##### PTY guidance
|
||||
##### Terminal 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.
|
||||
Use a terminal session only when work needs persistent terminal state or interactive stdin; prefer bash/read/write/edit for bounded one-shot operations. Track every terminal session id and close sessions that no longer matter. An inferred_idle or timeout result does not prove the foreground command exited.
|
||||
```
|
||||
|
||||
#### Token effect
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Six model-facing persistent PTY tools. Owner identity comes from the exact
|
||||
* Six model-facing persistent terminal tools. Owner identity comes from the exact
|
||||
* tool execution Agent; generic `ctx.tasks` owns background ids and collection.
|
||||
* @module @deepseek-ai/dsh-tool-pty
|
||||
*/
|
||||
@@ -51,7 +51,7 @@ interface SignalArgs extends SessionArgs {
|
||||
}
|
||||
|
||||
function requireAgent(agent: Agent | undefined): Agent {
|
||||
if (agent === undefined) throw new Error('PTY tools require an initiating agent')
|
||||
if (agent === undefined) throw new Error('terminal tools require an initiating agent')
|
||||
return agent
|
||||
}
|
||||
|
||||
@@ -78,19 +78,19 @@ function sendDetail(result: PtySendResult): string {
|
||||
: `session exited: ${result.sessionStatus.exitCode ?? result.sessionStatus.signal ?? 'unknown'}`
|
||||
}
|
||||
|
||||
/** Register all PTY tools and the minimal usage guidance. */
|
||||
/** Register all terminal 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.',
|
||||
text: 'Use a terminal session only when work needs persistent terminal state or interactive stdin; prefer bash/read/write/edit for bounded one-shot operations. Track every terminal session id and close 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.',
|
||||
name: 'terminal_open',
|
||||
description: 'Create a persistent, owner-isolated terminal 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".' },
|
||||
type: { type: 'string', required: true, description: 'Registered terminal 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.' },
|
||||
},
|
||||
@@ -105,15 +105,15 @@ export function apply(ctx: Context): void {
|
||||
},
|
||||
presentCall: (args) => {
|
||||
const parsed = args
|
||||
return { card: 'generic', title: `Start PTY ${parsed.name ?? parsed.type}`, kind: 'execute' }
|
||||
return { card: 'generic', title: `Open terminal ${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.',
|
||||
name: 'terminal_send',
|
||||
description: 'Send text to a persistent terminal. 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.' },
|
||||
sessionId: { type: 'string', required: true, description: 'Terminal session id returned by terminal_open or terminal_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.' },
|
||||
@@ -124,8 +124,8 @@ export function apply(ctx: Context): void {
|
||||
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')
|
||||
if (tasks === undefined) throw new Error('background terminal sends require @deepseek-ai/dsh-tasks and @deepseek-ai/dsh-tool-tasks')
|
||||
if (exec.signal?.aborted === true) throw new Error('terminal send aborted')
|
||||
let cancelRequested = false
|
||||
const taskId = tasks.start({
|
||||
kind: 'pty-send',
|
||||
@@ -150,15 +150,15 @@ export function apply(ctx: Context): void {
|
||||
}
|
||||
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')
|
||||
if (exec.signal?.aborted === true) throw new Error('terminal 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: 'generic', title: `Send to terminal ${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}` }
|
||||
return { card: 'terminal', title: parsed.text || '(send input)', description: `Terminal ${parsed.sessionId as string}` }
|
||||
},
|
||||
presentResult(args, result) {
|
||||
if ((args as Partial<SendArgs>).run_in_background === true || result.isError) return undefined
|
||||
@@ -168,10 +168,10 @@ export function apply(ctx: Context): void {
|
||||
}))
|
||||
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'pty_read',
|
||||
description: 'Read a bounded page of retained output from a persistent PTY without sending input.',
|
||||
name: 'terminal_read',
|
||||
description: 'Read a bounded page of retained output from a persistent terminal without sending input.',
|
||||
parameters: {
|
||||
sessionId: { type: 'string', required: true, description: 'PTY session id.' },
|
||||
sessionId: { type: 'string', required: true, description: 'Terminal session id.' },
|
||||
offset: { type: 'number', description: 'Newest-relative line offset (default 0).' },
|
||||
count: { type: 'number', description: 'Requested line count (default 500; backend caps apply).' },
|
||||
},
|
||||
@@ -182,44 +182,44 @@ export function apply(ctx: Context): void {
|
||||
})
|
||||
return Promise.resolve(textResult(renderRead(result)))
|
||||
},
|
||||
presentCall: args => ({ card: 'generic', title: `Read PTY ${(args).sessionId}`, kind: 'read', rawInput: args }),
|
||||
presentCall: args => ({ card: 'generic', title: `Read terminal ${(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.',
|
||||
name: 'terminal_signal',
|
||||
description: 'Send an allowed signal to the current foreground process group of a persistent terminal.',
|
||||
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.' },
|
||||
sessionId: { type: 'string', required: true, description: 'Terminal session id.' },
|
||||
signal: { type: 'string', required: true, enum: ['SIGINT', 'SIGTERM', 'SIGKILL', 'SIGTSTP', 'SIGHUP'], description: 'Signal to deliver. Shell-targeted SIGKILL is rejected; use terminal_close.' },
|
||||
},
|
||||
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 }),
|
||||
presentCall: args => ({ card: 'generic', title: `Signal terminal ${(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.',
|
||||
name: 'terminal_close',
|
||||
description: 'Close one persistent terminal and wait until its captured owned process tree is gone.',
|
||||
parameters: {
|
||||
sessionId: { type: 'string', required: true, description: 'PTY session id.' },
|
||||
sessionId: { type: 'string', required: true, description: 'Terminal 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`)
|
||||
const closed = await ctx.pty.kill(requireAgent(exec.agent), id)
|
||||
return textResult(closed ? `closed terminal session ${id}` : `terminal session ${id} was already closing`)
|
||||
},
|
||||
presentCall: args => ({ card: 'generic', title: `Kill PTY ${(args).sessionId}`, kind: 'delete' }),
|
||||
presentCall: args => ({ card: 'generic', title: `Close terminal ${(args).sessionId}`, kind: 'delete' }),
|
||||
}))
|
||||
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'pty_list',
|
||||
description: 'List persistent PTY sessions owned by the current agent.',
|
||||
name: 'terminal_list',
|
||||
description: 'List persistent terminal 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' }),
|
||||
presentCall: () => ({ card: 'generic', title: 'List terminal sessions', kind: 'read' }),
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/** Model and ACP rendering for persistent PTY tool results. */
|
||||
/** Model and ACP rendering for persistent terminal tool results. */
|
||||
|
||||
import type { PtyReadResult, PtySendRead, PtySendResult, PtySessionSnapshot, PtySpawnResult } from '@deepseek-ai/dsh-pty'
|
||||
|
||||
@@ -9,7 +9,7 @@ import type { PtyReadResult, PtySendRead, PtySendResult, PtySessionSnapshot, Pty
|
||||
*/
|
||||
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)'}`
|
||||
return `started terminal session ${label} [type: ${result.type}]\n${result.motd || '(no startup output)'}`
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -50,7 +50,7 @@ export function renderRead(result: PtyReadResult): string {
|
||||
* @returns One line per session or the empty marker.
|
||||
*/
|
||||
export function renderList(sessions: PtySessionSnapshot[]): string {
|
||||
if (sessions.length === 0) return '(no PTY sessions)'
|
||||
if (sessions.length === 0) return '(no terminal sessions)'
|
||||
return sessions.map((session) => {
|
||||
const name = session.name === undefined ? '' : ` (${session.name})`
|
||||
const pid = session.pid === undefined ? '' : ` pid=${session.pid}`
|
||||
|
||||
@@ -52,7 +52,7 @@ function resultText(result: { content: { type: string; text?: string }[] }): str
|
||||
|
||||
const suite = process.platform === 'linux' || process.platform === 'darwin' ? describe : describe.skip
|
||||
|
||||
suite('PTY real Loader composition through cordis.yml', () => {
|
||||
suite('terminal 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')
|
||||
@@ -103,15 +103,15 @@ suite('PTY real Loader composition through cordis.yml', () => {
|
||||
|
||||
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,
|
||||
callId: CallId('spawn'), name: 'terminal_open', arguments: { type: 'shell', name: 'main', cwd: root }, agent: owner,
|
||||
})
|
||||
expect(resultText(spawn)).toContain('started PTY session pty-1 (main)')
|
||||
expect(resultText(spawn)).toContain('started terminal 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,
|
||||
callId: CallId('state'), name: 'terminal_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,
|
||||
callId: CallId('read'), name: 'terminal_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)
|
||||
|
||||
@@ -5,7 +5,7 @@ import { renderList, renderRead, renderSend, renderSendRead, renderSpawn } from
|
||||
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)')
|
||||
.toBe('started terminal 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)')
|
||||
})
|
||||
@@ -28,7 +28,7 @@ describe('tool-pty rendering', () => {
|
||||
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([])).toBe('(no terminal 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 } },
|
||||
|
||||
@@ -119,41 +119,41 @@ function text(result: { content: { type: string; text?: string }[] }): string {
|
||||
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)
|
||||
expect(['terminal_open', 'terminal_send', 'terminal_read', 'terminal_signal', 'terminal_close', 'terminal_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)
|
||||
const spawned = await call(ctx, 'terminal_open', { type: 'stub', name: 'main' }, agent)
|
||||
expect(text(spawned)).toContain('started terminal session pty-1 (main)')
|
||||
expect(text(await call(ctx, 'terminal_list', {}, agent))).toContain('pty-1 (main) [stub] running pid=42')
|
||||
expect(text(await call(ctx, 'terminal_read', { sessionId: 'pty-1' }, agent))).toContain('history\n[lines: 0-1 of 1]')
|
||||
expect(text(await call(ctx, 'terminal_signal', { sessionId: 'pty-1', signal: 'SIGINT' }, agent))).toBe('delivered SIGINT to foreground process group 10')
|
||||
const sent = await call(ctx, 'terminal_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)')
|
||||
expect(text(await call(ctx, 'terminal_close', { sessionId: 'pty-1' }, agent))).toBe('closed terminal session pty-1')
|
||||
expect(text(await call(ctx, 'terminal_list', {}, agent))).toBe('(no terminal 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((await call(ctx, 'terminal_open', { type: 'stub' })).isError).toBe(true)
|
||||
await call(ctx, 'terminal_open', { type: 'stub' }, agent)
|
||||
const result = await call(ctx, 'terminal_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')
|
||||
expect((await call(ctx, 'terminal_open', { type: '' }, agent)).isError).toBe(true)
|
||||
expect((await call(ctx, 'terminal_send', { sessionId: '', text: 'x' }, agent)).isError).toBe(true)
|
||||
expect((await call(ctx, 'terminal_send', { sessionId: 1, text: 'x' }, agent)).isError).toBe(true)
|
||||
expect((await call(ctx, 'terminal_send', { sessionId: 'pty-1', text: 1 }, agent)).isError).toBe(true)
|
||||
await call(ctx, 'terminal_open', { type: 'stub', name: 'named', cwd: '/tmp' }, agent)
|
||||
expect(text(await call(ctx, 'terminal_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')
|
||||
const definition = ctx.tools.get('terminal_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)' })
|
||||
@@ -164,20 +164,20 @@ describe('tool-pty foreground surface', () => {
|
||||
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' })
|
||||
expect(ctx.tools.get('terminal_open')?.presentCall?.({ type: 'stub' })).toMatchObject({ card: 'generic', title: 'Open terminal stub' })
|
||||
expect(ctx.tools.get('terminal_open')?.presentCall?.({ type: 'stub', name: 'main' })).toMatchObject({ card: 'generic', title: 'Open terminal main' })
|
||||
expect(ctx.tools.get('terminal_read')?.presentCall?.({ sessionId: 'pty-1' })).toMatchObject({ card: 'generic', title: 'Read terminal pty-1' })
|
||||
expect(ctx.tools.get('terminal_signal')?.presentCall?.({ sessionId: 'pty-1', signal: 'SIGINT' })).toMatchObject({ card: 'generic', title: 'Signal terminal pty-1' })
|
||||
expect(ctx.tools.get('terminal_close')?.presentCall?.({ sessionId: 'pty-1' })).toMatchObject({ card: 'generic', title: 'Close terminal pty-1' })
|
||||
expect(ctx.tools.get('terminal_list')?.presentCall?.({})).toMatchObject({ card: 'generic', title: 'List terminal 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')
|
||||
await call(ctx, 'terminal_open', { type: 'stub' }, agent)
|
||||
expect(text(await call(ctx, 'terminal_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]')
|
||||
@@ -185,30 +185,30 @@ describe('tool-pty task integration', () => {
|
||||
|
||||
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)
|
||||
await call(ctx, 'terminal_open', { 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)
|
||||
expect((await callWithSignal(ctx, 'terminal_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, 'terminal_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')
|
||||
expect(text(await call(ctx, 'terminal_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 () => {
|
||||
it('reports foreground cancellation after the terminal operation settles', async () => {
|
||||
const { ctx, agent, stub } = await setup(false)
|
||||
await call(ctx, 'pty_spawn', { type: 'stub' }, agent)
|
||||
await call(ctx, 'terminal_open', { 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)
|
||||
const pending = callWithSignal(ctx, 'terminal_send', { sessionId: 'pty-1', text: 'sleep' }, agent, controller.signal)
|
||||
await Promise.resolve()
|
||||
controller.abort()
|
||||
stub.sessions[0]!.operation?.cancel()
|
||||
@@ -217,20 +217,20 @@ describe('tool-pty task integration', () => {
|
||||
|
||||
it('renders the already-closing kill result', async () => {
|
||||
const { ctx, agent, stub } = await setup(false)
|
||||
await call(ctx, 'pty_spawn', { type: 'stub' }, agent)
|
||||
await call(ctx, 'terminal_open', { 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)
|
||||
const second = call(ctx, 'terminal_close', { sessionId: 'pty-1' }, agent)
|
||||
stub.sessions[0]!.closeGate?.resolve(undefined)
|
||||
await first
|
||||
expect(text(await second)).toBe('PTY session pty-1 was already closing')
|
||||
expect(text(await second)).toBe('terminal 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)
|
||||
await call(ctx, 'terminal_open', { 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)
|
||||
await call(ctx, 'terminal_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')
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user