1361 lines
53 KiB
TypeScript
1361 lines
53 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 CommandService, { type CommandInvocation } from '@deepseek-ai/dsh-commands'
|
|
import SessionStore, { SessionId, type JsonValue } from '@deepseek-ai/dsh-session'
|
|
import type { ToolDefinition } from '@deepseek-ai/dsh-tools'
|
|
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
|
|
import SessionQueryService from '@deepseek-ai/dsh-session-query'
|
|
import SessionReferenceService, { formatSessionReferenceMention } from '@deepseek-ai/dsh-session-reference'
|
|
import type {} from '@deepseek-ai/dsh-llm-retry'
|
|
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 },
|
|
{ turn: 2, step: 0 },
|
|
)
|
|
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('counts failed and recovered request usage once per step', async () => {
|
|
const result = await setup()
|
|
result.session.append('assistant/chunk', {
|
|
turn: 1,
|
|
step: 1,
|
|
chunk: { type: 'usage', usage: { inputTokens: 10, outputTokens: 2 } },
|
|
})
|
|
result.session.append('llm/retry', {
|
|
turn: 1,
|
|
step: 1,
|
|
retry: 1,
|
|
maxRetries: 2,
|
|
delayMs: 500,
|
|
failure: { message: 'temporary', code: 'SERVER' },
|
|
})
|
|
result.session.append('assistant/chunk', {
|
|
turn: 1,
|
|
step: 2,
|
|
chunk: { type: 'usage', usage: { inputTokens: 7, outputTokens: 3 } },
|
|
})
|
|
appendAssistant(
|
|
result.session,
|
|
[{ type: 'text', text: 'recovered' }],
|
|
{ inputTokens: 7, outputTokens: 3 },
|
|
{ turn: 1, step: 2 },
|
|
)
|
|
await tick()
|
|
|
|
expect(result.terminal.output).toContain('↑17 ↓5')
|
|
await dispose(result)
|
|
})
|
|
|
|
it('retracts a failed live stream and renders its durable retry status', async () => {
|
|
const result = await setup()
|
|
result.session.append('assistant/chunk', {
|
|
turn: 1,
|
|
step: 1,
|
|
chunk: { type: 'text-delta', index: 0, text: 'discarded partial answer' },
|
|
})
|
|
result.session.append('llm/retry', {
|
|
turn: 1,
|
|
step: 1,
|
|
retry: 1,
|
|
maxRetries: 2,
|
|
delayMs: 500,
|
|
failure: { message: 'rate limited', code: 'RATE_LIMIT', status: 429 },
|
|
})
|
|
result.session.append('llm/retry', {
|
|
turn: 1,
|
|
step: 2,
|
|
retry: 2,
|
|
maxRetries: 2,
|
|
delayMs: 1_000,
|
|
failure: { message: 'failed before chunks', code: 'SERVER', status: 503 },
|
|
})
|
|
await tick()
|
|
|
|
expect(result.terminal.output).toContain('Retrying model request (1/2) in 500ms: rate limited')
|
|
expect(result.terminal.output).toContain('Retrying model request (2/2) in 1000ms: failed before chunks')
|
|
await dispose(result)
|
|
})
|
|
|
|
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)
|
|
})
|
|
|
|
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('combines session autocomplete with files and prepares send/steer references asynchronously', async () => {
|
|
let sourceId = SessionId('uninitialized')
|
|
const result = await setup({
|
|
async configureContext(ctx) {
|
|
ctx.provide('tools', { get: () => undefined } as never)
|
|
await ctx.plugin(SessionQueryService)
|
|
await ctx.plugin(SessionReferenceService)
|
|
const source = ctx.sessions.create(SessionId('source-session'), { meta: { cwd: process.cwd(), createdAt: 1 } })
|
|
sourceId = source.id
|
|
appendUser(source, 'source background')
|
|
ctx.sessions.create(SessionId('no-cwd'), { meta: { createdAt: 2 } })
|
|
},
|
|
})
|
|
|
|
result.terminal.send('@no-cwd')
|
|
await tick()
|
|
expect(result.terminal.output).toContain('Session · no-cwd')
|
|
expect(result.terminal.output).toContain('(no cwd)')
|
|
result.terminal.send('\x03')
|
|
|
|
result.terminal.send('@source-session')
|
|
await tick()
|
|
result.terminal.send('\t')
|
|
await tick()
|
|
result.terminal.send('\r')
|
|
await tick()
|
|
expect(result.agent.sent).toEqual([[{ type: 'text', text: '@source-session' }]])
|
|
expect(result.agent.sentOptions[0]?.contexts).toHaveLength(1)
|
|
|
|
const mention = formatSessionReferenceMention({ sessionId: sourceId, label: 'Source chat' })
|
|
expect(result.agent.sentOptions[0]?.contexts).toMatchObject([{
|
|
source: { kind: 'plugin', plugin: 'session-reference' },
|
|
meta: { kind: 'session-reference', references: [{ sessionId: 'source-session' }] },
|
|
}])
|
|
|
|
result.agent.status = 'running'
|
|
result.terminal.send(`steer ${mention}`)
|
|
result.terminal.send('\r')
|
|
await tick()
|
|
expect(result.agent.steered).toEqual([[{ type: 'text', text: 'steer @Source chat' }]])
|
|
expect(result.agent.steeredOptions[0]?.contexts).toHaveLength(1)
|
|
await dispose(result)
|
|
})
|
|
|
|
it('falls back cleanly for non-session, empty, failed, and superseded autocomplete requests', async () => {
|
|
const result = await setup({
|
|
async configureContext(ctx) {
|
|
ctx.provide('tools', { get: () => undefined } as never)
|
|
await ctx.plugin(SessionQueryService)
|
|
await ctx.plugin(SessionReferenceService)
|
|
},
|
|
})
|
|
const originalListCandidates = result.ctx.sessionReferences.listCandidates.bind(result.ctx.sessionReferences)
|
|
const listCandidates = vi.spyOn(result.ctx.sessionReferences, 'listCandidates')
|
|
|
|
result.terminal.send('plain')
|
|
result.terminal.send('\t')
|
|
await tick()
|
|
result.terminal.send('\x03')
|
|
|
|
result.terminal.send('/he')
|
|
result.terminal.send('\t')
|
|
await tick()
|
|
result.terminal.send('\x03')
|
|
|
|
listCandidates.mockRejectedValueOnce(new Error('candidate lookup failed'))
|
|
result.terminal.send('@failed')
|
|
await vi.waitFor(() => { expect(listCandidates).toHaveBeenCalled() })
|
|
result.terminal.send('\x03')
|
|
|
|
result.terminal.send('@empty')
|
|
await tick()
|
|
result.terminal.send('\x03')
|
|
|
|
let releaseFirst: (() => void) | undefined
|
|
let delayed = true
|
|
listCandidates.mockImplementation(async (...args) => {
|
|
if (!delayed) return originalListCandidates(...args)
|
|
delayed = false
|
|
await new Promise<void>((resolve) => { releaseFirst = resolve })
|
|
return []
|
|
})
|
|
result.terminal.send('@slow')
|
|
await vi.waitFor(() => { expect(releaseFirst).toBeTypeOf('function') })
|
|
result.terminal.send('x')
|
|
releaseFirst?.()
|
|
await tick()
|
|
await dispose(result)
|
|
})
|
|
|
|
it('keeps failed mention input and renders durable reference contexts as compact cards', async () => {
|
|
const result = await setup({
|
|
async configureContext(ctx) {
|
|
ctx.provide('tools', { get: () => undefined } as never)
|
|
await ctx.plugin(SessionQueryService)
|
|
await ctx.plugin(SessionReferenceService)
|
|
},
|
|
})
|
|
const missing = formatSessionReferenceMention({ sessionId: SessionId('missing'), label: 'Missing chat' })
|
|
result.terminal.send(`keep ${missing}`)
|
|
result.terminal.send('\r')
|
|
await tick()
|
|
expect(result.agent.sent).toHaveLength(0)
|
|
expect(result.terminal.output).toContain('Session reference failed')
|
|
expect(result.terminal.output).toContain('keep @[')
|
|
|
|
result.session.append('context/message', {
|
|
content: [{ type: 'text', text: 'secret full snapshot payload' }],
|
|
source: { kind: 'plugin', plugin: 'session-reference' },
|
|
meta: {
|
|
kind: 'session-reference',
|
|
version: 1,
|
|
references: [{ sessionId: 'source', label: 'Source', capturedThroughSeq: 2 }],
|
|
},
|
|
}, { surfaceOp: 'append' })
|
|
await tick()
|
|
expect(result.terminal.output).toContain('Referenced sessions · Source (source)')
|
|
expect(result.terminal.output).not.toContain('secret full snapshot payload')
|
|
|
|
const invalidCards: [JsonValue, string][] = [
|
|
[{ kind: 'other' }, 'invalid-kind'],
|
|
[{ kind: 'session-reference', references: [null] }, 'invalid-entry'],
|
|
[{ kind: 'session-reference', references: [{}] }, 'invalid-fields'],
|
|
]
|
|
for (const [meta, text] of invalidCards) {
|
|
result.session.append('context/message', {
|
|
content: [{ type: 'text', text }],
|
|
source: { kind: 'plugin', plugin: 'session-reference' },
|
|
meta,
|
|
}, { surfaceOp: 'append' })
|
|
}
|
|
result.session.append('context/message', {
|
|
content: [{ type: 'text', text: 'same-label snapshot' }],
|
|
source: { kind: 'plugin', plugin: 'session-reference' },
|
|
meta: { kind: 'session-reference', references: [{ sessionId: 'same', label: 'same' }] },
|
|
}, { surfaceOp: 'append' })
|
|
await tick()
|
|
expect(result.terminal.output).toContain('Referenced sessions · same')
|
|
await dispose(result)
|
|
})
|
|
|
|
it('reports malformed and unavailable references without enqueueing', async () => {
|
|
const malformed = await setup()
|
|
malformed.terminal.send('use dsh-session:IiJ')
|
|
malformed.terminal.send('\r')
|
|
await tick()
|
|
expect(malformed.agent.sent).toHaveLength(0)
|
|
expect(malformed.terminal.output).toContain('Invalid session reference')
|
|
await dispose(malformed)
|
|
|
|
const unavailable = await setup()
|
|
const mention = formatSessionReferenceMention({ sessionId: SessionId('source') })
|
|
unavailable.terminal.send(`use ${mention}`)
|
|
unavailable.terminal.send('\r')
|
|
await tick()
|
|
expect(unavailable.agent.sent).toHaveLength(0)
|
|
expect(unavailable.terminal.output).toContain('Session reference capability unavailable')
|
|
await dispose(unavailable)
|
|
})
|
|
|
|
it('clears a retyped successful mention and aborts pending preparation on disposal', async () => {
|
|
const result = await setup({
|
|
async configureContext(ctx) {
|
|
ctx.provide('tools', { get: () => undefined } as never)
|
|
await ctx.plugin(SessionQueryService)
|
|
await ctx.plugin(SessionReferenceService)
|
|
ctx.sessions.create(SessionId('source'))
|
|
},
|
|
})
|
|
const mention = formatSessionReferenceMention({ sessionId: SessionId('source') })
|
|
const value = `use ${mention}`
|
|
let release: (() => void) | undefined
|
|
const prepare = vi.spyOn(result.ctx.sessionReferences, 'prepare').mockImplementation(
|
|
(_agent, content) => new Promise((resolve) => {
|
|
release = () => { resolve({ content, contexts: [] }) }
|
|
}),
|
|
)
|
|
result.terminal.send(value)
|
|
result.terminal.send('\r')
|
|
await vi.waitFor(() => { expect(prepare).toHaveBeenCalledOnce() })
|
|
result.terminal.send(value)
|
|
release?.()
|
|
await tick()
|
|
expect(result.agent.sent).toEqual([[{ type: 'text', text: 'use @source' }]])
|
|
|
|
let rejectPreparation: (() => void) | undefined
|
|
prepare.mockImplementation(() => new Promise((_resolve, reject) => {
|
|
rejectPreparation = () => { reject(new Error('delayed failure')) }
|
|
}))
|
|
result.terminal.send(value)
|
|
result.terminal.send('\r')
|
|
await vi.waitFor(() => { expect(rejectPreparation).toBeTypeOf('function') })
|
|
result.terminal.send('new draft')
|
|
rejectPreparation?.()
|
|
await tick()
|
|
expect(result.terminal.output).toContain('delayed failure')
|
|
result.terminal.send('\x03')
|
|
|
|
let pendingSignal: AbortSignal | undefined
|
|
prepare.mockImplementation((_agent, _content, _references, signal) => new Promise((_resolve, reject) => {
|
|
pendingSignal = signal
|
|
signal?.addEventListener('abort', () => { reject(new Error('aborted')) }, { once: true })
|
|
}))
|
|
result.terminal.send(value)
|
|
result.terminal.send('\r')
|
|
await vi.waitFor(() => { expect(pendingSignal).toBeDefined() })
|
|
await result.controller.dispose()
|
|
expect(pendingSignal?.aborted).toBe(true)
|
|
await tick()
|
|
await result.ctx.fiber.dispose()
|
|
|
|
const lateSuccess = await setup({
|
|
async configureContext(ctx) {
|
|
ctx.provide('tools', { get: () => undefined } as never)
|
|
await ctx.plugin(SessionQueryService)
|
|
await ctx.plugin(SessionReferenceService)
|
|
ctx.sessions.create(SessionId('source'))
|
|
},
|
|
})
|
|
let resolveAfterDispose: (() => void) | undefined
|
|
const latePrepare = vi.spyOn(lateSuccess.ctx.sessionReferences, 'prepare').mockImplementation(
|
|
(_agent, content) => new Promise((resolve) => {
|
|
resolveAfterDispose = () => { resolve({ content, contexts: [] }) }
|
|
}),
|
|
)
|
|
lateSuccess.terminal.send(value)
|
|
lateSuccess.terminal.send('\r')
|
|
await vi.waitFor(() => { expect(latePrepare).toHaveBeenCalledOnce() })
|
|
await lateSuccess.controller.dispose()
|
|
resolveAfterDispose?.()
|
|
await tick()
|
|
expect(lateSuccess.agent.sent).toHaveLength(0)
|
|
await lateSuccess.ctx.fiber.dispose()
|
|
})
|
|
|
|
it('discovers and executes plugin commands, then removes TUI-local commands on disposal', async () => {
|
|
const result = await setup()
|
|
const handler = vi.fn(({ rawInput }: CommandInvocation) => ({
|
|
kind: 'success' as const,
|
|
text: `PLUGIN:${rawInput}`,
|
|
}))
|
|
result.ctx.commands.register({
|
|
name: 'plugin-check',
|
|
description: 'Run a plugin command',
|
|
input: { hint: '<value>' },
|
|
handler,
|
|
})
|
|
result.ctx.commands.register({
|
|
name: 'plugin-fail',
|
|
description: 'Fail a plugin command',
|
|
handler: () => { throw new Error('plugin command exploded') },
|
|
})
|
|
|
|
result.terminal.send('/plugin-check value ')
|
|
result.terminal.send('\r')
|
|
await tick()
|
|
|
|
expect(handler).toHaveBeenCalledTimes(1)
|
|
const invocation = handler.mock.calls[0]?.[0]
|
|
expect(invocation?.agent).toBe(result.agent)
|
|
// pi-tui's Editor owns terminal-line normalization and removes trailing
|
|
// spaces before onSubmit; the registry preserves the adapter-delivered line.
|
|
expect(invocation?.rawInput).toBe(' value')
|
|
expect(result.terminal.output).toContain('PLUGIN: value')
|
|
result.terminal.send('/plugin-fail')
|
|
result.terminal.send('\r')
|
|
await tick()
|
|
expect(result.terminal.output).toContain('Command failed: plugin command exploded')
|
|
result.terminal.send('/help')
|
|
result.terminal.send('\r')
|
|
await tick()
|
|
expect(result.terminal.output).toContain('/plugin-check <value> — Run a plugin command')
|
|
expect(result.ctx.commands.list(result.agent).map(command => command.name)).toContain('help')
|
|
|
|
await result.controller.dispose()
|
|
expect(result.ctx.commands.list(result.agent).map(command => command.name)).toEqual([
|
|
'plugin-check',
|
|
'plugin-fail',
|
|
])
|
|
await result.ctx.fiber.dispose()
|
|
})
|
|
|
|
it('aborts an in-flight plugin command during TUI disposal', async () => {
|
|
const result = await setup()
|
|
let started!: () => void
|
|
const ready = new Promise<void>((resolve) => { started = resolve })
|
|
let commandSignal: AbortSignal | undefined
|
|
result.ctx.commands.register({
|
|
name: 'wait-plugin',
|
|
description: 'Wait until disposal',
|
|
handler: ({ signal }) => {
|
|
commandSignal = signal
|
|
started()
|
|
return new Promise((resolve) => {
|
|
signal.addEventListener('abort', () => { resolve({ kind: 'error', text: 'late result' }) }, { once: true })
|
|
})
|
|
},
|
|
})
|
|
|
|
result.terminal.send('/wait-plugin')
|
|
result.terminal.send('\r')
|
|
await ready
|
|
await result.controller.dispose()
|
|
|
|
expect(commandSignal?.aborted).toBe(true)
|
|
expect(result.terminal.output).not.toContain('late result')
|
|
await result.ctx.fiber.dispose()
|
|
})
|
|
|
|
it('suppresses a successful plugin result that settles as TUI disposal starts', async () => {
|
|
const result = await setup()
|
|
let started!: () => void
|
|
const ready = new Promise<void>((resolve) => { started = resolve })
|
|
let resolveCommand!: (result: { kind: 'success'; text: string }) => void
|
|
result.ctx.commands.register({
|
|
name: 'late-success',
|
|
description: 'Resolve while the TUI closes',
|
|
handler: () => new Promise((resolve) => {
|
|
resolveCommand = resolve
|
|
started()
|
|
}),
|
|
})
|
|
|
|
result.terminal.send('/late-success')
|
|
result.terminal.send('\r')
|
|
await ready
|
|
resolveCommand({ kind: 'success', text: 'must not render after disposal' })
|
|
// Let the command boundary accept the result before disposal, but leave the
|
|
// TUI continuation queued so the success-side disposal guard owns the race.
|
|
await Promise.resolve()
|
|
await result.controller.dispose()
|
|
await tick()
|
|
|
|
expect(result.terminal.output).not.toContain('must not render after disposal')
|
|
await result.ctx.fiber.dispose()
|
|
})
|
|
|
|
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.session.append('turn/end', {
|
|
turn: 9,
|
|
reason: { kind: 'error', step: 1, failure: { message: 'structured provider failure', code: 'SERVER' } },
|
|
})
|
|
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('structured provider 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(CommandService)
|
|
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(CommandService)
|
|
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(CommandService)
|
|
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: 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(CommandService)
|
|
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 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(CommandService)
|
|
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')
|
|
await tick()
|
|
expect(ctx.commands.list(ctx.agents.get(SessionId('failed-start-session'))!)).toEqual([])
|
|
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(CommandService)
|
|
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()
|
|
})
|
|
})
|