b5e8e4e9c1
Model responses, replayed session data, tool presenter output, question metadata, configuration, and diagnostics all cross into ANSI-aware pi-tui renderers. Passing their C0 or C1 controls through unchanged lets an otherwise ordinary transcript emit OSC, CSI, cursor, or title operations in the user terminal. Introduce one displayText boundary that preserves line-feed layout but renders every other C0/C1 control as visible \\xNN text before application styling is applied. Route transcript blocks, streaming output, tool cards, diffs, plans, dialogs, headers, cwd/title data, notices, errors, and pre-mount startup failures through that boundary while leaving pi-tui and the theme responsible for legitimate terminal control sequences. Pin the contract at three levels: a settled headless-terminal golden spans the main untrusted display sources, unit coverage checks the pre-fullscreen failure path, and the real Loader/PTY conversation streams hostile OSC, cursor, and C1 probes and proves only their inert textual forms reach the terminal stream.
931 lines
36 KiB
TypeScript
931 lines
36 KiB
TypeScript
import { describe, expect, it, vi } from 'vitest'
|
|
import { Context } from 'cordis'
|
|
import type { Terminal } from '@earendil-works/pi-tui'
|
|
import AgentRegistry, { AgentId, type Agent } from '@deepseek-ai/dsh-agent'
|
|
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
|
import type { ToolDefinition } from '@deepseek-ai/dsh-tools'
|
|
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
|
|
import {
|
|
createTuiChat,
|
|
mountTui,
|
|
resolveTuiConfig,
|
|
type TuiRuntime,
|
|
} from '../src/index.ts'
|
|
import {
|
|
appendAssistant,
|
|
appendUser,
|
|
createTuiTestHarness,
|
|
disposeTuiTestHarness,
|
|
type TuiHarnessOptions,
|
|
} from './harness.ts'
|
|
|
|
class FakeTerminal implements Terminal {
|
|
columns = 88
|
|
rows = 32
|
|
kittyProtocolActive = false
|
|
output = ''
|
|
title = ''
|
|
progress: boolean[] = []
|
|
started = 0
|
|
stopped = 0
|
|
drainInput = vi.fn(() => Promise.resolve())
|
|
private onInput: (data: string) => void = () => {}
|
|
private onResize: () => void = () => {}
|
|
|
|
start(onInput: (data: string) => void, onResize: () => void): void {
|
|
this.started += 1
|
|
this.onInput = onInput
|
|
this.onResize = onResize
|
|
}
|
|
|
|
stop(): void {
|
|
this.stopped += 1
|
|
}
|
|
|
|
write(data: string): void {
|
|
this.output += data
|
|
}
|
|
|
|
moveBy(lines: number): void {
|
|
this.output += `[move:${lines}]`
|
|
}
|
|
|
|
hideCursor(): void {
|
|
this.output += '[hide]'
|
|
}
|
|
|
|
showCursor(): void {
|
|
this.output += '[show]'
|
|
}
|
|
|
|
clearLine(): void {
|
|
this.output += '[clear-line]'
|
|
}
|
|
|
|
clearFromCursor(): void {
|
|
this.output += '[clear-rest]'
|
|
}
|
|
|
|
clearScreen(): void {
|
|
this.output += '[clear-screen]'
|
|
}
|
|
|
|
setTitle(title: string): void {
|
|
this.title = title
|
|
}
|
|
|
|
setProgress(active: boolean): void {
|
|
this.progress.push(active)
|
|
}
|
|
|
|
send(data: string): void {
|
|
this.onInput(data)
|
|
}
|
|
|
|
resize(columns: number, rows = this.rows): void {
|
|
this.columns = columns
|
|
this.rows = rows
|
|
this.onResize()
|
|
}
|
|
}
|
|
|
|
async function tick(): Promise<void> {
|
|
await new Promise(resolve => setTimeout(resolve, 25))
|
|
}
|
|
|
|
async function setup(options: TuiHarnessOptions = {}) {
|
|
const terminal = new FakeTerminal()
|
|
const exit = vi.fn()
|
|
const result = await createTuiTestHarness(terminal, exit, {
|
|
...options,
|
|
cwd: options.cwd === undefined ? process.cwd() : options.cwd,
|
|
})
|
|
await tick()
|
|
return result
|
|
}
|
|
|
|
async function dispose(setupResult: Awaited<ReturnType<typeof setup>>): Promise<void> {
|
|
await disposeTuiTestHarness(setupResult)
|
|
}
|
|
|
|
describe('TUI config', () => {
|
|
it('defaults every direct-call TUI option', () => {
|
|
expect(resolveTuiConfig(undefined)).toEqual({
|
|
showReasoning: true,
|
|
maxToolOutputLines: 12,
|
|
maxQuestionOptions: 8,
|
|
questionDialogWidth: 72,
|
|
questionDialogMaxHeight: 20,
|
|
showHardwareCursor: false,
|
|
color: true,
|
|
title: 'DeepSeek Harness',
|
|
})
|
|
expect(resolveTuiConfig({
|
|
showReasoning: false,
|
|
maxToolOutputLines: 2,
|
|
maxQuestionOptions: 3,
|
|
questionDialogWidth: 60,
|
|
questionDialogMaxHeight: 14,
|
|
showHardwareCursor: true,
|
|
color: false,
|
|
title: 'DSH',
|
|
})).toEqual({
|
|
showReasoning: false,
|
|
maxToolOutputLines: 2,
|
|
maxQuestionOptions: 3,
|
|
questionDialogWidth: 60,
|
|
questionDialogMaxHeight: 14,
|
|
showHardwareCursor: true,
|
|
color: false,
|
|
title: 'DSH',
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('pi-tui chat lifecycle and transcript', () => {
|
|
it('renders its header, footer, replay, streaming answer, todos, and status', async () => {
|
|
const result = await setup({
|
|
beforeMount(session) {
|
|
appendUser(session, 'restored prompt')
|
|
appendAssistant(session, [
|
|
{ type: 'reasoning', text: 'restored thought' },
|
|
{ type: 'text', text: '**restored answer**' },
|
|
], { inputTokens: 1_250, outputTokens: 42 })
|
|
session.append('todo/write', {
|
|
todos: [
|
|
{ content: 'read code', status: 'completed' },
|
|
{ content: 'write tests', status: 'in_progress' },
|
|
{ content: 'ship', status: 'pending' },
|
|
],
|
|
})
|
|
},
|
|
})
|
|
|
|
expect(result.terminal.started).toBe(1)
|
|
expect(result.terminal.title).toBe('DeepSeek Harness')
|
|
expect(result.terminal.output).toContain('DEEPSEEK')
|
|
expect(result.terminal.output).toContain('Coding agent ready.')
|
|
expect(result.terminal.output).toContain('restored prompt')
|
|
expect(result.terminal.output).toContain('restored thought')
|
|
expect(result.terminal.output).toContain('restored answer')
|
|
expect(result.terminal.output).toContain('write tests')
|
|
expect(result.terminal.output).toContain('↑1.3k ↓42')
|
|
|
|
result.agent.status = 'running'
|
|
result.ctx.emit('agent/status', result.agent, 'running')
|
|
result.session.append('user/message', { content: [{ type: 'text', text: ' ' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
|
result.session.append('steering/message', { turn: 2, content: [{ type: 'text', text: 'steering note' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
|
result.session.append('steering/message', { turn: 2, content: [{ type: 'text', text: '' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
|
result.session.append('context/message', { content: [{ type: 'text', text: 'user context' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
|
result.session.append('context/message', { content: [{ type: 'text', text: '' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
|
result.session.append('prompt/blocked', { content: [{ type: 'text', text: 'blocked' }], source: { kind: 'user' }, reason: 'test policy' })
|
|
appendAssistant(result.session, [])
|
|
result.session.append('turn/end', { turn: 9, reason: { kind: 'aborted' } })
|
|
result.session.append('turn/end', { turn: 10, reason: { kind: 'completed' } })
|
|
result.session.append('step/start', { turn: 11, step: 0 })
|
|
result.session.append('assistant/chunk', {
|
|
turn: 2,
|
|
step: 0,
|
|
chunk: { type: 'block-start', index: 0, blockType: 'reasoning' },
|
|
})
|
|
result.session.append('assistant/chunk', {
|
|
turn: 2,
|
|
step: 0,
|
|
chunk: { type: 'reasoning-delta', index: 0, text: 'live thought' },
|
|
})
|
|
result.session.append('assistant/chunk', {
|
|
turn: 2,
|
|
step: 0,
|
|
chunk: { type: 'reasoning-delta', index: 9, text: 'unannounced thought' },
|
|
})
|
|
result.session.append('assistant/chunk', {
|
|
turn: 2,
|
|
step: 0,
|
|
chunk: { type: 'block-end', index: 0, block: { type: 'reasoning', text: 'live thought complete' } },
|
|
})
|
|
result.session.append('assistant/chunk', {
|
|
turn: 2,
|
|
step: 0,
|
|
chunk: { type: 'block-start', index: 1, blockType: 'text' },
|
|
})
|
|
result.session.append('assistant/chunk', {
|
|
turn: 2,
|
|
step: 0,
|
|
chunk: { type: 'text-delta', index: 1, text: 'live answer' },
|
|
})
|
|
result.session.append('assistant/chunk', {
|
|
turn: 2,
|
|
step: 0,
|
|
chunk: { type: 'block-end', index: 1, block: { type: 'text', text: 'live answer done' } },
|
|
})
|
|
result.session.append('assistant/chunk', {
|
|
turn: 2,
|
|
step: 0,
|
|
chunk: { type: 'block-start', index: 2, blockType: 'tool-call' },
|
|
})
|
|
result.session.append('assistant/chunk', {
|
|
turn: 2,
|
|
step: 0,
|
|
chunk: { type: 'block-end', index: 2, block: { type: 'tool-call', id: 'stream-tool' as never, name: 'tool', arguments: '{}' } },
|
|
})
|
|
result.session.append('assistant/chunk', {
|
|
turn: 2,
|
|
step: 0,
|
|
chunk: { type: 'tool-call-delta', index: 2, id: 'stream-tool' as never, argumentsDelta: '{}' },
|
|
})
|
|
result.session.append('assistant/chunk', {
|
|
turn: 2,
|
|
step: 0,
|
|
chunk: { type: 'usage', usage: { inputTokens: 1, outputTokens: 2 } },
|
|
})
|
|
await tick()
|
|
expect(result.terminal.output).toContain('live thought')
|
|
result.terminal.send('\x12')
|
|
await tick()
|
|
appendAssistant(result.session, [{ type: 'text', text: 'final live answer' }], { inputTokens: 500, outputTokens: 8 })
|
|
await tick()
|
|
|
|
expect(result.terminal.output).toContain('Working')
|
|
expect(result.terminal.output).toContain('Steering')
|
|
expect(result.terminal.output).toContain('user context')
|
|
expect(result.terminal.output).toContain('Prompt blocked')
|
|
expect(result.terminal.output).toContain('Turn cancelled')
|
|
expect(result.terminal.output).toContain('final live answer')
|
|
expect(result.terminal.output).toContain('↑1.8k ↓50')
|
|
expect(result.terminal.progress).toContain(true)
|
|
|
|
result.session.append('assistant/chunk', {
|
|
turn: 3,
|
|
step: 0,
|
|
chunk: { type: 'text-delta', index: 0, text: 'cleared stream' },
|
|
})
|
|
result.terminal.send('/clear')
|
|
result.terminal.send('\r')
|
|
appendAssistant(result.session, [{ type: 'text', text: 'answer after clear' }])
|
|
await tick()
|
|
expect(result.terminal.output).toContain('answer after clear')
|
|
|
|
result.agent.status = 'idle'
|
|
result.ctx.emit('agent/status', result.agent, 'idle')
|
|
await tick()
|
|
expect(result.terminal.progress.at(-1)).toBe(false)
|
|
await dispose(result)
|
|
expect(result.terminal.stopped).toBe(1)
|
|
expect(result.terminal.drainInput).toHaveBeenCalledWith(100, 20)
|
|
})
|
|
|
|
it('renders the ANSI palette and every markdown/content style', async () => {
|
|
const result = await setup({
|
|
config: { color: true },
|
|
beforeMount(session) {
|
|
session.append('user/message', {
|
|
content: [
|
|
{ type: 'text', text: '# Heading\n\n[link](https://example.com) `code`\n\n```ts\nconst x = 1\n```\n\n> quote\n\n---\n\n- item\n\n**bold** *italic* ~~strike~~' },
|
|
{ type: 'tool-call', id: 'nested' as never, name: 'nested_tool', arguments: '{}' },
|
|
{ type: 'tool-result', toolCallId: 'nested' as never, content: [{ type: 'reasoning', text: 'nested result' }] },
|
|
{ type: 'future-block' } as never,
|
|
{} as never,
|
|
],
|
|
source: { kind: 'user' },
|
|
}, { surfaceOp: 'append' })
|
|
appendAssistant(session, [
|
|
{ type: 'reasoning', text: 'styled reasoning' },
|
|
{ type: 'text', text: 'styled answer' },
|
|
], { inputTokens: 2_000_000, outputTokens: 1_500_000 })
|
|
session.append('todo/write', { todos: [
|
|
{ content: 'done', status: 'completed' },
|
|
{ content: 'active', status: 'in_progress' },
|
|
{ content: 'later', status: 'pending' },
|
|
] })
|
|
},
|
|
})
|
|
result.terminal.send('/')
|
|
await tick()
|
|
result.terminal.send('zz')
|
|
await tick()
|
|
result.terminal.send('\x0c')
|
|
await tick()
|
|
|
|
expect(result.terminal.output).toContain('\x1b[')
|
|
expect(result.terminal.output).toContain('Heading')
|
|
expect(result.terminal.output).toContain('nested_tool({})')
|
|
expect(result.terminal.output).toContain('nested result')
|
|
expect(result.terminal.output).toContain('[future-block]')
|
|
expect(result.terminal.output).toContain('[content]')
|
|
expect(result.terminal.output).toContain('↑2.0m ↓1.5m')
|
|
await dispose(result)
|
|
})
|
|
|
|
it('suppresses stale replay chunks and does not duplicate editor history on rebuild', async () => {
|
|
const result = await setup({
|
|
beforeMount(session) {
|
|
appendUser(session, 'first prompt')
|
|
appendUser(session, 'second prompt')
|
|
session.append('assistant/chunk', {
|
|
turn: 2,
|
|
step: 0,
|
|
chunk: { type: 'text-delta', index: 0, text: 'stale partial response' },
|
|
})
|
|
},
|
|
})
|
|
|
|
expect(result.terminal.output).not.toContain('stale partial response')
|
|
result.terminal.send('/reasoning')
|
|
result.terminal.send('\r')
|
|
result.terminal.send('\x1b[A')
|
|
result.terminal.send('\x1b[A')
|
|
result.terminal.send('\x1b[A')
|
|
result.terminal.send('\r')
|
|
expect(result.agent.sent).toEqual([[{ type: 'text', text: 'first prompt' }]])
|
|
await dispose(result)
|
|
})
|
|
|
|
it('formats large token totals and cwd variants', async () => {
|
|
const homeResult = await setup({
|
|
cwd: process.env.HOME ?? process.cwd(),
|
|
beforeMount(session) {
|
|
appendAssistant(session, [{ type: 'text', text: 'home' }], { inputTokens: 25_000, outputTokens: 10_000 })
|
|
},
|
|
})
|
|
expect(homeResult.terminal.output).toContain('~ ↑25k ↓10k')
|
|
await dispose(homeResult)
|
|
|
|
const unsetResult = await setup({ cwd: null })
|
|
expect(unsetResult.terminal.output).toContain('cwd unset')
|
|
await dispose(unsetResult)
|
|
|
|
const outsideResult = await setup({ cwd: '/opt' })
|
|
expect(outsideResult.terminal.output).toContain('/opt')
|
|
await dispose(outsideResult)
|
|
})
|
|
|
|
it('sends, steers, handles commands, global keys, and disposed-agent input', async () => {
|
|
const result = await setup()
|
|
|
|
result.terminal.send('do the work')
|
|
result.terminal.send('\r')
|
|
expect(result.agent.sent).toEqual([[{ type: 'text', text: 'do the work' }]])
|
|
|
|
result.terminal.send(' ')
|
|
result.terminal.send('\r')
|
|
|
|
result.agent.status = 'running'
|
|
result.terminal.send('steer it')
|
|
result.terminal.send('\r')
|
|
expect(result.agent.steered).toEqual([[{ type: 'text', text: 'steer it' }]])
|
|
|
|
result.terminal.send('\x1b')
|
|
result.terminal.send('\x04')
|
|
result.terminal.send('\x03')
|
|
result.terminal.send('\x12')
|
|
result.terminal.send('\x0f')
|
|
result.terminal.send('/cancel')
|
|
result.terminal.send('\r')
|
|
expect(result.agent.cancelled).toContain('cancelled from terminal')
|
|
|
|
result.agent.status = 'idle'
|
|
for (const command of ['/help', '/reasoning', '/tools', '/redraw']) {
|
|
result.terminal.send(command)
|
|
result.terminal.send('\r')
|
|
await tick()
|
|
}
|
|
for (const command of ['/clear', '/cancel', '/wat']) {
|
|
result.terminal.send(command)
|
|
result.terminal.send('\r')
|
|
}
|
|
await tick()
|
|
result.terminal.send('draft')
|
|
result.terminal.send('\x03')
|
|
result.terminal.send('\x04')
|
|
await tick()
|
|
|
|
expect(result.terminal.output).toContain('Keyboard shortcuts')
|
|
expect(result.terminal.output).toContain('Reasoning blocks')
|
|
expect(result.terminal.output).toContain('Tool cards')
|
|
expect(result.terminal.output).toContain('already idle')
|
|
expect(result.terminal.output).toContain('Unknown command')
|
|
expect(result.exit).toHaveBeenCalledWith(0)
|
|
await result.controller.dispose()
|
|
await result.ctx.fiber.dispose()
|
|
|
|
const ctrlCExit = await setup()
|
|
ctrlCExit.terminal.send('\x03')
|
|
await tick()
|
|
expect(ctrlCExit.exit).toHaveBeenCalledWith(0)
|
|
await ctrlCExit.controller.dispose()
|
|
await ctrlCExit.ctx.fiber.dispose()
|
|
|
|
const disposedAgent = await setup()
|
|
disposedAgent.agent.status = 'disposed'
|
|
disposedAgent.terminal.send('late input')
|
|
disposedAgent.terminal.send('\r')
|
|
await tick()
|
|
expect(disposedAgent.terminal.output).toContain('is disposed')
|
|
await dispose(disposedAgent)
|
|
})
|
|
|
|
it('cancels before /exit while running and handles agent errors/disposal', async () => {
|
|
const result = await setup({ status: 'running' })
|
|
result.terminal.send('/exit')
|
|
result.terminal.send('\r')
|
|
await tick()
|
|
expect(result.agent.cancelled).toContain('terminal exit requested')
|
|
expect(result.exit).toHaveBeenCalledWith(0)
|
|
|
|
const events = await setup()
|
|
const unrelatedSession = events.ctx.sessions.create(SessionId('unrelated-session'))
|
|
const unrelatedAgent = { ...events.agent, id: AgentId('unrelated'), session: unrelatedSession }
|
|
unrelatedSession.append('todo/write', { todos: [{ content: 'hidden', status: 'pending' }] })
|
|
events.ctx.emit('agent/status', unrelatedAgent, 'running')
|
|
events.ctx.emit('agent/error', unrelatedAgent, 1, 1, new Error('hidden error'))
|
|
events.ctx.emit('agent/disposed', unrelatedAgent)
|
|
events.ctx.emit('agent/error', events.agent, 3, 2, new Error('live failure'))
|
|
events.session.append('turn/end', { turn: 3, reason: { kind: 'error', step: 2, message: 'live failure' } })
|
|
events.session.append('turn/end', { turn: 4, reason: { kind: 'error', step: 1, message: 'durable failure' } })
|
|
events.session.append('turn/end', { turn: 5, reason: { kind: 'aborted', reason: 'stopped' } })
|
|
events.session.append('turn/end', { turn: 6, reason: { kind: 'max-tokens' } })
|
|
events.session.append('turn/end', { turn: 7, reason: { kind: 'rejected', reason: 'policy' } })
|
|
events.session.append('turn/end', { turn: 8, reason: { kind: 'interrupted' } })
|
|
events.ctx.emit('agent/disposed', events.agent)
|
|
await tick()
|
|
expect(events.terminal.output).toContain('live failure')
|
|
expect(events.terminal.output).toContain('durable failure')
|
|
expect(events.terminal.output).toContain('stopped')
|
|
expect(events.terminal.output).toContain('output-token limit')
|
|
expect(events.terminal.output).toContain('Turn rejected')
|
|
expect(events.terminal.output).toContain('previous process ended')
|
|
expect(events.terminal.output).toContain('was disposed')
|
|
await dispose(events)
|
|
})
|
|
})
|
|
|
|
describe('tool cards and surface replay', () => {
|
|
const tools: Record<string, ToolDefinition> = {
|
|
bash: {
|
|
name: 'bash', description: '', parameters: {}, execute: async () => [],
|
|
presentCall: () => ({ card: 'terminal', title: 'printf hello', description: 'Run command', cwd: '/tmp' }),
|
|
presentResult: () => ({ card: 'terminal', output: 'hello\nworld\nthird', exitCode: 0 }),
|
|
},
|
|
signal: {
|
|
name: 'signal', description: '', parameters: {}, execute: async () => [],
|
|
presentCall: () => ({ card: 'terminal', title: 'sleep 10' }),
|
|
presentResult: () => ({ card: 'terminal', signal: 'SIGTERM' }),
|
|
},
|
|
edit: {
|
|
name: 'edit', description: '', parameters: {}, execute: async () => [],
|
|
presentCall: () => ({
|
|
card: 'diff',
|
|
title: 'Edit files',
|
|
diffs: [
|
|
{ path: 'a.txt', oldText: 'old', newText: 'new' },
|
|
{ path: 'b.txt', oldText: 'before', newText: 'after' },
|
|
],
|
|
}),
|
|
presentResult: () => ({ card: 'diff', diffs: [{ path: 'a.txt', oldText: null, newText: 'created' }] }),
|
|
},
|
|
generic: {
|
|
name: 'generic', description: '', parameters: {}, execute: async () => [],
|
|
presentCall: () => ({ card: 'generic', title: 'Inspect value', rawInput: { alpha: 1 } }),
|
|
presentResult: () => ({ card: 'generic', title: 'Inspected', content: [{ type: 'text', text: 'result text' }] }),
|
|
},
|
|
throwing: {
|
|
name: 'throwing', description: '', parameters: {}, execute: async () => [],
|
|
presentCall: () => { throw new Error('call presenter boom') },
|
|
presentResult: () => { throw new Error('result presenter boom') },
|
|
},
|
|
rawTerminal: {
|
|
name: 'rawTerminal', description: '', parameters: {}, execute: async () => [],
|
|
presentCall: () => ({ card: 'terminal', title: 'raw command' }),
|
|
},
|
|
undefinedViews: {
|
|
name: 'undefinedViews', description: '', parameters: {}, execute: async () => [],
|
|
presentCall: () => undefined,
|
|
presentResult: () => undefined,
|
|
},
|
|
empty: {
|
|
name: 'empty', description: '', parameters: {}, execute: async () => [],
|
|
presentCall: () => ({ card: 'generic', title: 'Empty card' }),
|
|
},
|
|
terminalResult: {
|
|
name: 'terminalResult', description: '', parameters: {}, execute: async () => [],
|
|
presentCall: () => ({ card: 'generic', title: 'Becomes terminal' }),
|
|
presentResult: () => ({ card: 'terminal', output: 'converted terminal' }),
|
|
},
|
|
symbolic: {
|
|
name: 'symbolic', description: '', parameters: {}, execute: async () => [],
|
|
presentCall: () => ({ card: 'generic', title: 'Symbol input', rawInput: Symbol('input') }),
|
|
},
|
|
}
|
|
|
|
it('uses terminal, diff, generic, fallback, and collapsed tool presentations', async () => {
|
|
const result = await setup({ tools, config: { maxToolOutputLines: 1 } })
|
|
const calls = [
|
|
['c1', 'bash', '{"command":"printf hello"}'],
|
|
['c2', 'signal', '{}'],
|
|
['c3', 'edit', '{}'],
|
|
['c4', 'generic', '{}'],
|
|
['c5', 'throwing', '{}'],
|
|
['c6', 'unknown', 'not-json'],
|
|
['c7', 'rawTerminal', '{"value":"raw"}'],
|
|
['c8', 'undefinedViews', '{"value":8}'],
|
|
['c10', 'empty', '{}'],
|
|
['c11', 'terminalResult', '{}'],
|
|
['c12', 'symbolic', '{}'],
|
|
] as const
|
|
appendAssistant(result.session, [
|
|
{ type: 'text', text: 'Calling tools' },
|
|
...calls.map(([id, name, args]) => ({
|
|
type: 'tool-call' as const, id: id as never, name, arguments: args,
|
|
})),
|
|
])
|
|
for (const [id, name, args] of calls) {
|
|
result.session.append('tool/call', { turn: 1, step: 0, callId: id as never, name, arguments: args })
|
|
}
|
|
await tick()
|
|
expect(result.terminal.output).toContain('$ raw command')
|
|
result.terminal.send('/reasoning')
|
|
result.terminal.send('\r')
|
|
await tick()
|
|
expect(result.terminal.output).toContain('call presenter boom')
|
|
expect(result.terminal.output).toContain('Symbol(input)')
|
|
result.session.append('tool/result', {
|
|
turn: 1, step: 0, callId: 'c1' as never, content: [{ type: 'text', text: 'raw bash' }], isError: false,
|
|
}, { surfaceOp: 'append' })
|
|
result.session.append('tool/result', {
|
|
turn: 1, step: 0, callId: 'c2' as never, content: [{ type: 'text', text: 'stopped' }], isError: true,
|
|
}, { surfaceOp: 'append' })
|
|
result.session.append('tool/result', {
|
|
turn: 1, step: 0, callId: 'c3' as never, content: [{ type: 'text', text: 'done' }], isError: false,
|
|
}, { surfaceOp: 'append' })
|
|
result.session.append('tool/result', {
|
|
turn: 1, step: 0, callId: 'c4' as never, content: [{ type: 'text', text: 'raw generic' }], isError: false,
|
|
}, { surfaceOp: 'append' })
|
|
result.session.append('tool/result', {
|
|
turn: 1, step: 0, callId: 'c5' as never, content: [{ type: 'text', text: 'raw throwing' }], isError: false,
|
|
meta: { value: 1 },
|
|
}, { surfaceOp: 'append' })
|
|
result.session.append('tool/result', {
|
|
turn: 1, step: 0, callId: 'c7' as never,
|
|
content: [
|
|
{ type: 'tool-call', id: 'inner' as never, name: 'inner', arguments: '{}' },
|
|
{ type: 'tool-result', toolCallId: 'inner' as never, content: [{ type: 'text', text: 'nested output' }] },
|
|
{ type: 'future-result' } as never,
|
|
],
|
|
isError: false,
|
|
}, { surfaceOp: 'append' })
|
|
result.session.append('tool/result', {
|
|
turn: 1, step: 0, callId: 'c8' as never, content: [{ type: 'text', text: '\nundefined presenter output\n\nkept tail\n' }], isError: false,
|
|
}, { surfaceOp: 'append' })
|
|
result.session.append('tool/result', {
|
|
turn: 1, step: 0, callId: 'c11' as never, content: [{ type: 'text', text: '\nconverted terminal\n\nfinished\n' }], isError: false,
|
|
}, { surfaceOp: 'append' })
|
|
result.session.append('tool/result', {
|
|
turn: 1, step: 0, callId: 'orphan' as never, content: [{ type: 'text', text: 'orphan result' }], isError: false,
|
|
}, { surfaceOp: 'append' })
|
|
await tick()
|
|
|
|
const output = result.terminal.output
|
|
expect(output).toContain('Run command')
|
|
expect(output).toContain('printf hello')
|
|
expect(output).toContain('more lines')
|
|
expect(output).toContain('SIGTERM')
|
|
expect(output).toContain('Edit files')
|
|
expect(output).toContain('Inspected')
|
|
expect(output).toContain('result text')
|
|
expect(output).toContain('Presenter failed')
|
|
expect(output).toContain('not-json')
|
|
expect(output).toContain('nested output')
|
|
expect(output).toContain('[future-result]')
|
|
expect(output).toContain('undefined presenter output')
|
|
expect(output).toContain('Empty card')
|
|
expect(output).toContain('converted terminal')
|
|
expect(output).toContain('orphan result')
|
|
|
|
result.terminal.send('/redraw')
|
|
result.terminal.send('\r')
|
|
await tick()
|
|
result.terminal.send('\x0f')
|
|
await tick()
|
|
expect(result.terminal.output).toContain('world')
|
|
expect(result.terminal.output).toContain('+ created')
|
|
await dispose(result)
|
|
})
|
|
|
|
it('rebuilds after a surface replacement and hides shadowed tool calls', async () => {
|
|
const result = await setup({ tools })
|
|
appendUser(result.session, 'old prompt')
|
|
const assistant = result.session.append('assistant/message', {
|
|
turn: 1,
|
|
step: 0,
|
|
provenance: { provider: 'mock', model: 'deepseek-v4-flash' },
|
|
content: [{ type: 'tool-call', id: 'old-call' as never, name: 'bash', arguments: '{}' }],
|
|
}, { surfaceOp: 'append' })
|
|
result.session.append('tool/call', {
|
|
turn: 1, step: 0, callId: 'old-call' as never, name: 'bash', arguments: '{}',
|
|
})
|
|
const toolResult = result.session.append('tool/result', {
|
|
turn: 1, step: 0, callId: 'old-call' as never, content: [{ type: 'text', text: 'old output' }], isError: false,
|
|
}, { surfaceOp: 'append' })
|
|
const start = result.session.surface.nodes[0] as number
|
|
result.session.append('context/message', {
|
|
content: [{ type: 'text', text: 'summary replacement' }],
|
|
source: { kind: 'plugin', plugin: 'compact' },
|
|
}, {
|
|
surfaceOp: { op: 'replace', start, end: toolResult.seq },
|
|
sourceEventSeqs: [start, assistant.seq, toolResult.seq],
|
|
})
|
|
await tick()
|
|
|
|
result.terminal.resize(89)
|
|
await tick()
|
|
const lastFullRender = result.terminal.output.slice(result.terminal.output.lastIndexOf('\x1b[2J'))
|
|
expect(lastFullRender).toContain('summary replacement')
|
|
expect(lastFullRender).not.toContain('old output')
|
|
await dispose(result)
|
|
})
|
|
})
|
|
|
|
describe('TUI user-interaction dialogs', () => {
|
|
it('answers single-select, multi-select, custom, and optionless questions', async () => {
|
|
const result = await setup({ config: { maxQuestionOptions: 1 } })
|
|
|
|
const single = result.ctx.userInteraction.ask({
|
|
questions: [{
|
|
id: 'mode', header: 'Mode', question: 'Choose a mode',
|
|
options: [{ label: 'Safe', description: 'Use checks' }, { label: 'Fast' }],
|
|
}],
|
|
})
|
|
await tick()
|
|
expect(result.terminal.output).toContain('Choose a mode')
|
|
expect(result.terminal.output).toContain('1/2')
|
|
result.terminal.send('\x1b[B')
|
|
result.terminal.send('\r')
|
|
await expect(single).resolves.toEqual({ answers: [{ id: 'mode', selected: ['Fast'] }] })
|
|
|
|
const multi = result.ctx.userInteraction.ask({
|
|
questions: [{ id: 'targets', question: 'Pick targets', multiSelect: true, options: [{ label: 'Code' }, { label: 'Docs' }] }],
|
|
})
|
|
await tick()
|
|
result.terminal.send(' ')
|
|
result.terminal.send('\x1b[B')
|
|
result.terminal.send(' ')
|
|
result.terminal.send('\r')
|
|
await expect(multi).resolves.toEqual({ answers: [{ id: 'targets', selected: ['Code', 'Docs'] }] })
|
|
|
|
const custom = result.ctx.userInteraction.ask({
|
|
questions: [{ id: 'other', question: 'Choose or type', options: [{ label: 'Default' }] }],
|
|
})
|
|
await tick()
|
|
result.terminal.send('c')
|
|
result.terminal.send('my choice')
|
|
result.terminal.send('\r')
|
|
await expect(custom).resolves.toEqual({ answers: [{ id: 'other', selected: [], custom: 'my choice' }] })
|
|
|
|
const free = result.ctx.userInteraction.ask({ questions: [{ id: 'note', question: 'Add a note' }] })
|
|
await tick()
|
|
result.terminal.send('\r')
|
|
await tick()
|
|
expect(result.terminal.output).toContain('Enter an answer before submitting')
|
|
result.terminal.send('ship it')
|
|
result.terminal.send('\r')
|
|
await expect(free).resolves.toEqual({ answers: [{ id: 'note', selected: [], custom: 'ship it' }] })
|
|
await dispose(result)
|
|
})
|
|
|
|
it('handles option wrapping, deselection errors, and returning from custom input', async () => {
|
|
const result = await setup({ config: { color: true } })
|
|
const single = result.ctx.userInteraction.ask({
|
|
questions: [{ id: 'single', question: 'Single options', options: [{ label: 'One' }, { label: 'Two' }] }],
|
|
})
|
|
const singleRejected = expect(single).rejects.toMatchObject({ code: 'ASK_ABORTED' })
|
|
await tick()
|
|
expect(result.terminal.output).toContain('Two')
|
|
result.terminal.send('\x03')
|
|
await singleRejected
|
|
|
|
const answer = result.ctx.userInteraction.ask({
|
|
questions: [{
|
|
id: 'options',
|
|
question: 'Exercise options',
|
|
multiSelect: true,
|
|
options: [{ label: 'One', description: 'first' }, { label: 'Two' }],
|
|
}],
|
|
})
|
|
const rejected = expect(answer).rejects.toMatchObject({ code: 'ASK_ABORTED' })
|
|
await tick()
|
|
result.terminal.send('\x1b[A')
|
|
result.terminal.send('\x1b[B')
|
|
result.terminal.send('\x1b[B')
|
|
result.terminal.send('\x1b[A')
|
|
result.terminal.send(' ')
|
|
await tick()
|
|
result.terminal.send('x')
|
|
result.terminal.send(' ')
|
|
result.terminal.send('\r')
|
|
await tick()
|
|
expect(result.terminal.output).toContain('Select at least one option')
|
|
result.terminal.send('c')
|
|
await tick()
|
|
result.terminal.send('\x1b')
|
|
await tick()
|
|
expect(result.terminal.output).toContain('Space toggle')
|
|
result.terminal.send('\x03')
|
|
await rejected
|
|
await dispose(result)
|
|
})
|
|
|
|
it('asks batches in order and rejects cancelled or aborted work', async () => {
|
|
const result = await setup()
|
|
const preAborted = new AbortController()
|
|
preAborted.abort()
|
|
await expect(result.ctx.userInteraction.ask({
|
|
questions: [{ id: 'pre-aborted', question: 'Already cancelled?' }],
|
|
signal: preAborted.signal,
|
|
})).rejects.toMatchObject({ code: 'ASK_ABORTED' })
|
|
|
|
const batch = result.ctx.userInteraction.ask({
|
|
questions: [
|
|
{ id: 'first', question: 'First?', options: [{ label: 'Yes' }] },
|
|
{ id: 'second', question: 'Second?' },
|
|
],
|
|
})
|
|
await tick()
|
|
result.terminal.send('\r')
|
|
await tick()
|
|
expect(result.terminal.output).toContain('Second?')
|
|
result.terminal.send('done')
|
|
result.terminal.send('\r')
|
|
await expect(batch).resolves.toEqual({ answers: [
|
|
{ id: 'first', selected: ['Yes'] },
|
|
{ id: 'second', selected: [], custom: 'done' },
|
|
] })
|
|
|
|
const cancelled = result.ctx.userInteraction.ask({ questions: [{ id: 'cancel', question: 'Cancel?' }] })
|
|
const cancelledExpectation = expect(cancelled).rejects.toMatchObject({ code: 'ASK_ABORTED' })
|
|
await tick()
|
|
result.terminal.send('\x1b')
|
|
await cancelledExpectation
|
|
|
|
const controller = new AbortController()
|
|
const active = result.ctx.userInteraction.ask({ questions: [{ id: 'active', question: 'Active?' }], signal: controller.signal })
|
|
const queuedController = new AbortController()
|
|
const queued = result.ctx.userInteraction.ask({ questions: [{ id: 'queued', question: 'Queued?' }], signal: queuedController.signal })
|
|
const activeExpectation = expect(active).rejects.toMatchObject({ code: 'ASK_ABORTED' })
|
|
const queuedExpectation = expect(queued).rejects.toMatchObject({ code: 'ASK_ABORTED' })
|
|
await tick()
|
|
queuedController.abort()
|
|
controller.abort()
|
|
await activeExpectation
|
|
await queuedExpectation
|
|
await dispose(result)
|
|
})
|
|
|
|
it('rejects active and queued dialogs on disposal', async () => {
|
|
const result = await setup()
|
|
const active = result.ctx.userInteraction.ask({ questions: [{ id: 'active', question: 'Active?' }] })
|
|
const queued = result.ctx.userInteraction.ask({ questions: [{ id: 'queued', question: 'Queued?' }] })
|
|
const activeExpectation = expect(active).rejects.toMatchObject({ code: 'ASK_ABORTED' })
|
|
const queuedExpectation = expect(queued).rejects.toMatchObject({ code: 'ASK_ABORTED' })
|
|
await tick()
|
|
await result.controller.dispose()
|
|
await activeExpectation
|
|
await queuedExpectation
|
|
await expect(result.ctx.userInteraction.ask({ questions: [{ id: 'late', question: 'Late?' }] }))
|
|
.rejects.toMatchObject({ code: 'NO_PROVIDER' })
|
|
await result.ctx.fiber.dispose()
|
|
})
|
|
})
|
|
|
|
describe('terminal mounting', () => {
|
|
it('starts immediately when the configured agent already exists', async () => {
|
|
const ctx = new Context()
|
|
await ctx.plugin(SessionStore)
|
|
await ctx.plugin(AgentRegistry)
|
|
await ctx.plugin(UserInteractionService)
|
|
ctx.provide('tools', { get: () => undefined } as never)
|
|
const session = ctx.sessions.create(SessionId('mounted-session'))
|
|
ctx.agents.register({
|
|
id: AgentId('main'), options: {}, session, status: 'idle', ctx,
|
|
send() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(),
|
|
})
|
|
const terminal = new FakeTerminal()
|
|
mountTui(ctx, { color: false }, { terminal, exit: vi.fn() })
|
|
await tick()
|
|
expect(terminal.started).toBe(1)
|
|
await ctx.fiber.dispose()
|
|
})
|
|
|
|
it('waits for its configured agent before starting the TUI', async () => {
|
|
const ctx = new Context()
|
|
await ctx.plugin(SessionStore)
|
|
await ctx.plugin(AgentRegistry)
|
|
await ctx.plugin(UserInteractionService)
|
|
ctx.provide('tools', { get: () => undefined } as never)
|
|
const terminal = new FakeTerminal()
|
|
mountTui(ctx, { agent: 'main', color: false }, { terminal, exit: vi.fn() })
|
|
expect(terminal.started).toBe(0)
|
|
|
|
const otherSession = ctx.sessions.create(SessionId('other-session'))
|
|
ctx.agents.register({
|
|
id: AgentId('other'), options: {}, session: otherSession, status: 'idle', ctx,
|
|
send() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(),
|
|
})
|
|
expect(terminal.started).toBe(0)
|
|
|
|
const session = ctx.sessions.create(SessionId('late-session'))
|
|
const agent = {
|
|
id: AgentId('main'), options: {}, session, status: 'idle', ctx,
|
|
send() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(),
|
|
} as Agent
|
|
ctx.agents.register(agent)
|
|
await tick()
|
|
expect(terminal.started).toBe(1)
|
|
await ctx.fiber.dispose()
|
|
})
|
|
|
|
it('prints a matching live startup failure and exits instead of waiting forever', async () => {
|
|
const ctx = new Context()
|
|
await ctx.plugin(SessionStore)
|
|
await ctx.plugin(AgentRegistry)
|
|
await ctx.plugin(UserInteractionService)
|
|
ctx.provide('tools', { get: () => undefined } as never)
|
|
const terminal = new FakeTerminal()
|
|
const exit = vi.fn()
|
|
mountTui(ctx, { agent: 'main', color: false }, { terminal, exit })
|
|
|
|
ctx.agents.reportStartFailure(AgentId('other'), new Error('other failed'))
|
|
expect(terminal.output).toBe('')
|
|
expect(exit).not.toHaveBeenCalled()
|
|
ctx.agents.reportStartFailure(AgentId('main'), new Error('resume \u001b]2;failure-controlled\u0007'))
|
|
expect(terminal.output).toBe('ui-tui: agent "main" failed to start: resume \\x1b]2;failure-controlled\\x07\n')
|
|
expect(exit).toHaveBeenCalledWith(1)
|
|
|
|
const session = ctx.sessions.create(SessionId('must-not-start'))
|
|
ctx.agents.register({
|
|
id: AgentId('main'), options: {}, session, status: 'idle', ctx,
|
|
send() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(),
|
|
})
|
|
await tick()
|
|
expect(terminal.started).toBe(0)
|
|
await ctx.fiber.dispose()
|
|
})
|
|
|
|
it('prints a retained startup failure for a late-mounted TUI', async () => {
|
|
const ctx = new Context()
|
|
await ctx.plugin(SessionStore)
|
|
await ctx.plugin(AgentRegistry)
|
|
await ctx.plugin(UserInteractionService)
|
|
ctx.provide('tools', { get: () => undefined } as never)
|
|
ctx.agents.reportStartFailure(AgentId('main'), new Error('already failed'))
|
|
const terminal = new FakeTerminal()
|
|
const exit = vi.fn()
|
|
|
|
mountTui(ctx, { agent: 'main', color: false }, { terminal, exit })
|
|
|
|
expect(terminal.started).toBe(0)
|
|
expect(terminal.output).toBe('ui-tui: agent "main" failed to start: already failed\n')
|
|
expect(exit).toHaveBeenCalledWith(1)
|
|
await ctx.fiber.dispose()
|
|
})
|
|
|
|
it('rolls back providers, listeners, and terminal state when startup fails', async () => {
|
|
const ctx = new Context()
|
|
await ctx.plugin(SessionStore)
|
|
await ctx.plugin(AgentRegistry)
|
|
await ctx.plugin(UserInteractionService)
|
|
ctx.provide('tools', { get: () => undefined } as never)
|
|
const session = ctx.sessions.create(SessionId('failed-start-session'))
|
|
ctx.agents.register({
|
|
id: AgentId('main'), options: {}, session, status: 'running', ctx,
|
|
send() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(),
|
|
})
|
|
const terminal = new FakeTerminal()
|
|
terminal.start = () => { throw new Error('terminal startup failed') }
|
|
|
|
expect(() => createTuiChat(ctx, { color: false }, { terminal, exit: vi.fn() }))
|
|
.toThrow('terminal startup failed')
|
|
expect(terminal.stopped).toBe(1)
|
|
expect(terminal.progress).toEqual([false, true, false])
|
|
await expect(ctx.userInteraction.ask({ questions: [{ id: 'late', question: 'Late?' }] }))
|
|
.rejects.toMatchObject({ code: 'NO_PROVIDER' })
|
|
session.append('assistant/chunk', {
|
|
turn: 1,
|
|
step: 0,
|
|
chunk: { type: 'text-delta', index: 0, text: 'must not render' },
|
|
})
|
|
await tick()
|
|
expect(terminal.output).not.toContain('must not render')
|
|
await ctx.fiber.dispose()
|
|
})
|
|
|
|
it('throws when createTuiChat is called without the configured agent', async () => {
|
|
const ctx = new Context()
|
|
await ctx.plugin(AgentRegistry)
|
|
await ctx.plugin(UserInteractionService)
|
|
ctx.provide('tools', { get: () => undefined } as never)
|
|
const runtime: TuiRuntime = { terminal: new FakeTerminal(), exit: vi.fn() }
|
|
expect(() => createTuiChat(ctx, { agent: 'missing' }, runtime)).toThrow('is not running')
|
|
await ctx.fiber.dispose()
|
|
})
|
|
})
|