Apply the accepted pre-release package, service, type, directory, and role renames as one repository-wide change.
573 lines
22 KiB
TypeScript
573 lines
22 KiB
TypeScript
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')
|
|
})
|
|
})
|