Files
deepseek-harness/packages/pty/pty/tests/service.spec.ts
NI0317 a7bfade7eb 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.
2026-07-21 19:29:56 +08:00

368 lines
14 KiB
TypeScript

import { describe, expect, expectTypeOf, it } from 'vitest'
import { Context } from 'cordis'
import { Session, SessionId } from '@deepseek-ai/dsh-session'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import PtyService, { PtyError, PtySessionId } from '@deepseek-ai/dsh-pty'
import type {
PtyBackend,
PtyBackendSession,
PtyReadRequest,
PtySendOperation,
PtySendRequest,
PtySessionId as PtySessionIdType,
PtySessionStatus,
PtySignal,
} from '@deepseek-ai/dsh-pty'
const agentScopeDisposers = new WeakMap<Agent, () => Promise<void>>()
const ptyServiceDisposers = new WeakMap<Context, () => Promise<void>>()
function stubAgent(ctx: Context, rawId: string): Agent {
const id = SessionId(rawId)
const scopeFiber = ctx.plugin(() => {})
const agent: Agent = {
id,
options: {},
session: new Session(id),
status: 'idle',
ctx: scopeFiber.ctx,
send() {},
steer() {},
inject() {},
cancel() {},
whenIdle: () => Promise.resolve(),
}
agentScopeDisposers.set(agent, async () => { await scopeFiber.dispose() })
return agent
}
async function disposeAgentScope(agent: Agent): Promise<void> {
const dispose = agentScopeDisposers.get(agent)
if (dispose === undefined) throw new Error('missing agent scope')
await dispose()
}
class StubSession implements PtyBackendSession {
readonly motd = 'stub ready'
readonly pid = 123
closed: string[] = []
statusValue: PtySessionStatus = { kind: 'running' }
operation: PtySendOperation | undefined
rejectSend = false
rejectClose = false
closeGate: PromiseWithResolvers<undefined> | undefined
startSend(_request: PtySendRequest): PtySendOperation {
if (this.rejectSend) {
return { done: Promise.reject(new Error('send failed')), readOutput: () => ({ delta: '', truncated: false }), cancel: () => false }
}
let settle!: () => void
let settled = false
const done = new Promise<void>((resolve) => { settle = resolve }).then(() => ({
viewport: 'done',
waitReason: 'stdin_read' as const,
sessionStatus: this.statusValue,
truncated: false,
}))
const operation: PtySendOperation = {
done,
readOutput: () => ({ delta: 'delta', truncated: false }),
cancel: () => {
if (settled) return false
settled = true
settle()
return true
},
}
this.operation = operation
return operation
}
read(request: PtyReadRequest) {
return { text: `${request.offset ?? 0}:${request.count ?? 0}`, totalLines: 1, lineBegin: 0, lineEnd: 1, truncated: false }
}
async signal(signal: PtySignal) {
return { delivered: true as const, targetPgid: signal === 'SIGINT' ? 12 : 13 }
}
status(): PtySessionStatus {
return this.statusValue
}
async close(reason: string): Promise<void> {
this.closed.push(reason)
if (this.rejectClose) throw new Error('close failed')
if (this.closeGate !== undefined) await this.closeGate.promise
this.statusValue = { kind: 'exited', exitCode: 0, signal: null }
this.operation?.cancel()
}
}
function backend(type = 'stub') {
const sessions: StubSession[] = []
const provider: PtyBackend = {
type,
async spawn() {
const session = new StubSession()
sessions.push(session)
return session
},
}
return { provider, sessions }
}
async function harness() {
const ctx = new Context()
await ctx.plugin(AgentRegistry)
const fiber = await ctx.plugin(PtyService)
ptyServiceDisposers.set(ctx, async () => { await fiber.dispose() })
return ctx
}
async function disposePtyService(ctx: Context): Promise<void> {
const dispose = ptyServiceDisposers.get(ctx)
if (dispose === undefined) throw new Error('missing PTY service fiber')
await dispose()
}
describe('PtyService backend registry', () => {
it('preserves the id brand and disposes exact backend contributions', async () => {
expectTypeOf(PtySessionId('pty-1')).toEqualTypeOf<PtySessionIdType>()
const ctx = await harness()
const first = backend()
const dispose = ctx.pty.registerBackend(first.provider)
expect(ctx.pty.listBackends()).toEqual(['stub'])
expect(() => ctx.pty.registerBackend(backend().provider)).toThrow(PtyError)
const internal = ctx.pty as unknown as { backends: Map<string, PtyBackend> }
internal.backends.set('stub', backend('replacement').provider)
dispose()
expect(ctx.pty.listBackends()).toEqual(['stub'])
internal.backends.clear()
})
it('rejects empty backend types', async () => {
const ctx = await harness()
expect(() => ctx.pty.registerBackend(backend('').provider)).toThrow('must be non-empty')
})
})
describe('PtyService ownership and lifecycle', () => {
it('publishes only after spawn and fences every operation to the exact owner', async () => {
const ctx = await harness()
const b = backend()
ctx.pty.registerBackend(b.provider)
const owner = stubAgent(ctx, 'owner')
const foreign = stubAgent(ctx, 'foreign')
ctx.agents.register(owner)
ctx.agents.register(foreign)
const created = await ctx.pty.spawn(owner, { type: 'stub', name: 'main', cwd: '/tmp' })
expect(created).toMatchObject({ sessionId: 'pty-1', name: 'main', type: 'stub', pid: 123, motd: 'stub ready', status: { kind: 'running' } })
expect(ctx.pty.list(owner)).toHaveLength(1)
expect(ctx.pty.list(foreign)).toEqual([])
expect(() => ctx.pty.read(foreign, created.sessionId)).toThrow('belongs to another agent')
expect(() => ctx.pty.signal(foreign, created.sessionId, 'SIGINT')).toThrow('belongs to another agent')
await expect(Promise.resolve().then(() => ctx.pty.kill(foreign, created.sessionId))).rejects.toThrow('belongs to another agent')
})
it('rejects unknown backends, non-live owners, duplicate names, and active sends', async () => {
const ctx = await harness()
const owner = stubAgent(ctx, 'owner')
await expect(ctx.pty.spawn(owner, { type: 'missing' })).rejects.toMatchObject({ code: 'OWNER_NOT_LIVE' })
ctx.agents.register(owner)
await expect(ctx.pty.spawn(owner, { type: 'missing' })).rejects.toMatchObject({ code: 'NO_BACKEND' })
const b = backend()
ctx.pty.registerBackend(b.provider)
const created = await ctx.pty.spawn(owner, { type: 'stub', name: 'main' })
await expect(ctx.pty.spawn(owner, { type: 'stub', name: '' })).rejects.toThrow('must be non-empty')
const aborted = new AbortController()
aborted.abort()
await expect(ctx.pty.spawn(owner, { type: 'stub' }, aborted.signal)).rejects.toThrow('spawn aborted')
await expect(ctx.pty.spawn(owner, { type: 'stub', name: 'main' })).rejects.toMatchObject({ code: 'DUPLICATE_NAME' })
const operation = ctx.pty.startSend(owner, created.sessionId, { text: 'echo hi', submit: true })
expect(() => ctx.pty.startSend(owner, created.sessionId, { text: 'pwd', submit: true })).toThrow(PtyError)
expect(operation.readOutput()).toEqual({ delta: 'delta', truncated: false })
expect(operation.cancel()).toBe(true)
await operation.done
const next = ctx.pty.startSend(owner, created.sessionId, { text: 'pwd', submit: true })
next.cancel()
await next.done
b.sessions[0]!.rejectSend = true
await expect(ctx.pty.startSend(owner, created.sessionId, { text: 'bad', submit: true }).done).rejects.toThrow('send failed')
await new Promise(resolve => setTimeout(resolve, 0))
})
it('reserves concurrent names and rolls back a spawn whose owner disappears', async () => {
const ctx = await harness()
const gate = Promise.withResolvers<PtyBackendSession>()
const session = new StubSession()
ctx.pty.registerBackend({ type: 'slow', spawn: () => gate.promise })
const owner = stubAgent(ctx, 'owner')
ctx.agents.register(owner)
const pending = ctx.pty.spawn(owner, { type: 'slow', name: 'main' })
await expect(ctx.pty.spawn(owner, { type: 'slow', name: 'main' })).rejects.toMatchObject({ code: 'DUPLICATE_NAME' })
await disposeAgentScope(owner)
gate.resolve(session)
await expect(pending).rejects.toMatchObject({ code: 'OWNER_NOT_LIVE' })
expect(session.closed).toEqual(['PTY spawn rolled back'])
})
it('keeps independent reservations and handles provider failure before publication', async () => {
const ctx = await harness()
const firstGate = Promise.withResolvers<PtyBackendSession>()
const secondGate = Promise.withResolvers<PtyBackendSession>()
let count = 0
ctx.pty.registerBackend({
type: 'slow',
spawn: () => ++count === 1 ? firstGate.promise : secondGate.promise,
})
const owner = stubAgent(ctx, 'owner')
ctx.agents.register(owner)
const first = ctx.pty.spawn(owner, { type: 'slow', name: 'one' })
const second = ctx.pty.spawn(owner, { type: 'slow', name: 'two' })
firstGate.resolve(new StubSession())
await first
secondGate.resolve(new StubSession())
await second
ctx.pty.registerBackend({ type: 'throwing', spawn: () => Promise.reject(new Error('provider failed')) })
await expect(ctx.pty.spawn(owner, { type: 'throwing' })).rejects.toThrow('provider failed')
const controller = new AbortController()
const b = backend('signaled')
ctx.pty.registerBackend(b.provider)
await ctx.pty.spawn(owner, { type: 'signaled' }, controller.signal)
})
it('omits optional pid metadata when a backend has no process id', async () => {
const ctx = await harness()
const owner = stubAgent(ctx, 'owner')
ctx.agents.register(owner)
const session = new StubSession()
Object.defineProperty(session, 'pid', { value: undefined })
ctx.pty.registerBackend({ type: 'virtual', spawn: () => Promise.resolve(session) })
expect(await ctx.pty.spawn(owner, { type: 'virtual' })).not.toHaveProperty('pid')
})
it('reports rollback and close failures without publishing false success', async () => {
const ctx = await harness()
const owner = stubAgent(ctx, 'owner')
ctx.agents.register(owner)
const failedSpawn = new StubSession()
failedSpawn.rejectClose = true
ctx.pty.registerBackend({
type: 'bad-spawn',
async spawn() {
await disposeAgentScope(owner)
return failedSpawn
},
})
await expect(ctx.pty.spawn(owner, { type: 'bad-spawn' })).rejects.toThrow('spawn and rollback both failed')
const nextOwner = stubAgent(ctx, 'next')
ctx.agents.register(nextOwner)
const b = backend('bad-close')
ctx.pty.registerBackend(b.provider)
const created = await ctx.pty.spawn(nextOwner, { type: 'bad-close' })
b.sessions[0]!.rejectClose = true
await expect(ctx.pty.kill(nextOwner, created.sessionId)).rejects.toThrow('close failed')
expect(ctx.pty.list(nextOwner)).toHaveLength(1)
})
it('joins an already-running close and refuses new sends while closing', async () => {
const ctx = await harness()
const owner = stubAgent(ctx, 'owner')
ctx.agents.register(owner)
const b = backend()
ctx.pty.registerBackend(b.provider)
const created = await ctx.pty.spawn(owner, { type: 'stub' })
b.sessions[0]!.closeGate = Promise.withResolvers<undefined>()
const first = ctx.pty.kill(owner, created.sessionId)
expect(() => ctx.pty.startSend(owner, created.sessionId, { text: '', submit: false })).toThrow('closing')
const second = ctx.pty.kill(owner, created.sessionId)
b.sessions[0]!.closeGate?.resolve(undefined)
expect(await first).toBe(true)
expect(await second).toBe(false)
expect(() => ctx.pty.read(owner, created.sessionId)).toThrow('unknown PTY')
})
it('awaits owner cleanup and removes sessions while backend registration may reload', async () => {
const ctx = await harness()
const b = backend()
const disposeBackend = ctx.pty.registerBackend(b.provider)
const owner = stubAgent(ctx, 'owner')
ctx.agents.register(owner)
const created = await ctx.pty.spawn(owner, { type: 'stub' })
disposeBackend()
expect(ctx.pty.listBackends()).toEqual([])
expect(ctx.pty.read(owner, created.sessionId).text).toBe('0:0')
await disposeAgentScope(owner)
expect(b.sessions[0]?.closed).toEqual(['PTY owner disposed'])
expect(ctx.pty.list(owner)).toEqual([])
})
it('kills idempotently and service disposal closes all owners', async () => {
const ctx = await harness()
const b = backend()
ctx.pty.registerBackend(b.provider)
const first = stubAgent(ctx, 'first')
const second = stubAgent(ctx, 'second')
ctx.agents.register(first)
ctx.agents.register(second)
const a = await ctx.pty.spawn(first, { type: 'stub' })
await ctx.pty.spawn(second, { type: 'stub' })
expect(await ctx.pty.kill(first, a.sessionId)).toBe(true)
expect(b.sessions[0]?.closed).toEqual(['model request'])
const service = ctx.pty
await disposePtyService(ctx)
expect(b.sessions[1]?.closed).toEqual(['PTY service disposed'])
await expect(service.spawn(first, { type: 'stub' })).rejects.toMatchObject({ code: 'SERVICE_DISPOSING' })
})
it('aggregates service-disposal close failures after attempting every record', async () => {
const ctx = await harness()
const service = ctx.pty
const b = backend()
ctx.pty.registerBackend(b.provider)
const owner = stubAgent(ctx, 'owner')
ctx.agents.register(owner)
await ctx.pty.spawn(owner, { type: 'stub' })
b.sessions[0]!.rejectClose = true
const internal = service as unknown as {
sessions: Map<PtySessionIdType, unknown>
closeRecords(records: unknown[], reason: string): Promise<void>
}
await expect(internal.closeRecords([...internal.sessions.values()], 'test failure')).rejects.toThrow('failed to close 1 PTY session')
b.sessions[0]!.rejectClose = false
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)
})
})