mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
Treat an absolute path.relative() result as a cross-volume path instead of incorrectly abbreviating it beneath the user's home directory. Allow embeddings to project a logical footer cwd without changing the operational session cwd. The recorded-session harness now uses a POSIX-shaped display alias for both the footer and filesystem result paths, preserving the existing pre-normalization layout width on every host. Keep runtime-provided labels behind terminal-control escaping, cover that boundary, and document the embedding contract.
947 lines
37 KiB
TypeScript
947 lines
37 KiB
TypeScript
import { homedir } from 'node:os'
|
|
import { join } from 'node:path'
|
|
import { describe, expect, it, vi } from 'vitest'
|
|
import { Context } from 'cordis'
|
|
import type { Terminal } from '@earendil-works/pi-tui'
|
|
import AgentRegistry, { 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 home = homedir()
|
|
const homeResult = await setup({
|
|
cwd: home,
|
|
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 childResult = await setup({ cwd: join(home, 'projects', 'dsh-tui') })
|
|
expect(childResult.terminal.output).toContain(join('~', 'projects', 'dsh-tui'))
|
|
await dispose(childResult)
|
|
|
|
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)
|
|
|
|
const logicalResult = await setup({
|
|
cwd: '/host/worktree',
|
|
formatCwd: cwd => `logical:${cwd}\x1b`,
|
|
})
|
|
expect(logicalResult.terminal.output).toContain('logical:/host/worktree\\x1b')
|
|
await dispose(logicalResult)
|
|
})
|
|
|
|
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: unrelatedSession.id, 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('main'))
|
|
ctx.agents.register({
|
|
id: session.id, 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, { sessionId: 'late-session', color: false }, { terminal, exit: vi.fn() })
|
|
expect(terminal.started).toBe(0)
|
|
|
|
const otherSession = ctx.sessions.create(SessionId('other-session'))
|
|
ctx.agents.register({
|
|
id: otherSession.id, 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: session.id, 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, { sessionId: 'main-session', color: false }, { terminal, exit })
|
|
|
|
ctx.emit('agent-loop/config-start-failed', SessionId('other-session'), new Error('other failed'))
|
|
expect(terminal.output).toBe('')
|
|
expect(exit).not.toHaveBeenCalled()
|
|
ctx.emit('agent-loop/config-start-failed', SessionId('main-session'), new Error('resume \u001b]2;failure-controlled\u0007'))
|
|
expect(terminal.output).toBe('ui-tui: session "main-session" failed to start: Error: resume \\x1b]2;failure-controlled\\x07\n')
|
|
expect(exit).toHaveBeenCalledWith(1)
|
|
|
|
const session = ctx.sessions.create(SessionId('main-session'))
|
|
ctx.agents.register({
|
|
id: session.id, options: {}, session, status: 'idle', ctx,
|
|
send() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(),
|
|
})
|
|
await tick()
|
|
expect(terminal.started).toBe(0)
|
|
await ctx.fiber.dispose()
|
|
})
|
|
|
|
it('renders an uncoercible startup failure without escaping the display boundary', 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, { sessionId: 'main-session', color: false }, { terminal, exit })
|
|
ctx.emit('agent-loop/config-start-failed', SessionId('main-session'), {
|
|
toString(): string { throw new Error('coercion failed') },
|
|
})
|
|
|
|
expect(terminal.started).toBe(0)
|
|
expect(terminal.output).toBe('ui-tui: session "main-session" failed to start: <unrenderable thrown value>\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: session.id, 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, { sessionId: 'failed-start-session', 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, { sessionId: 'missing' }, runtime)).toThrow('is not running')
|
|
await ctx.fiber.dispose()
|
|
})
|
|
})
|