Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
218 changes: 218 additions & 0 deletions cli/src/agent/AgentPtyManager.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { AgentPtyManager } from './AgentPtyManager'

const globalWithBun = globalThis as unknown as {
Bun?: {
spawn?: unknown
}
}
const originalBun = globalWithBun.Bun

function makeMockProc(): { terminal: Bun.Terminal; killed: boolean; exitCode: number | null; signalCode: string | null; kill: ReturnType<typeof vi.fn>; onExit?: (code: number | null) => void } {
return {
terminal: {
write: vi.fn(),
resize: vi.fn(),
close: vi.fn(),
} as unknown as Bun.Terminal,
killed: false,
exitCode: null,
signalCode: null,
kill: vi.fn(() => { (proc as any).killed = true }),
}
}

let proc: ReturnType<typeof makeMockProc>

describe('AgentPtyManager', () => {
beforeEach(() => {
proc = makeMockProc()
const spawnMock = vi.fn(() => proc)
globalWithBun.Bun = {
spawn: spawnMock,
}
})

afterEach(() => {
if (originalBun === undefined) {
delete globalWithBun.Bun
} else {
globalWithBun.Bun = originalBun
}
})

it('spawns a process with terminal option', () => {
const manager = new AgentPtyManager()
const onData = vi.fn()

manager.spawn({
command: 'claude',
args: ['--model', 'sonnet'],
cwd: '/workspace/project',
cols: 80,
rows: 24,
onData,
})

expect(globalWithBun.Bun!.spawn).toHaveBeenCalledWith(
['claude', '--model', 'sonnet'],
expect.objectContaining({
cwd: '/workspace/project',
terminal: expect.objectContaining({
cols: 80,
rows: 24,
data: expect.any(Function),
}),
})
)
expect(manager.isRunning).toBe(true)
})

it('calls onData callback when terminal emits data', () => {
const manager = new AgentPtyManager()
const onData = vi.fn()

manager.spawn({
command: 'claude',
onData,
})

const spawnCall = (globalWithBun.Bun!.spawn as ReturnType<typeof vi.fn>).mock.calls[0]
const terminalConfig = spawnCall[1].terminal
const decoder = new TextDecoder()
const data = new TextEncoder().encode('hello from claude')

terminalConfig.data(proc.terminal, data)

expect(onData).toHaveBeenCalledWith('hello from claude')
})

it('writes data to terminal', () => {
const manager = new AgentPtyManager()

manager.spawn({
command: 'claude',
onData: vi.fn(),
})

manager.write('test input\n')

expect(proc.terminal.write).toHaveBeenCalledWith('test input\n')
})

it('resizes terminal dimensions', () => {
const manager = new AgentPtyManager()

manager.spawn({
command: 'claude',
cols: 80,
rows: 24,
onData: vi.fn(),
})

manager.resize(120, 40)

expect(proc.terminal.resize).toHaveBeenCalledWith(120, 40)
})

it('kills the process and cleans up', () => {
const manager = new AgentPtyManager()

manager.spawn({
command: 'claude',
onData: vi.fn(),
})

manager.kill()

expect(proc.kill).toHaveBeenCalled()
expect(proc.terminal.close).toHaveBeenCalled()
expect(manager.isRunning).toBe(false)
})

it('reports exit code via onExit callback', () => {
const manager = new AgentPtyManager()
const onExit = vi.fn()

manager.spawn({
command: 'claude',
onData: vi.fn(),
onExit,
})

const spawnCall = (globalWithBun.Bun!.spawn as ReturnType<typeof vi.fn>).mock.calls[0]
const onExitHandler = spawnCall[1].onExit

onExitHandler(proc, 0)

expect(onExit).toHaveBeenCalledWith(0, null)
expect(manager.exitCode).toBe(0)
})

it('does not call spawn if Bun is unavailable', () => {
delete globalWithBun.Bun
const manager = new AgentPtyManager()
const onError = vi.fn()

manager.spawn({
command: 'claude',
onData: vi.fn(),
onError,
})

expect(onError).toHaveBeenCalledWith(
expect.objectContaining({ message: expect.stringContaining('Bun') })
)
expect(manager.isRunning).toBe(false)
})

it('does not write if not spawned', () => {
const manager = new AgentPtyManager()
manager.write('data')
// No error should be thrown
})

it('does not resize if not spawned', () => {
const manager = new AgentPtyManager()
manager.resize(80, 24)
// No error should be thrown
})

it('does not kill if not spawned', () => {
const manager = new AgentPtyManager()
manager.kill()
// No error should be thrown
})

it('tracks exit code and signal code', () => {
const manager = new AgentPtyManager()

manager.spawn({
command: 'claude',
onData: vi.fn(),
})

const spawnCall = (globalWithBun.Bun!.spawn as ReturnType<typeof vi.fn>).mock.calls[0]
const onExitHandler = spawnCall[1].onExit

proc.signalCode = 'SIGTERM'
onExitHandler(proc, null)

expect(manager.exitCode).toBe(null)
expect(manager.signalCode).toBe('SIGTERM')
expect(manager.isRunning).toBe(false)
})

it('applies environment variables from filtered env', () => {
const manager = new AgentPtyManager()

manager.spawn({
command: 'claude',
env: { TERM: 'xterm-256color', CUSTOM_VAR: 'value' },
onData: vi.fn(),
})

const spawnCall = (globalWithBun.Bun!.spawn as ReturnType<typeof vi.fn>).mock.calls[0]
expect(spawnCall[1].env).toEqual({ TERM: 'xterm-256color', CUSTOM_VAR: 'value' })
})
})
133 changes: 133 additions & 0 deletions cli/src/agent/AgentPtyManager.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import { logger } from '@/ui/logger'

export type AgentPtyOptions = {
command: string
args?: string[]
cwd?: string
env?: Record<string, string>
cols?: number
rows?: number
onData: (data: string) => void
onExit?: (code: number | null, signal: string | null) => void
onError?: (error: Error) => void
}

function getOptionalBun(): typeof Bun | null {
return typeof Bun === 'undefined' ? null : Bun
}

export class AgentPtyManager {
private proc: Bun.Subprocess | null = null
private terminal: Bun.Terminal | null = null
private _exitCode: number | null = null
private _signalCode: string | null = null
private _isRunning: boolean = false

get exitCode(): number | null {
return this._exitCode
}

get signalCode(): string | null {
return this._signalCode
}

get isRunning(): boolean {
return this._isRunning
}

spawn(opts: AgentPtyOptions): void {
const bun = getOptionalBun()
if (!bun || typeof bun.spawn !== 'function') {
const err = new Error('Bun.spawn is unavailable in this runtime')
opts.onError?.(err)
return
}

const cmd = opts.command
const args = opts.args ?? []
const cwd = opts.cwd
const decoder = new TextDecoder()

try {
this.proc = bun.spawn([cmd, ...args], {
cwd,
env: opts.env ?? process.env,
terminal: {
cols: opts.cols ?? 80,
rows: opts.rows ?? 24,
data: (_terminal, data) => {
const text = decoder.decode(data, { stream: true })
if (text) {
opts.onData(text)
}
},
},
onExit: (subprocess, exitCode) => {
this._exitCode = exitCode
this._signalCode = subprocess.signalCode ?? null
this._isRunning = false
opts.onExit?.(this._exitCode, this._signalCode)
},
})

this.terminal = this.proc.terminal ?? null
if (!this.terminal) {
try {
this.proc.kill()
} catch (error) {
logger.debug('[AgentPtyManager] Failed to kill process after missing terminal', { error })
}
this.proc = null
const err = new Error('Failed to attach terminal to spawned process')
opts.onError?.(err)
return
}

this._isRunning = true
} catch (error) {
logger.debug('[AgentPtyManager] Failed to spawn process', { error })
this.proc = null
this.terminal = null
opts.onError?.(error instanceof Error ? error : new Error(String(error)))
}
}

write(data: string): void {
if (!this.terminal || !this._isRunning) {
return
}
this.terminal.write(data)
}

resize(cols: number, rows: number): void {
if (!this.terminal || !this._isRunning) {
return
}
this.terminal.resize(cols, rows)
}

kill(): void {
if (!this.proc || !this._isRunning) {
return
}

if (!this.proc.killed && this.proc.exitCode === null) {
try {
this.proc.kill()
} catch (error) {
logger.debug('[AgentPtyManager] Failed to kill process', { error })
}
}

if (this.terminal) {
try {
this.terminal.close()
} catch (error) {
logger.debug('[AgentPtyManager] Failed to close terminal', { error })
}
}

this.terminal = null
this._isRunning = false
}
}
28 changes: 28 additions & 0 deletions cli/src/agent/__tests__/bracketedPaste.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { describe, expect, it } from 'vitest'
import { bracketPasteIfMultiline } from '../bracketedPaste'

const START = '\x1b[200~'
const END = '\x1b[201~'

describe('bracketPasteIfMultiline', () => {
it('leaves a single-line message untouched', () => {
expect(bracketPasteIfMultiline('hello world')).toBe('hello world')
})

it('wraps a multiline message in bracketed-paste markers', () => {
expect(bracketPasteIfMultiline('line 1\nline 2')).toBe(`${START}line 1\nline 2${END}`)
})

it('wraps an attachment-formatted prompt (@path\\n\\ntext)', () => {
expect(bracketPasteIfMultiline('@/tmp/a.png\n\ndescribe this'))
.toBe(`${START}@/tmp/a.png\n\ndescribe this${END}`)
})

it('wraps a trailing newline (so it is not interpreted as a premature submit)', () => {
expect(bracketPasteIfMultiline('text\n')).toBe(`${START}text\n${END}`)
})

it('leaves an empty string untouched', () => {
expect(bracketPasteIfMultiline('')).toBe('')
})
})
Loading
Loading