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, { AgentMessageId } from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { renderToolsSdk } from '@deepseek-ai/dsh-tools' import type { ToolSdkSchema } from '@deepseek-ai/dsh-tools/src/ts-types.ts' import PtyService, { PtySessionId } from '@deepseek-ai/dsh-pty' import type { PtyBackend, PtyBackendSession, PtySendOperation, PtySendRequest, PtySessionStatus, PtySignal } from '@deepseek-ai/dsh-pty' import LocalTaskService from '@deepseek-ai/dsh-tasks-local' 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, followup: () => AgentMessageId('stub'), queue: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), send: () => AgentMessageId('stub'), 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 viewport = 'command output' delta = 'live output' deltaTruncated = false startSend(_request: PtySendRequest): PtySendOperation { let settle!: () => void let reject!: (error: unknown) => void let cancelled = false const done = new Promise((resolve, rejectPromise) => { settle = resolve; reject = rejectPromise }).then(() => ({ viewport: cancelled ? '^C' : this.viewport, waitReason: 'stdin_read' as const, sessionStatus: this.statusValue, truncated: false, })) const operation: PtySendOperation = { done, readOutput: () => ({ delta: this.delta, truncated: this.deltaTruncated }), 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, config: ToolPty.Config = {}) { const base = await setupBase(tasks) await base.ctx.plugin(ToolPty, config) return base } async function setupBase(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(LocalTaskService) await ctx.plugin(ToolTasks) } return { ctx, stub, agent: fakeAgent(ctx, tasks ? 'with-tasks' : 'foreground') } } let callNumber = 0 const TOOL_NAMES = ['terminal_open', 'terminal_send', 'terminal_read', 'terminal_signal', 'terminal_close', 'terminal_list'] as const const testToolSignal = new AbortController().signal function call(ctx: Context, name: string, args: unknown, agent?: Agent) { return ctx.tools.execute({ signal: testToolSignal, 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(TOOL_NAMES.every(name => ctx.tools.get(name) !== undefined)).toBe(true) const spawned = await call(ctx, 'terminal_open', { type: 'stub', name: 'main' }, agent) expect(text(spawned)).toContain('started terminal session pty-1 (main)') expect(spawned).toMatchObject({ isError: false, value: { sessionId: 'pty-1', name: 'main', type: 'stub', pid: 42, status: { kind: 'running' }, motd: 'stub prompt', }, }) const listed = await call(ctx, 'terminal_list', {}, agent) expect(text(listed)).toContain('pty-1 (main) [stub] running pid=42') expect(listed).toMatchObject({ isError: false, value: [{ sessionId: 'pty-1', name: 'main', type: 'stub', pid: 42, status: { kind: 'running' } }] }) const read = await call(ctx, 'terminal_read', { sessionId: 'pty-1' }, agent) expect(text(read)).toContain('history\n[lines: 0-1 of 1]') expect(read).toMatchObject({ isError: false, value: { text: 'history', totalLines: 1, lineBegin: 0, lineEnd: 1, truncated: false } }) const signalled = await call(ctx, 'terminal_signal', { sessionId: 'pty-1', signal: 'SIGINT' }, agent) expect(text(signalled)).toBe('delivered SIGINT to foreground process group 10') expect(signalled).toMatchObject({ isError: false, value: { delivered: true, targetPgid: 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(sent).toMatchObject({ isError: false, value: { kind: 'foreground', viewport: 'command output', waitReason: 'stdin_read', sessionStatus: { kind: 'running' }, truncated: false, }, meta: { viewport: 'command output', waitReason: 'stdin_read', sessionStatus: { kind: 'running' }, truncated: false, }, }) const closed = await call(ctx, 'terminal_close', { sessionId: 'pty-1' }, agent) expect(text(closed)).toBe('closed terminal session pty-1') expect(closed).toMatchObject({ isError: false, value: { sessionId: 'pty-1', outcome: 'closed' } }) const empty = await call(ctx, 'terminal_list', {}, agent) expect(text(empty)).toBe('(no terminal sessions)') expect(empty).toMatchObject({ isError: false, value: [] }) }) it('projects every terminal DTO into the generated Code Mode output map', async () => { const { ctx } = await setup(false) const schemas = TOOL_NAMES.map((toolName): ToolSdkSchema => { const definition = ctx.tools.get(toolName) if (definition === undefined) throw new Error(`missing terminal tool ${toolName}`) return { name: definition.name, description: definition.description, parameters: definition.parameters, output: definition.output.schema, } }) const sdk = renderToolsSdk(schemas) const outputMapStart = sdk.indexOf('interface ToolOutputMap') const outputMapEnd = sdk.indexOf('\n\ntype ToolName', outputMapStart) expect(sdk.slice(outputMapStart, outputMapEnd)).toMatchInlineSnapshot(` "interface ToolOutputMap { terminal_close: { sessionId: string; outcome: "closed" | "already-closing"; }; terminal_list: ({ sessionId: string; name?: string; type: string; pid?: number; status: { kind: "running"; } | { kind: "exited"; exitCode: number | null; signal: string | null; }; })[]; terminal_open: { sessionId: string; name?: string; type: string; pid?: number; status: { kind: "running"; } | { kind: "exited"; exitCode: number | null; signal: string | null; }; motd: string; }; terminal_read: { text: string; totalLines: number; lineBegin: number; lineEnd: number; truncated: boolean; }; terminal_send: { kind: "background"; taskId: string; } | { kind: "foreground"; viewport: string; waitReason: "stdin_read" | "inferred_idle" | "timeout" | "session_exit"; sessionStatus: { kind: "running"; } | { kind: "exited"; exitCode: number | null; signal: string | null; }; truncated: boolean; }; terminal_signal: { delivered: true; targetPgid: number; }; }" `) }) it('fails without an initiating agent and rejects background before writing', async () => { const { ctx, agent, stub } = await setup(false) 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, '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('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)' }) 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('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' }) }) it('configuration-gates background sends and validates the final result bound', async () => { const disabled = await setup(true, { enableRunInBackground: false }) const definition = disabled.ctx.tools.get('terminal_send') expect(definition?.parameters).not.toHaveProperty('properties.run_in_background') expect(definition?.description).not.toContain('Background mode') await call(disabled.ctx, 'terminal_open', { type: 'stub' }, disabled.agent) expect((await call(disabled.ctx, 'terminal_send', { sessionId: 'pty-1', text: 'work', run_in_background: true, }, disabled.agent)).isError).toBe(true) const defaults = await setupBase(false) ToolPty.apply(defaults.ctx) expect(defaults.ctx.tools.get('terminal_send')?.parameters).toHaveProperty('properties.run_in_background') const invalid = await setupBase(false) expect(() => { ToolPty.apply(invalid.ctx, { maxResultBytes: 0 }) }).toThrow('maxResultBytes') expect(() => { ToolPty.apply(invalid.ctx, { maxResultBytes: 63 }) }).toThrow('at least 64') }) it('bounds normalized errors and preserves allocated ids at the minimum result cap', async () => { const { ctx, agent } = await setup(true, { maxResultBytes: 64 }) const failed = await call(ctx, 'terminal_open', { type: 'x'.repeat(1_000) }, agent) expect(failed.isError).toBe(true) expect(Buffer.byteLength(text(failed))).toBeLessThanOrEqual(64) expect(text(failed)).toContain('[output truncated]') const opened = await call(ctx, 'terminal_open', { type: 'stub', name: 'n'.repeat(1_000) }, agent) expect(text(opened)).toContain('pty-1') expect(Buffer.byteLength(text(opened))).toBeLessThanOrEqual(64) const background = await call(ctx, 'terminal_send', { sessionId: 'pty-1', text: 'work', run_in_background: true, }, agent) expect(text(background)).toContain('pty-send-1') expect(Buffer.byteLength(text(background))).toBeLessThanOrEqual(64) }) it('bounds terminal results after policy decisions and pipeline failures', async () => { const { ctx, agent } = await setup(false, { maxResultBytes: 64 }) ctx.on('tools/pre-execute', async (exec, next) => { if (exec.name === 'terminal_list') return { kind: 'deny', reason: 'd'.repeat(1_000) } if (exec.name === 'terminal_signal') throw new Error(`pre failed: ${'p'.repeat(1_000)}`) return next() }) ctx.on('tools/execute', async (exec, next) => { if (exec.name === 'terminal_close') throw new Error(`around failed: ${'e'.repeat(1_000)}`) return next() }) ctx.on('tools/post-execute', async (exec, _result, next) => { if (exec.name === 'terminal_open') { return { kind: 'accept', content: [{ type: 'text', text: 'a'.repeat(1_000) }] } } if (exec.name === 'terminal_read') { return { kind: 'block', feedback: [{ type: 'text', text: 'b'.repeat(1_000) }] } } if (exec.name === 'terminal_send') throw new Error(`post failed: ${'o'.repeat(1_000)}`) return next() }) const denied = await call(ctx, 'terminal_list', {}, agent) expect(denied.isError).toBe(true) expect(Buffer.byteLength(text(denied))).toBeLessThanOrEqual(64) expect(text(denied)).toContain('[output truncated]') const replaced = await call(ctx, 'terminal_open', { type: 'stub' }, agent) expect(replaced.isError).toBe(false) expect(Buffer.byteLength(text(replaced))).toBeLessThanOrEqual(64) expect(text(replaced)).toContain('[output truncated]') const blocked = await call(ctx, 'terminal_read', { sessionId: 'pty-1' }, agent) expect(blocked.isError).toBe(true) expect(Buffer.byteLength(text(blocked))).toBeLessThanOrEqual(64) expect(text(blocked)).toContain('[output truncated]') const failures = [ await call(ctx, 'terminal_signal', { sessionId: 'pty-1', signal: 'SIGINT' }, agent), await call(ctx, 'terminal_close', { sessionId: 'pty-1' }, agent), await call(ctx, 'terminal_send', { sessionId: 'pty-1', text: 'work' }, agent), ] for (const failure of failures) { expect(failure.isError).toBe(true) expect(Buffer.byteLength(text(failure))).toBeLessThanOrEqual(64) expect(text(failure)).toContain('[output truncated]') } }) it('leaves a structured around-dispatch failure unchanged', async () => { const { ctx, agent } = await setup(false, { maxResultBytes: 64 }) ctx.on('tools/execute', async (exec, next) => exec.name === 'terminal_list' ? { content: [], isError: true, error: { message: 'structured failure' } } : next()) const result = await call(ctx, 'terminal_list', {}, agent) expect(result.isError).toBe(true) expect(result.content).toEqual([]) }) }) describe('tool-pty task integration', () => { it('registers a generic task and exposes incremental output', async () => { const { ctx, agent } = await setup(true) await call(ctx, 'terminal_open', { type: 'stub' }, agent) const started = await call(ctx, 'terminal_send', { sessionId: 'pty-1', text: 'build', run_in_background: true }, agent) expect(text(started)).toBe('started background task pty-send-1') expect(started).toMatchObject({ isError: false, value: { kind: 'background', taskId: '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('bounds foreground and background results after terminal and task metadata', async () => { const { ctx, agent, stub } = await setup(true, { maxResultBytes: 64 }) await call(ctx, 'terminal_open', { type: 'stub' }, agent) stub.sessions[0]!.viewport = '界'.repeat(100) const foreground = await call(ctx, 'terminal_send', { sessionId: 'pty-1', text: 'foreground' }, agent) expect(Buffer.byteLength(text(foreground))).toBeLessThanOrEqual(64) stub.sessions[0]!.delta = '界'.repeat(100) stub.sessions[0]!.deltaTruncated = true await call(ctx, 'terminal_send', { sessionId: 'pty-1', text: 'background', run_in_background: true }, agent) const background = await call(ctx, 'task_output', { task_id: 'pty-send-1', wait: true }, agent) expect(Buffer.byteLength(text(background))).toBeLessThanOrEqual(64) expect(text(background)).toContain('[status: completed') expect(text(background).match(/\[output truncated\]/g)).toHaveLength(1) expect(text(background)).toContain('[output truncated]\n[status: completed') }) it('rejects pre-aborted background calls, maps task cancellation, and contains operation failure', async () => { const { ctx, agent, stub } = await setup(true) await call(ctx, 'terminal_open', { type: 'stub' }, agent) const controller = new AbortController() controller.abort() 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, '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, '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 terminal operation settles', async () => { const { ctx, agent, stub } = await setup(false) await call(ctx, 'terminal_open', { type: 'stub' }, agent) stub.sessions[0]!.autoSettle = false const controller = new AbortController() const pending = callWithSignal(ctx, 'terminal_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, 'terminal_open', { type: 'stub' }, agent) stub.sessions[0]!.closeGate = Promise.withResolvers() const first = ctx.pty.kill(agent, PtySessionId('pty-1')) const second = call(ctx, 'terminal_close', { sessionId: 'pty-1' }, agent) stub.sessions[0]!.closeGate?.resolve(undefined) await first const result = await second expect(text(result)).toBe('terminal session pty-1 was already closing') expect(result).toMatchObject({ isError: false, value: { sessionId: 'pty-1', outcome: 'already-closing' } }) }) it('renders an exited session detail for background completion', async () => { const { ctx, agent, stub } = await setup(true) await call(ctx, 'terminal_open', { type: 'stub' }, agent) stub.sessions[0]!.statusValue = { kind: 'exited', exitCode: null, signal: null } 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') }) }) 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']) }) })