mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
# Conflicts: # .agents/notes/implemented/feature/2026-07-21-cross-session-references.i18n.yaml # .agents/notes/implemented/feature/2026-07-21-cross-session-references.md # .agents/notes/implemented/feature/2026-07-21-cross-session-references.zh.md # packages/context/session-reference/README.i18n.yaml # packages/context/session-reference/README.md # packages/context/session-reference/README.zh.md # packages/context/session-reference/src/index.ts
4694 lines
193 KiB
TypeScript
4694 lines
193 KiB
TypeScript
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||
import { homedir, tmpdir } from 'node:os'
|
||
import { join, resolve } from 'node:path'
|
||
import { describe, expect, it, vi } from 'vitest'
|
||
import { Context } from 'cordis'
|
||
import { CombinedAutocompleteProvider, visibleWidth, type Terminal } from '@earendil-works/pi-tui'
|
||
import AgentRegistry, { agentEvents, AgentMessageId, assembleContextFor, type Agent } from '@deepseek-ai/dsh-agent'
|
||
import {
|
||
ReasoningEffortId,
|
||
type LlmCallConfig,
|
||
type LlmModelReasoningInfo,
|
||
} from '@deepseek-ai/dsh-llm'
|
||
import { GOAL_CHANGE_VERSION, GoalId, renderGoalChange, type GoalSnapshotChangeMeta } from '@deepseek-ai/dsh-goal'
|
||
import CommandService, { type CommandInvocation } from '@deepseek-ai/dsh-commands'
|
||
import SessionStore, { SessionId, type JsonValue, type SessionEvent, type SessionHeader, type TurnEndReason } from '@deepseek-ai/dsh-session'
|
||
import type { SessionRecord } from '@deepseek-ai/dsh-session-query'
|
||
import SkillService, { type SkillDefinition, type SkillSummary } from '@deepseek-ai/dsh-skill'
|
||
import type {} from '@deepseek-ai/dsh-session-title'
|
||
import type { ToolDefinition } from '@deepseek-ai/dsh-tools'
|
||
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
|
||
import SessionReferenceService, { formatSessionReferenceMention } from '@deepseek-ai/dsh-session-reference'
|
||
import type {} from '@deepseek-ai/dsh-llm-retry'
|
||
import {
|
||
createTuiChat,
|
||
FILE_REFERENCE_PROMPT,
|
||
mountTui,
|
||
renderSkillInvocation,
|
||
TuiPromptService,
|
||
resolveTuiConfig,
|
||
type TuiOverlayHost,
|
||
type TuiOverlaySession,
|
||
type TuiRuntime,
|
||
} from '../src/index.ts'
|
||
import { WorkspaceFileSearch } from '../src/chat/file-autocomplete.ts'
|
||
import {
|
||
appendAssistant,
|
||
appendUser,
|
||
createTuiTestHarness,
|
||
disposeTuiTestHarness,
|
||
type TuiHarnessOptions,
|
||
} from './harness.ts'
|
||
import { TestSessionQueryService } from './session-query.ts'
|
||
|
||
const UNUSED_TOOL_OUTPUT: ToolDefinition['output'] = {
|
||
schema: { type: 'null' },
|
||
render: () => [],
|
||
}
|
||
|
||
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()
|
||
// Let the harness default cwd ('/workspace') stand: a checkout-dependent
|
||
// process.cwd() longer than the 88-column fake terminal pushes the footer
|
||
// token counters off-screen and fails their assertions by location.
|
||
const result = await createTuiTestHarness(terminal, exit, options)
|
||
await tick()
|
||
return result
|
||
}
|
||
|
||
async function dispose(setupResult: Awaited<ReturnType<typeof setup>>): Promise<void> {
|
||
await disposeTuiTestHarness(setupResult)
|
||
}
|
||
|
||
function provideTokenMeter(ctx: Context): void {
|
||
ctx.provide('tokenMeter', {
|
||
measure() {
|
||
return { totalTokens: 0 }
|
||
},
|
||
} as never)
|
||
}
|
||
|
||
/** Minimal advisory-catalog llm stub for tests composing their own context. */
|
||
function provideLlmCatalog(ctx: Context): void {
|
||
ctx.provide('llm', {
|
||
listProviders: () => [],
|
||
listModels: () => Promise.resolve([]),
|
||
resolveModelInfo: (provider: string, model: string) => Promise.resolve({
|
||
provider,
|
||
id: model,
|
||
name: model,
|
||
}),
|
||
} as never)
|
||
}
|
||
|
||
describe('TUI config', () => {
|
||
it('defaults every direct-call TUI option', () => {
|
||
expect(resolveTuiConfig(undefined)).toEqual({
|
||
showReasoning: true,
|
||
maxToolOutputLines: 6,
|
||
maxQuestionOptions: 8,
|
||
maxModelOptions: 8,
|
||
maxResumeOptions: 8,
|
||
questionDialogWidth: 200,
|
||
questionDialogMaxHeight: 20,
|
||
modelDialogWidth: 76,
|
||
modelDialogMaxHeight: 20,
|
||
fileSearchMaxResults: 20,
|
||
fileSearchMaxEntries: 10_000,
|
||
fileSearchExcludedDirectories: ['.git', 'node_modules'],
|
||
showHardwareCursor: false,
|
||
theme: {
|
||
color: true,
|
||
truecolor: false,
|
||
leftPrompt: '${cwd}${git/worktree}${model}${token_meter/cache_hit_rate}${context}',
|
||
rightPrompt: '${timing}',
|
||
inputPrompt: '${symbol} ${indicator}',
|
||
inputPlaceholder: 'press enter to steer and esc to cancel',
|
||
},
|
||
title: 'DeepSeek Harness',
|
||
})
|
||
expect(resolveTuiConfig({
|
||
showReasoning: false,
|
||
maxToolOutputLines: 2,
|
||
maxQuestionOptions: 3,
|
||
maxModelOptions: 4,
|
||
maxResumeOptions: 5,
|
||
questionDialogWidth: 60,
|
||
questionDialogMaxHeight: 14,
|
||
modelDialogWidth: 64,
|
||
modelDialogMaxHeight: 16,
|
||
fileSearchMaxResults: 7,
|
||
fileSearchMaxEntries: 123,
|
||
fileSearchExcludedDirectories: ['.git', 'generated'],
|
||
showHardwareCursor: true,
|
||
theme: { color: false, truecolor: true },
|
||
title: 'DSH',
|
||
})).toEqual({
|
||
showReasoning: false,
|
||
maxToolOutputLines: 2,
|
||
maxQuestionOptions: 3,
|
||
maxModelOptions: 4,
|
||
maxResumeOptions: 5,
|
||
questionDialogWidth: 60,
|
||
questionDialogMaxHeight: 14,
|
||
modelDialogWidth: 64,
|
||
modelDialogMaxHeight: 16,
|
||
fileSearchMaxResults: 7,
|
||
fileSearchMaxEntries: 123,
|
||
fileSearchExcludedDirectories: ['.git', 'generated'],
|
||
showHardwareCursor: true,
|
||
theme: {
|
||
color: false,
|
||
truecolor: true,
|
||
leftPrompt: '${cwd}${git/worktree}${model}${token_meter/cache_hit_rate}${context}',
|
||
rightPrompt: '${timing}',
|
||
inputPrompt: '${symbol} ${indicator}',
|
||
inputPlaceholder: 'press enter to steer and esc to cancel',
|
||
},
|
||
title: 'DSH',
|
||
})
|
||
})
|
||
})
|
||
|
||
describe('resume command and /resume', () => {
|
||
const RESUME = 'dsh --resume {session}'
|
||
const header = (id: string, createdAt: number, cwd: string): SessionHeader =>
|
||
({ version: 0, id: SessionId(id), createdAt, cwd })
|
||
const resumeEvents = (
|
||
title: string,
|
||
provider = 'deepseek',
|
||
time = 100,
|
||
reason: TurnEndReason = { kind: 'completed' },
|
||
): SessionEvent[] => [
|
||
{ type: 'turn/start', seq: 0, time, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
|
||
{ type: 'user/message', seq: 1, time: time + 1, data: { content: [{ type: 'text', text: 'resume me' }], source: { kind: 'user' } }, surfaceOp: 'append' },
|
||
{ type: 'step/start', seq: 2, time: time + 2, data: { turn: 1, step: 1 } },
|
||
{ type: 'request/header', seq: 3, time: time + 3, data: { header: { config: { provider, model: 'model-1' } }, reason: 'initial' } },
|
||
{ type: 'assistant/message', seq: 4, time: time + 4, data: { turn: 1, step: 1, content: [{ type: 'text', text: 'done' }], provenance: { provider, model: 'model-1' } }, surfaceOp: 'append' },
|
||
{ type: 'step/end', seq: 5, time: time + 5, data: { turn: 1, step: 1 } },
|
||
{ type: 'turn/end', seq: 6, time: time + 6, data: { turn: 1, reason } },
|
||
{ type: 'session/title', seq: 7, time: time + 7, data: { title, messageSeqs: [1], source: { kind: 'fallback' } } },
|
||
]
|
||
|
||
it('prints the resume command on exit once the session is persisted', async () => {
|
||
const result = await setup({
|
||
cwd: '/workspace',
|
||
config: { resumeCommand: RESUME },
|
||
sessionPersistence: { list: async () => [header('main-session', 1000, '/workspace')] },
|
||
})
|
||
result.terminal.send('/exit')
|
||
result.terminal.send('\r')
|
||
await tick()
|
||
expect(result.terminal.output).toContain('To resume this session: dsh --resume main-session')
|
||
expect(result.exit).toHaveBeenCalledWith(0)
|
||
await dispose(result)
|
||
})
|
||
|
||
it('omits the exit hint when the session is not yet persisted', async () => {
|
||
const result = await setup({ cwd: '/workspace', config: { resumeCommand: RESUME } })
|
||
result.terminal.send('/exit')
|
||
result.terminal.send('\r')
|
||
await tick()
|
||
expect(result.terminal.output).not.toContain('To resume this session')
|
||
expect(result.exit).toHaveBeenCalledWith(0)
|
||
await dispose(result)
|
||
})
|
||
|
||
it('omits the exit hint when the session listing fails', async () => {
|
||
const result = await setup({
|
||
cwd: '/workspace',
|
||
config: { resumeCommand: RESUME },
|
||
sessionPersistence: { list: () => Promise.reject(new Error('disk gone')) },
|
||
})
|
||
result.terminal.send('/exit')
|
||
result.terminal.send('\r')
|
||
await tick()
|
||
expect(result.terminal.output).not.toContain('To resume this session')
|
||
expect(result.exit).toHaveBeenCalledWith(0)
|
||
await dispose(result)
|
||
})
|
||
|
||
it('opens a newest-active-first searchable selector and Esc clears before cancelling', async () => {
|
||
const older = header('older-session', 500, '/workspace')
|
||
const newer = header('newer-session', 2000, '/workspace')
|
||
const handoff = vi.fn<NonNullable<TuiRuntime['handoffResume']>>()
|
||
const result = await setup({
|
||
cwd: '/workspace',
|
||
config: { resumeCommand: RESUME },
|
||
handoffResume: handoff,
|
||
sessionPersistence: {
|
||
list: async () => [older, newer, header('foreign-session', 3000, '/elsewhere')],
|
||
load: async id => id === newer.id
|
||
? { meta: newer, events: resumeEvents('Newer product work', 'deepseek', 300) }
|
||
: { meta: older, events: resumeEvents('Older investigation', 'deepseek', 100) },
|
||
},
|
||
})
|
||
result.terminal.send('/resume')
|
||
result.terminal.send('\r')
|
||
await tick(); await tick()
|
||
const output = result.terminal.output
|
||
expect(output).toContain('Resume session')
|
||
expect(output).toContain('Newer product work')
|
||
expect(output).toContain('Older investigation')
|
||
expect(output).toContain('current · live')
|
||
expect(output.indexOf('Newer product work')).toBeLessThan(output.indexOf('Older investigation'))
|
||
expect(output).not.toContain('foreign-session')
|
||
result.terminal.send('Older')
|
||
await tick()
|
||
expect(result.terminal.output).toContain('⌕ Older')
|
||
result.terminal.send('\x1b')
|
||
await tick()
|
||
expect(result.terminal.output.slice(result.terminal.output.lastIndexOf('Resume session')))
|
||
.not.toContain('⌕ Older')
|
||
result.terminal.send('\x1b')
|
||
await tick()
|
||
expect(handoff).not.toHaveBeenCalled()
|
||
await dispose(result)
|
||
})
|
||
|
||
it('handles selector navigation, empty matches, and backspace search edits', async () => {
|
||
const target = header('keyboard-target', 10, '/workspace')
|
||
const result = await setup({
|
||
cwd: '/workspace',
|
||
sessionPersistence: {
|
||
list: async () => [target],
|
||
load: async () => ({ meta: target, events: resumeEvents('Keyboard target') }),
|
||
},
|
||
})
|
||
result.terminal.send('/resume')
|
||
result.terminal.send('\r')
|
||
await tick(); await tick()
|
||
result.terminal.send('\x1b[B')
|
||
result.terminal.send('\x1b[A')
|
||
result.terminal.send('\t')
|
||
result.terminal.send('zz')
|
||
result.terminal.send('\r')
|
||
await tick()
|
||
expect(result.terminal.output).toContain('No session matches this search')
|
||
result.terminal.send('\x7f')
|
||
result.terminal.send('\x7f')
|
||
await tick()
|
||
const cleared = result.terminal.output.slice(result.terminal.output.lastIndexOf('Resume session'))
|
||
expect(cleared).toContain('⌕ ')
|
||
expect(cleared).not.toContain('zz')
|
||
result.terminal.send('\r')
|
||
await tick()
|
||
expect(result.terminal.output).toContain('current session')
|
||
result.terminal.send('\x1b')
|
||
await dispose(result)
|
||
})
|
||
|
||
it('sanitizes bracketed-paste terminal controls before storing the search query', async () => {
|
||
const target = header('safe-target', 10, '/workspace')
|
||
const result = await setup({
|
||
cwd: '/workspace',
|
||
sessionPersistence: {
|
||
list: async () => [target],
|
||
load: async () => ({ meta: target, events: resumeEvents('Safe target') }),
|
||
},
|
||
})
|
||
result.terminal.send('/resume')
|
||
result.terminal.send('\r')
|
||
await tick(); await tick()
|
||
result.terminal.send('\x1b[200~Safe\x1b]0;own')
|
||
result.terminal.send('ed\x07 target\x1b[31m\x1b[201~')
|
||
await tick()
|
||
const rendered = result.terminal.output.slice(result.terminal.output.lastIndexOf('Resume session'))
|
||
expect(rendered).toContain('⌕ Safe target')
|
||
expect(rendered).not.toContain('owned')
|
||
expect(rendered).not.toContain('[31m')
|
||
result.terminal.send('\x1b')
|
||
result.terminal.send('Safe\x1b[200~\x1b[201~ target')
|
||
await tick()
|
||
expect(result.terminal.output.slice(result.terminal.output.lastIndexOf('Resume session')))
|
||
.toContain('⌕ Safe target')
|
||
await dispose(result)
|
||
})
|
||
|
||
it('pages by the number of candidates that fit the current viewport', async () => {
|
||
const targets = Array.from({ length: 8 }, (_, index) =>
|
||
header(`paged-${index}`, 1000 - index, '/workspace'))
|
||
const result = await setup({
|
||
cwd: '/workspace',
|
||
sessionPersistence: {
|
||
list: async () => targets,
|
||
load: async id => ({
|
||
meta: targets.find(target => target.id === id)!,
|
||
events: resumeEvents(`Paged ${id.slice('paged-'.length)}`, 'deepseek', 1000 - Number(id.slice('paged-'.length)) * 10),
|
||
}),
|
||
},
|
||
})
|
||
result.terminal.send('/resume')
|
||
result.terminal.send('\r')
|
||
await tick(); await tick()
|
||
result.terminal.send('\x1b[6~')
|
||
await tick()
|
||
const rendered = result.terminal.output.slice(result.terminal.output.lastIndexOf('Resume session'))
|
||
expect(rendered).toContain('❯ Paged 3')
|
||
result.terminal.send('\x1b[5~')
|
||
await tick()
|
||
expect(result.terminal.output.slice(result.terminal.output.lastIndexOf('Resume session')))
|
||
.toContain('❯ Untitled session')
|
||
result.terminal.resize(10)
|
||
await tick()
|
||
expect(result.terminal.output.slice(result.terminal.output.lastIndexOf('Resume session')))
|
||
.toContain('⌕')
|
||
result.terminal.send('\x03')
|
||
await dispose(result)
|
||
})
|
||
|
||
it('clips candidate count through the configured visible-session limit', async () => {
|
||
const targets = [header('limited-a', 10, '/workspace'), header('limited-b', 20, '/workspace')]
|
||
const result = await setup({
|
||
cwd: '/workspace',
|
||
config: { maxResumeOptions: 1 },
|
||
sessionPersistence: {
|
||
list: async () => targets,
|
||
load: async id => ({
|
||
meta: targets.find(target => target.id === id)!,
|
||
events: resumeEvents(`Limited ${id}`),
|
||
}),
|
||
},
|
||
})
|
||
result.terminal.send('/resume')
|
||
result.terminal.send('\r')
|
||
await tick(); await tick()
|
||
expect(result.terminal.output).toContain('(1 of 3)')
|
||
await dispose(result)
|
||
})
|
||
|
||
it.each([
|
||
[{ kind: 'aborted' }, 'cancelled'],
|
||
[{ kind: 'error', step: 1, message: 'failed' }, 'error'],
|
||
[{ kind: 'disposed' }, 'disposed'],
|
||
[{ kind: 'max-tokens' }, 'max tokens'],
|
||
[{ kind: 'interrupted' }, 'interrupted'],
|
||
[{ kind: 'future-result' } as unknown as TurnEndReason, 'unknown result'],
|
||
] as const)('renders the last turn result %s', async (reason, label) => {
|
||
const target = header(`turn-${label}`, 10, '/workspace')
|
||
const result = await setup({
|
||
cwd: '/workspace',
|
||
sessionPersistence: {
|
||
list: async () => [target],
|
||
load: async () => ({ meta: target, events: resumeEvents(`Turn ${label}`, 'deepseek', 100, reason) }),
|
||
},
|
||
})
|
||
result.terminal.send('/resume')
|
||
result.terminal.send('\r')
|
||
await tick(); await tick()
|
||
expect(result.terminal.output).toContain(`turn 1: ${label}`)
|
||
await dispose(result)
|
||
})
|
||
|
||
it('refuses while running instead of cancelling or switching', async () => {
|
||
const result = await setup({ cwd: '/workspace', status: 'running' })
|
||
result.terminal.send('/resume')
|
||
result.terminal.send('\r')
|
||
await tick()
|
||
expect(result.terminal.output).toContain('finish or be cancelled first')
|
||
expect(result.agent.cancelled).toEqual([])
|
||
await dispose(result)
|
||
})
|
||
|
||
it('warns when the optional session-query service is absent', async () => {
|
||
const result = await setup({ cwd: '/workspace', mountSessionQuery: false })
|
||
result.terminal.send('/resume')
|
||
result.terminal.send('\r')
|
||
await tick()
|
||
expect(result.terminal.output).toContain('session query is not mounted')
|
||
await dispose(result)
|
||
})
|
||
|
||
it('keeps persisted query records readable without a persistence service', async () => {
|
||
const target = header('query-only-persisted', 10, '/workspace')
|
||
const result = await setup({
|
||
cwd: '/workspace',
|
||
async configureContext(ctx) {
|
||
ctx.provide('tools', { get: () => undefined } as never)
|
||
ctx.provide('sessionQuery', {
|
||
listSessions: () => Promise.resolve([{
|
||
header: target,
|
||
live: false,
|
||
persisted: true,
|
||
}]),
|
||
readSession: () => Promise.resolve({
|
||
session: target,
|
||
events: resumeEvents('Query-only persisted session'),
|
||
}),
|
||
} as never)
|
||
},
|
||
})
|
||
result.terminal.send('/resume')
|
||
result.terminal.send('\r')
|
||
await tick(); await tick()
|
||
expect(result.terminal.output).toContain('Query-only persisted session')
|
||
expect(result.terminal.output).toContain('persisted')
|
||
expect(result.terminal.output).not.toContain('session cannot be loaded')
|
||
await dispose(result)
|
||
})
|
||
|
||
it('contains a session-query scan failure in the current TUI', async () => {
|
||
const result = await setup({
|
||
async configureContext(ctx) {
|
||
ctx.provide('tools', { get: () => undefined } as never)
|
||
ctx.provide('sessionQuery', {
|
||
listSessions: () => Promise.reject(new Error('index unavailable')),
|
||
} as never)
|
||
},
|
||
})
|
||
result.terminal.send('/resume')
|
||
result.terminal.send('\r')
|
||
await tick()
|
||
expect(result.terminal.output).toContain('Resume session scan failed: index unavailable')
|
||
expect(result.terminal.stopped).toBe(0)
|
||
await dispose(result)
|
||
})
|
||
|
||
it('supersedes a slower prior selector scan', async () => {
|
||
const first = Promise.withResolvers<SessionRecord[]>()
|
||
let calls = 0
|
||
const result = await setup({
|
||
async configureContext(ctx) {
|
||
ctx.provide('tools', { get: () => undefined } as never)
|
||
ctx.provide('sessionQuery', {
|
||
listSessions: () => ++calls === 1 ? first.promise : Promise.resolve([]),
|
||
} as never)
|
||
},
|
||
})
|
||
result.terminal.send('/resume')
|
||
result.terminal.send('\r')
|
||
result.terminal.send('/resume')
|
||
result.terminal.send('\r')
|
||
await tick()
|
||
first.reject(new Error('superseded scan failed'))
|
||
await tick()
|
||
expect(calls).toBe(2)
|
||
expect(result.terminal.output).toContain('No matching sessions')
|
||
expect(result.terminal.output).not.toContain('superseded scan failed')
|
||
result.terminal.send('\x1b[A')
|
||
result.terminal.send('\x1b[B')
|
||
await dispose(result)
|
||
})
|
||
|
||
it('drops a selector scan that resolves after TUI disposal', async () => {
|
||
const listing = Promise.withResolvers<SessionRecord[]>()
|
||
const result = await setup({
|
||
async configureContext(ctx) {
|
||
ctx.provide('tools', { get: () => undefined } as never)
|
||
ctx.provide('sessionQuery', { listSessions: () => listing.promise } as never)
|
||
},
|
||
})
|
||
result.terminal.send('/resume')
|
||
result.terminal.send('\r')
|
||
await tick()
|
||
await dispose(result)
|
||
listing.resolve([])
|
||
await tick()
|
||
expect(result.terminal.stopped).toBeGreaterThan(0)
|
||
})
|
||
|
||
it('drops loaded selector summaries when the TUI disposed during log reads', async () => {
|
||
const target = header('dispose-during-load', 10, '/workspace')
|
||
const loading = Promise.withResolvers<{ meta: SessionHeader; events: SessionEvent[] }>()
|
||
const result = await setup({
|
||
cwd: '/workspace',
|
||
sessionPersistence: {
|
||
list: async () => [target],
|
||
load: () => loading.promise,
|
||
},
|
||
})
|
||
result.terminal.send('/resume')
|
||
result.terminal.send('\r')
|
||
await tick()
|
||
await dispose(result)
|
||
loading.resolve({ meta: target, events: resumeEvents('Disposed load') })
|
||
await tick()
|
||
expect(result.terminal.stopped).toBeGreaterThan(0)
|
||
})
|
||
|
||
it('preflights route availability and corrupt sessions without losing the current TUI', async () => {
|
||
const missing = header('missing-route', 10, '/workspace')
|
||
const corrupt = header('corrupt', 30, '/workspace')
|
||
const result = await setup({
|
||
cwd: '/workspace',
|
||
config: { resumeCommand: RESUME },
|
||
sessionPersistence: {
|
||
list: async () => [missing, corrupt],
|
||
load: async (id) => {
|
||
if (id === corrupt.id) throw new Error('checksum mismatch')
|
||
return {
|
||
meta: missing,
|
||
events: resumeEvents('Missing adapter', 'absent-provider'),
|
||
}
|
||
},
|
||
},
|
||
})
|
||
result.terminal.send('/resume')
|
||
result.terminal.send('\r')
|
||
await tick(); await tick()
|
||
expect(result.terminal.output).toContain('Missing adapter')
|
||
expect(result.terminal.output).toContain('absent-provider/model-1')
|
||
expect(result.terminal.output).toContain('Unreadable session')
|
||
result.terminal.send('Missing adapter')
|
||
result.terminal.send('\r')
|
||
await tick()
|
||
expect(result.terminal.output).toContain('route is currently unavailable')
|
||
expect(result.terminal.stopped).toBe(0)
|
||
await dispose(result)
|
||
})
|
||
|
||
it('keeps a session already live in this runtime visible but disabled', async () => {
|
||
const target = header('live-target', 10, '/workspace')
|
||
const handoff = vi.fn<NonNullable<TuiRuntime['handoffResume']>>()
|
||
const result = await setup({
|
||
cwd: '/workspace',
|
||
handoffResume: handoff,
|
||
async configureContext(ctx) {
|
||
ctx.provide('tools', { get: () => undefined } as never)
|
||
ctx.provide('sessionQuery', {
|
||
listSessions: () => Promise.resolve([{
|
||
header: target,
|
||
live: true,
|
||
persisted: true,
|
||
}]),
|
||
readSession: () => Promise.resolve({
|
||
session: target,
|
||
events: resumeEvents('Live target'),
|
||
}),
|
||
} as never)
|
||
},
|
||
})
|
||
result.terminal.send('/resume')
|
||
result.terminal.send('\r')
|
||
await tick(); await tick()
|
||
result.terminal.send('Live target')
|
||
result.terminal.send('\r')
|
||
await tick()
|
||
expect(result.terminal.output).toContain('session is already live in this runtime')
|
||
expect(handoff).not.toHaveBeenCalled()
|
||
await dispose(result)
|
||
})
|
||
|
||
it('falls back to assistant provenance and header creation time for sparse logs', async () => {
|
||
const assistantOnly = header('assistant-route', 20, '/workspace')
|
||
const empty = header('empty-log', 10, '/workspace')
|
||
const events = resumeEvents('Assistant route', 'deepseek')
|
||
.filter(event => event.type !== 'request/header')
|
||
.map((event, seq) => ({ ...event, seq })) as SessionEvent[]
|
||
const result = await setup({
|
||
cwd: '/workspace',
|
||
sessionPersistence: {
|
||
list: async () => [assistantOnly, empty],
|
||
load: async id => id === assistantOnly.id
|
||
? { meta: assistantOnly, events }
|
||
: { meta: empty, events: [] },
|
||
},
|
||
})
|
||
result.terminal.send('/resume')
|
||
result.terminal.send('\r')
|
||
await tick(); await tick()
|
||
expect(result.terminal.output).toContain('deepseek/model-1')
|
||
expect(result.terminal.output).toContain(new Date(empty.createdAt).toISOString())
|
||
await dispose(result)
|
||
})
|
||
|
||
it('flushes, releases the terminal, and invokes one host handoff for the same SessionId', async () => {
|
||
const target = header('target-session', 10, '/workspace')
|
||
const handoff = vi.fn<NonNullable<TuiRuntime['handoffResume']>>(() => Promise.reject(new Error('test host retained process')))
|
||
const result = await setup({
|
||
cwd: '/workspace',
|
||
handoffResume: handoff,
|
||
sessionPersistence: {
|
||
list: async () => [target],
|
||
load: async () => ({ meta: target, events: resumeEvents('Target session') }),
|
||
},
|
||
})
|
||
result.terminal.send('/resume')
|
||
result.terminal.send('\r')
|
||
await tick(); await tick()
|
||
result.terminal.send('Target session')
|
||
result.terminal.send('\r')
|
||
await tick(); await tick()
|
||
expect(handoff).toHaveBeenCalledTimes(1)
|
||
expect(handoff).toHaveBeenCalledWith(target.id)
|
||
expect(result.terminal.stopped).toBeGreaterThan(0)
|
||
expect(result.terminal.output).toContain('Resume handoff failed: test host retained process')
|
||
await dispose(result)
|
||
})
|
||
|
||
it('restores the UI when a host returns instead of replacing the process', async () => {
|
||
const target = header('returning-host', 10, '/workspace')
|
||
const result = await setup({
|
||
cwd: '/workspace',
|
||
handoffResume: async () => undefined as never,
|
||
sessionPersistence: {
|
||
list: async () => [target],
|
||
load: async () => ({ meta: target, events: resumeEvents('Returning host') }),
|
||
},
|
||
})
|
||
result.terminal.send('/resume')
|
||
result.terminal.send('\r')
|
||
await tick(); await tick()
|
||
result.terminal.send('Returning host')
|
||
result.terminal.send('\r')
|
||
await tick(); await tick()
|
||
expect(result.terminal.output).toContain('resume host returned without replacing the process')
|
||
await dispose(result)
|
||
})
|
||
|
||
it('keeps the current TUI when the selected log fails its second preflight load', async () => {
|
||
const target = header('racing-corruption', 10, '/workspace')
|
||
let loads = 0
|
||
const result = await setup({
|
||
cwd: '/workspace',
|
||
handoffResume: vi.fn(),
|
||
sessionPersistence: {
|
||
list: async () => [target],
|
||
load: async () => {
|
||
if (++loads > 1) throw new Error('log changed during selection')
|
||
return { meta: target, events: resumeEvents('Racing corruption') }
|
||
},
|
||
},
|
||
})
|
||
result.terminal.send('/resume')
|
||
result.terminal.send('\r')
|
||
await tick(); await tick()
|
||
result.terminal.send('Racing corruption')
|
||
result.terminal.send('\r')
|
||
await tick(); await tick()
|
||
expect(result.terminal.output).toContain('Resume failed: session cannot be loaded: failed to inspect session')
|
||
expect(result.terminal.output).toContain('log changed during selection')
|
||
expect(result.terminal.stopped).toBe(0)
|
||
await dispose(result)
|
||
})
|
||
|
||
it('does not flush or hand off when disposal begins during selected-session preflight', async () => {
|
||
const target = header('dispose-during-preflight', 10, '/workspace')
|
||
const secondListing = Promise.withResolvers<SessionRecord[]>()
|
||
const handoff = vi.fn<NonNullable<TuiRuntime['handoffResume']>>()
|
||
const flush = vi.fn()
|
||
let listings = 0
|
||
const record: SessionRecord = { header: target, live: false, persisted: true }
|
||
const result = await setup({
|
||
cwd: '/workspace',
|
||
handoffResume: handoff,
|
||
async configureContext(ctx) {
|
||
ctx.provide('tools', { get: () => undefined } as never)
|
||
ctx.on('session/flush', flush)
|
||
ctx.provide('sessionQuery', {
|
||
listSessions: () => ++listings === 1 ? Promise.resolve([record]) : secondListing.promise,
|
||
readSession: () => Promise.resolve({
|
||
session: target,
|
||
events: resumeEvents('Dispose during preflight'),
|
||
}),
|
||
} as never)
|
||
},
|
||
})
|
||
result.terminal.send('/resume')
|
||
result.terminal.send('\r')
|
||
await tick()
|
||
result.terminal.send('Dispose during preflight')
|
||
result.terminal.send('\r')
|
||
await vi.waitFor(() => { expect(listings).toBe(2) })
|
||
await dispose(result)
|
||
secondListing.resolve([record])
|
||
await tick()
|
||
expect(flush).not.toHaveBeenCalled()
|
||
expect(handoff).not.toHaveBeenCalled()
|
||
})
|
||
|
||
it('hands off a validated session exposed by a query backend without a persistence service', async () => {
|
||
const target = header('query-without-persistence', 10, '/workspace')
|
||
const handoff = vi.fn<NonNullable<TuiRuntime['handoffResume']>>(
|
||
() => Promise.reject(new Error('test host retained process')),
|
||
)
|
||
const result = await setup({
|
||
cwd: '/workspace',
|
||
handoffResume: handoff,
|
||
async configureContext(ctx) {
|
||
ctx.provide('tools', { get: () => undefined } as never)
|
||
ctx.provide('sessionQuery', {
|
||
listSessions: () => Promise.resolve([{
|
||
header: target,
|
||
live: false,
|
||
persisted: true,
|
||
}]),
|
||
readSession: () => Promise.resolve({
|
||
session: target,
|
||
events: resumeEvents('Query without persistence'),
|
||
}),
|
||
} as never)
|
||
},
|
||
})
|
||
result.terminal.send('/resume')
|
||
result.terminal.send('\r')
|
||
await tick(); await tick()
|
||
result.terminal.send('Query without persistence')
|
||
result.terminal.send('\r')
|
||
await tick(); await tick()
|
||
expect(handoff).toHaveBeenCalledWith(target.id)
|
||
expect(result.terminal.output).toContain('Resume handoff failed: test host retained process')
|
||
await dispose(result)
|
||
})
|
||
|
||
it('does not hand off after disposal begins during the current-session flush', async () => {
|
||
const target = header('dispose-during-flush', 10, '/workspace')
|
||
const flushing = Promise.withResolvers<undefined>()
|
||
const handoff = vi.fn<NonNullable<TuiRuntime['handoffResume']>>()
|
||
const result = await setup({
|
||
cwd: '/workspace',
|
||
handoffResume: handoff,
|
||
async configureContext(ctx) {
|
||
ctx.provide('tools', { get: () => undefined } as never)
|
||
ctx.on('session/flush', () => flushing.promise)
|
||
},
|
||
sessionPersistence: {
|
||
list: async () => [target],
|
||
load: async () => ({ meta: target, events: resumeEvents('Dispose during flush') }),
|
||
},
|
||
})
|
||
result.terminal.send('/resume')
|
||
result.terminal.send('\r')
|
||
await tick(); await tick()
|
||
result.terminal.send('Dispose during flush')
|
||
result.terminal.send('\r')
|
||
await tick()
|
||
const disposing = dispose(result)
|
||
await tick()
|
||
flushing.resolve(undefined)
|
||
await disposing
|
||
expect(handoff).not.toHaveBeenCalled()
|
||
})
|
||
|
||
it('does not hand off after disposal begins while terminal input drains', async () => {
|
||
const target = header('dispose-during-drain', 10, '/workspace')
|
||
const draining = Promise.withResolvers<undefined>()
|
||
const handoff = vi.fn<NonNullable<TuiRuntime['handoffResume']>>()
|
||
const result = await setup({
|
||
cwd: '/workspace',
|
||
handoffResume: handoff,
|
||
sessionPersistence: {
|
||
list: async () => [target],
|
||
load: async () => ({ meta: target, events: resumeEvents('Dispose during drain') }),
|
||
},
|
||
})
|
||
result.terminal.drainInput.mockImplementationOnce(() => draining.promise)
|
||
result.terminal.send('/resume')
|
||
result.terminal.send('\r')
|
||
await tick(); await tick()
|
||
result.terminal.send('Dispose during drain')
|
||
result.terminal.send('\r')
|
||
await vi.waitFor(() => { expect(result.terminal.drainInput).toHaveBeenCalled() })
|
||
await dispose(result)
|
||
draining.resolve(undefined)
|
||
await tick()
|
||
expect(handoff).not.toHaveBeenCalled()
|
||
})
|
||
|
||
it('does not restart the terminal when a pending host rejects during disposal', async () => {
|
||
const target = header('host-rejects-during-disposal', 10, '/workspace')
|
||
const host = Promise.withResolvers<never>()
|
||
const handoff = vi.fn<NonNullable<TuiRuntime['handoffResume']>>(() => host.promise)
|
||
const result = await setup({
|
||
cwd: '/workspace',
|
||
handoffResume: handoff,
|
||
sessionPersistence: {
|
||
list: async () => [target],
|
||
load: async () => ({ meta: target, events: resumeEvents('Host disposal') }),
|
||
},
|
||
})
|
||
result.terminal.send('/resume')
|
||
result.terminal.send('\r')
|
||
await tick(); await tick()
|
||
result.terminal.send('Host disposal')
|
||
result.terminal.send('\r')
|
||
await vi.waitFor(() => { expect(handoff).toHaveBeenCalled() })
|
||
const startsBeforeDispose = result.terminal.started
|
||
await dispose(result)
|
||
host.reject(new Error('host rejected after disposal'))
|
||
await tick()
|
||
expect(result.terminal.started).toBe(startsBeforeDispose)
|
||
expect(result.terminal.output).not.toContain('host rejected after disposal')
|
||
})
|
||
|
||
it('rejects a candidate whose cwd changes between listing and preflight', async () => {
|
||
const target = header('moving-workspace', 10, '/workspace')
|
||
let listings = 0
|
||
const result = await setup({
|
||
cwd: '/workspace',
|
||
handoffResume: vi.fn(),
|
||
sessionPersistence: {
|
||
list: async () => [++listings <= 2 ? target : header('moving-workspace', 10, '/elsewhere')],
|
||
load: async () => ({
|
||
meta: listings <= 2 ? target : header('moving-workspace', 10, '/elsewhere'),
|
||
events: resumeEvents('Moving workspace'),
|
||
}),
|
||
},
|
||
})
|
||
result.terminal.send('/resume')
|
||
result.terminal.send('\r')
|
||
await tick(); await tick()
|
||
result.terminal.send('Moving workspace')
|
||
result.terminal.send('\r')
|
||
await tick(); await tick()
|
||
expect(result.terminal.output).toContain('different workspace')
|
||
await dispose(result)
|
||
})
|
||
|
||
it('admits only one handoff while the selected preflight is pending', async () => {
|
||
const target = header('single-handoff', 10, '/workspace')
|
||
const preflight = Promise.withResolvers<{ meta: SessionHeader; events: SessionEvent[] }>()
|
||
let loads = 0
|
||
const result = await setup({
|
||
cwd: '/workspace',
|
||
sessionPersistence: {
|
||
list: async () => [target],
|
||
load: () => ++loads === 1
|
||
? Promise.resolve({ meta: target, events: resumeEvents('Single handoff') })
|
||
: preflight.promise,
|
||
},
|
||
})
|
||
result.terminal.send('/resume')
|
||
result.terminal.send('\r')
|
||
await tick(); await tick()
|
||
result.terminal.send('Single handoff')
|
||
result.terminal.send('\r')
|
||
result.terminal.send('\r')
|
||
await tick()
|
||
preflight.resolve({ meta: target, events: resumeEvents('Single handoff') })
|
||
await tick(); await tick()
|
||
expect(loads).toBe(2)
|
||
await dispose(result)
|
||
})
|
||
|
||
it('rechecks running state and candidate existence before loading the selected log', async () => {
|
||
const target = header('preflight-races', 10, '/workspace')
|
||
const result = await setup({
|
||
cwd: '/workspace',
|
||
handoffResume: vi.fn(),
|
||
sessionPersistence: {
|
||
list: async () => [target],
|
||
load: async () => ({ meta: target, events: resumeEvents('Preflight races') }),
|
||
},
|
||
})
|
||
result.terminal.send('/resume')
|
||
result.terminal.send('\r')
|
||
await tick(); await tick()
|
||
result.agent.status = 'running'
|
||
result.terminal.send('Preflight races')
|
||
result.terminal.send('\r')
|
||
await tick()
|
||
expect(result.terminal.output).toContain('Resume requires an idle agent (status: running)')
|
||
result.agent.status = 'idle'
|
||
await dispose(result)
|
||
|
||
let disappearingLists = 0
|
||
const disappearing = await setup({
|
||
cwd: '/workspace',
|
||
handoffResume: vi.fn(),
|
||
sessionPersistence: {
|
||
list: async () => ++disappearingLists <= 2 ? [target] : [],
|
||
load: async () => ({ meta: target, events: resumeEvents('Disappearing target') }),
|
||
},
|
||
})
|
||
disappearing.terminal.send('/resume')
|
||
disappearing.terminal.send('\r')
|
||
await tick(); await tick()
|
||
disappearing.terminal.send('Disappearing target')
|
||
disappearing.terminal.send('\r')
|
||
await tick()
|
||
expect(disappearing.terminal.output).toContain('is no longer available')
|
||
await dispose(disappearing)
|
||
})
|
||
|
||
it('rechecks idleness after the selected log finishes loading', async () => {
|
||
const target = header('load-turns-running', 10, '/workspace')
|
||
let loads = 0
|
||
const result = await setup({
|
||
cwd: '/workspace',
|
||
handoffResume: vi.fn(),
|
||
sessionPersistence: {
|
||
list: async () => [target],
|
||
load: async () => {
|
||
loads += 1
|
||
if (loads === 2) result.agent.status = 'running'
|
||
return { meta: target, events: resumeEvents('Load turns running') }
|
||
},
|
||
},
|
||
})
|
||
result.terminal.send('/resume')
|
||
result.terminal.send('\r')
|
||
await tick(); await tick()
|
||
result.terminal.send('Load turns running')
|
||
result.terminal.send('\r')
|
||
await tick(); await tick()
|
||
expect(result.terminal.output).toContain('Resume requires an idle agent (status: running)')
|
||
result.agent.status = 'idle'
|
||
await dispose(result)
|
||
})
|
||
|
||
it('keeps resumeCommand as a displayed fallback when the host cannot hand off', async () => {
|
||
const target = header('fallback-session', 10, '/workspace')
|
||
const result = await setup({
|
||
cwd: '/workspace',
|
||
config: { resumeCommand: RESUME },
|
||
sessionPersistence: {
|
||
list: async () => [target],
|
||
load: async () => ({ meta: target, events: resumeEvents('Fallback target') }),
|
||
},
|
||
})
|
||
result.terminal.send('/resume')
|
||
result.terminal.send('\r')
|
||
await tick(); await tick()
|
||
result.terminal.send('Fallback target')
|
||
result.terminal.send('\r')
|
||
await vi.waitFor(() => {
|
||
expect(result.terminal.output).toContain('This host cannot hand off in place. Exit and run:')
|
||
})
|
||
expect(result.terminal.output).toContain('dsh --resume fallback-session')
|
||
expect(result.terminal.stopped).toBe(0)
|
||
await dispose(result)
|
||
})
|
||
|
||
it('keeps the selector independent from an absent command fallback', async () => {
|
||
const target = header('no-fallback-session', 10, '/workspace')
|
||
const result = await setup({
|
||
cwd: '/workspace',
|
||
sessionPersistence: {
|
||
list: async () => [target],
|
||
load: async () => ({ meta: target, events: resumeEvents('No fallback target') }),
|
||
},
|
||
})
|
||
result.terminal.send('/resume')
|
||
result.terminal.send('\r')
|
||
await tick(); await tick()
|
||
result.terminal.send('No fallback target')
|
||
result.terminal.send('\r')
|
||
await vi.waitFor(() => {
|
||
expect(result.terminal.output).toContain('Session is resumable, but this host cannot hand it off in place')
|
||
})
|
||
await dispose(result)
|
||
})
|
||
|
||
it('rechecks idleness after the current-session flush', async () => {
|
||
const target = header('post-flush-running', 10, '/workspace')
|
||
const control: { setRunning?: () => void } = {}
|
||
const handoff = vi.fn<NonNullable<TuiRuntime['handoffResume']>>()
|
||
const result = await setup({
|
||
cwd: '/workspace',
|
||
handoffResume: handoff,
|
||
async configureContext(ctx) {
|
||
ctx.provide('tools', { get: () => undefined } as never)
|
||
ctx.on('session/flush', () => { control.setRunning?.() })
|
||
},
|
||
sessionPersistence: {
|
||
list: async () => [target],
|
||
load: async () => ({ meta: target, events: resumeEvents('Post-flush running') }),
|
||
},
|
||
})
|
||
control.setRunning = () => { result.agent.status = 'running' }
|
||
result.terminal.send('/resume')
|
||
result.terminal.send('\r')
|
||
await tick(); await tick()
|
||
result.terminal.send('Post-flush running')
|
||
result.terminal.send('\r')
|
||
await tick(); await tick()
|
||
expect(result.terminal.output).toContain('Resume requires an idle agent (status: running)')
|
||
expect(handoff).not.toHaveBeenCalled()
|
||
result.agent.status = 'idle'
|
||
await dispose(result)
|
||
})
|
||
})
|
||
|
||
describe('pi-tui chat lifecycle and transcript', () => {
|
||
it('restores durable goal phase without implying automatic continuation', async () => {
|
||
const change: GoalSnapshotChangeMeta = {
|
||
kind: 'goal/change',
|
||
version: GOAL_CHANGE_VERSION,
|
||
operation: 'create',
|
||
goal: {
|
||
id: GoalId('restored-goal'),
|
||
revision: 1,
|
||
objective: 'Resume only with human confirmation',
|
||
phase: 'active',
|
||
maxGoalRounds: 4,
|
||
},
|
||
roundsStarted: 0,
|
||
createdAt: 10,
|
||
updatedAt: 10,
|
||
}
|
||
const result = await setup({
|
||
beforeMount(session) {
|
||
session.append('user/message', {
|
||
content: renderGoalChange(change),
|
||
source: {
|
||
kind: 'goal',
|
||
goalId: change.goal.id,
|
||
revision: change.goal.revision,
|
||
round: 0,
|
||
change,
|
||
},
|
||
}, { surfaceOp: 'append' })
|
||
},
|
||
})
|
||
expect(result.terminal.output).toContain('Goal restored (active) with automatic continuation disarmed')
|
||
expect(result.terminal.output).toContain('/goal resume')
|
||
result.terminal.send('/resume')
|
||
result.terminal.send('\r')
|
||
await tick(); await tick()
|
||
expect(result.terminal.output).toContain('goal active')
|
||
await dispose(result)
|
||
})
|
||
|
||
it('uses the latest log-backed title for the header subtitle and terminal window', async () => {
|
||
const result = await setup({
|
||
// A fixed short cwd keeps the footer's token counters inside the 88-column
|
||
// fake terminal regardless of where the checkout lives; cwd rendering has
|
||
// its own dedicated variants test below.
|
||
cwd: '/workspace',
|
||
beforeMount(session) {
|
||
session.append('session/title', {
|
||
title: 'Restored session title',
|
||
messageSeqs: [1],
|
||
source: { kind: 'fallback' },
|
||
})
|
||
},
|
||
})
|
||
|
||
expect(result.terminal.title).toBe('Restored session title — DeepSeek Harness')
|
||
expect(result.terminal.output).toContain('Restored session title')
|
||
expect(result.terminal.output).not.toContain('Coding agent ready.')
|
||
|
||
result.session.append('session/title', {
|
||
title: 'Live title \u001B]0;unsafe\u0007',
|
||
messageSeqs: [1, 5],
|
||
source: { kind: 'fallback' },
|
||
})
|
||
await tick()
|
||
|
||
expect(result.terminal.title).toContain('Live title \\x1b]0;unsafe\\x07 — DeepSeek Harness')
|
||
expect(result.terminal.title).not.toContain('\u001B')
|
||
expect(result.terminal.output).toContain('Live title \\x1b]0;unsafe\\x07')
|
||
await dispose(result)
|
||
})
|
||
|
||
it('renders its header, footer, replay, streaming answer, todos, and status', async () => {
|
||
let now = 0
|
||
const result = await setup({
|
||
contextWindow: 100,
|
||
contextTokens: 42,
|
||
// Short cwd: the footer clips its right (context/tools) segment first,
|
||
// and the default worktree path would swallow it at 88 columns.
|
||
cwd: '/opt',
|
||
now: () => now,
|
||
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('/opt (tui-staging) deepseek-v4-flash ↑1.3k ↓42')
|
||
expect(result.terminal.output).toContain('dsh > ')
|
||
expect(result.terminal.output).not.toContain('main-session deepseek-v4-flash')
|
||
// Context resolution is async (resolveModelContext); settle before reading.
|
||
await tick()
|
||
expect(result.terminal.output).toContain('42% context')
|
||
expect(result.terminal.output).not.toContain('tools:collapsed')
|
||
// Narrow terminals clip the right-hand context/tools segment first; the
|
||
// model-led left segment stays.
|
||
result.terminal.resize(52)
|
||
await tick()
|
||
expect(result.terminal.output).toContain('deepseek-v4-flash')
|
||
result.terminal.resize(88)
|
||
await tick()
|
||
|
||
result.agent.status = 'running'
|
||
agentEvents(result.ctx, result.agent).emit('agent/status', 'running')
|
||
now = 8_000
|
||
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('user/message', { content: [{ type: 'text', text: 'user context' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
||
result.session.append('user/message', {
|
||
content: [{ type: 'text', text: '<system-reminder>\nAdditional instructions from: nested/AGENTS.md\n\nRender XML context clearly.\n</system-reminder>' }],
|
||
source: { kind: 'plugin', plugin: 'workspace-context' },
|
||
}, { surfaceOp: 'append' })
|
||
result.session.append('user/message', {
|
||
content: [{ type: 'text', text: '<system-reminder>›</system-reminder>' }],
|
||
source: { kind: 'plugin', plugin: 'workspace-control-context' },
|
||
}, { surfaceOp: 'append' })
|
||
result.session.append('user/message', { content: [{ type: 'text', text: '' }], source: { kind: 'plugin', plugin: 'ctx' } }, { surfaceOp: 'append' })
|
||
// A non-plugin injected source (goal) has no `plugin` field, so its context
|
||
// card label falls back to the source kind.
|
||
result.session.append('user/message', { content: [{ type: 'text', text: 'goal context' }], source: { kind: 'goal', goalId: 'g1', revision: 1, round: 0 } as never }, { surfaceOp: 'append' })
|
||
appendAssistant(result.session, [])
|
||
result.session.append('step/end', { turn: 1, step: 1 })
|
||
result.session.append('turn/end', { turn: 1, reason: { kind: 'aborted' } })
|
||
result.session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||
result.session.append('turn/end', { turn: 2, reason: { kind: 'completed' } })
|
||
result.session.append('turn/start', { turn: 3, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||
result.session.append('step/start', { turn: 3, step: 1 })
|
||
result.session.append('assistant/chunk', {
|
||
turn: 3,
|
||
step: 1,
|
||
chunk: { type: 'block-start', index: 0, blockType: 'reasoning' },
|
||
})
|
||
result.session.append('assistant/chunk', {
|
||
turn: 3,
|
||
step: 1,
|
||
chunk: { type: 'reasoning-delta', index: 0, text: 'live thought' },
|
||
})
|
||
result.session.append('assistant/chunk', {
|
||
turn: 3,
|
||
step: 1,
|
||
chunk: { type: 'reasoning-delta', index: 9, text: 'unannounced thought' },
|
||
})
|
||
result.session.append('assistant/chunk', {
|
||
turn: 3,
|
||
step: 1,
|
||
chunk: { type: 'block-end', index: 0, block: { type: 'reasoning', text: 'live thought complete' } },
|
||
})
|
||
result.session.append('assistant/chunk', {
|
||
turn: 3,
|
||
step: 1,
|
||
chunk: { type: 'block-start', index: 1, blockType: 'text' },
|
||
})
|
||
result.session.append('assistant/chunk', {
|
||
turn: 3,
|
||
step: 1,
|
||
chunk: { type: 'text-delta', index: 1, text: 'live answer' },
|
||
})
|
||
result.session.append('assistant/chunk', {
|
||
turn: 3,
|
||
step: 1,
|
||
chunk: { type: 'block-end', index: 1, block: { type: 'text', text: 'live answer done' } },
|
||
})
|
||
result.session.append('assistant/chunk', {
|
||
turn: 3,
|
||
step: 1,
|
||
chunk: { type: 'block-start', index: 2, blockType: 'tool-call' },
|
||
})
|
||
result.session.append('assistant/chunk', {
|
||
turn: 3,
|
||
step: 1,
|
||
chunk: { type: 'block-end', index: 2, block: { type: 'tool-call', id: 'stream-tool' as never, name: 'tool', arguments: '{}' } },
|
||
})
|
||
result.session.append('assistant/chunk', {
|
||
turn: 3,
|
||
step: 1,
|
||
chunk: { type: 'tool-call-delta', index: 2, id: 'stream-tool' as never, argumentsDelta: '{}' },
|
||
})
|
||
result.session.append('assistant/chunk', {
|
||
turn: 3,
|
||
step: 1,
|
||
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: 3, step: 1 },
|
||
)
|
||
await vi.waitFor(() => {
|
||
expect(result.terminal.output).toContain('final live answer')
|
||
})
|
||
|
||
expect(result.terminal.output).toContain('press enter to steer and esc to cancel')
|
||
expect(result.terminal.output).toContain('Steering')
|
||
expect(result.terminal.output).toContain('user context')
|
||
expect(result.terminal.output).toContain('Context · workspace-context')
|
||
expect(result.terminal.output).toContain('system-reminder')
|
||
expect(result.terminal.output).toContain('Additional instructions from: nested/AGENTS.md')
|
||
expect(result.terminal.output).not.toContain('<system-reminder>')
|
||
expect(result.terminal.output).toContain('\\x9b')
|
||
expect(result.terminal.output).toContain('Context · goal') // goal-sourced injected context labels by kind
|
||
expect(result.terminal.output).toContain('Turn cancelled')
|
||
expect(result.terminal.progress).toContain(true)
|
||
|
||
result.session.append('assistant/chunk', {
|
||
turn: 3,
|
||
step: 1,
|
||
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' }], undefined, { turn: 3, step: 1 })
|
||
await tick()
|
||
expect(result.terminal.output).toContain('answer after clear')
|
||
|
||
result.agent.status = 'idle'
|
||
agentEvents(result.ctx, result.agent).emit('agent/status', 'idle')
|
||
await tick()
|
||
expect(result.terminal.output).toContain('↑1.8k ↓50')
|
||
expect(result.terminal.output).toContain('deepseek-v4-flash')
|
||
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,
|
||
provider: 'mock',
|
||
mode: 'normal',
|
||
policyKey: '["normal",2,["RATE_LIMIT"],1,10000,0]',
|
||
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('step/end', { turn: 1, step: 1 })
|
||
result.session.append('llm/retry', {
|
||
turn: 1,
|
||
step: 1,
|
||
provider: 'mock',
|
||
mode: 'normal',
|
||
policyKey: '["normal",2,["RATE_LIMIT"],1,10000,0]',
|
||
retry: 1,
|
||
maxRetries: 2,
|
||
delayMs: 500,
|
||
failure: { message: 'rate limited', code: 'RATE_LIMIT', status: 429 },
|
||
})
|
||
result.session.append('llm/retry', {
|
||
turn: 1,
|
||
step: 2,
|
||
provider: 'mock',
|
||
mode: 'normal',
|
||
policyKey: '["normal",2,["RATE_LIMIT"],1,10000,0]',
|
||
retry: 2,
|
||
maxRetries: 2,
|
||
delayMs: 1_000,
|
||
failure: { message: 'failed before chunks', code: 'SERVER', status: 503 },
|
||
})
|
||
result.session.append('llm/retry', {
|
||
turn: 1,
|
||
step: 3,
|
||
provider: 'mock',
|
||
mode: 'always',
|
||
policyKey: '["always",1,10000,0]',
|
||
retry: 1,
|
||
delayMs: 2_000,
|
||
failure: { message: 'retry without limit', code: 'AUTH', status: 401 },
|
||
})
|
||
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')
|
||
expect(result.terminal.output).not.toContain('discarded partial answer')
|
||
expect(result.terminal.output).toContain('Retrying model request (1/∞) in 2000ms: retry without limit')
|
||
await dispose(result)
|
||
})
|
||
|
||
it('badges queued steering on the prompt context timing and clears it as each drains', async () => {
|
||
// Pin a cwd free of the substring under test; the prompt context renders the path.
|
||
const result = await setup({ status: 'running', cwd: '/workspace' })
|
||
// Running with nothing queued: timing appears once in the prompt context and the editor keeps its hint.
|
||
expect(result.terminal.output).toContain('Assistant')
|
||
expect(result.terminal.output).toContain('Model wait 0.0s')
|
||
expect(result.terminal.output).toContain('press enter to steer and esc to cancel')
|
||
expect(result.terminal.output).not.toContain('│')
|
||
expect(result.terminal.output).not.toContain('queued')
|
||
|
||
result.terminal.output = ''
|
||
result.terminal.send('x')
|
||
await tick()
|
||
expect(result.terminal.output).not.toContain('press enter to steer and esc to cancel')
|
||
result.terminal.send('\x7f')
|
||
await tick()
|
||
expect(result.terminal.output).toContain('press enter to steer and esc to cancel')
|
||
|
||
const submitSteering = (text: string): void => {
|
||
result.terminal.send(text)
|
||
result.terminal.send('\r')
|
||
}
|
||
const drainSteering = (text: string): void => {
|
||
const id = result.agent.steeredIds.shift()
|
||
if (id !== undefined) {
|
||
result.ctx.emit('agent/inbox/dequeue', result.agent, {
|
||
id,
|
||
content: [{ type: 'text', text }],
|
||
source: { kind: 'user' },
|
||
})
|
||
}
|
||
result.session.append('steering/message', { turn: 1, content: [{ type: 'text', text }], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
||
}
|
||
|
||
// A steering queue for a different agent never touches this status line.
|
||
const other = { ...result.agent, id: SessionId('other') } as Agent
|
||
result.terminal.output = ''
|
||
result.ctx.emit('agent/inbox/enqueue', other, { id: AgentMessageId('stub'), content: [{ type: 'text', text: 'elsewhere' }], source: { kind: 'user' } }, 'queued')
|
||
await tick()
|
||
expect(result.terminal.output).not.toContain('queued')
|
||
|
||
// Two steering messages queue while the turn runs.
|
||
submitSteering('first')
|
||
result.terminal.output = ''
|
||
submitSteering('second')
|
||
await tick()
|
||
expect(result.terminal.output).toContain('2 queued')
|
||
|
||
// Draining one submitted message decrements the badge.
|
||
result.terminal.output = ''
|
||
drainSteering('first')
|
||
await tick()
|
||
expect(result.terminal.output).toContain('1 queued')
|
||
expect(result.terminal.output).not.toContain('2 queued')
|
||
|
||
// Draining the last queued message returns the plain hint.
|
||
result.terminal.output = ''
|
||
drainSteering('second')
|
||
await tick()
|
||
expect(result.terminal.output).toContain('press enter to steer and esc to cancel')
|
||
expect(result.terminal.output).not.toContain('│')
|
||
expect(result.terminal.output).not.toContain('queued')
|
||
|
||
// A drain with no matching queued entry is ignored rather than underflowing.
|
||
result.terminal.output = ''
|
||
drainSteering('continuation')
|
||
submitSteering('after')
|
||
await tick()
|
||
expect(result.terminal.output).toContain('1 queued')
|
||
|
||
// A steering/message has no inbox identity and therefore cannot consume a
|
||
// pending slot by itself.
|
||
result.terminal.output = ''
|
||
result.session.append('steering/message', {
|
||
turn: 1,
|
||
content: [{ type: 'text', text: 'continue: goal not reached' }],
|
||
source: { kind: 'plugin', plugin: 'hooks' },
|
||
}, { surfaceOp: 'append' })
|
||
await tick()
|
||
expect(result.terminal.output).toContain('1 queued')
|
||
result.terminal.output = ''
|
||
drainSteering('after')
|
||
await tick()
|
||
expect(result.terminal.output).not.toContain('queued')
|
||
|
||
// The turn ending resets the badge, so the next running turn starts clean.
|
||
result.agent.status = 'idle'
|
||
result.ctx.emit('agent/status', result.agent, 'idle')
|
||
result.agent.status = 'running'
|
||
result.terminal.output = ''
|
||
result.ctx.emit('agent/status', result.agent, 'running')
|
||
await tick()
|
||
expect(result.terminal.output).not.toContain('│')
|
||
expect(result.terminal.output).not.toContain('queued')
|
||
|
||
// A cancellation discards queued steering: the badge clears without drains.
|
||
submitSteering('third')
|
||
submitSteering('fourth')
|
||
await tick()
|
||
expect(result.terminal.output).toContain('2 queued')
|
||
const discarded = result.agent.steeredIds.splice(0).map(id => ({
|
||
id, content: [{ type: 'text' as const, text: 'discarded' }], source: { kind: 'user' as const },
|
||
}))
|
||
// Another agent's dequeue/discard, and ones naming no pending id, leave
|
||
// the badge alone.
|
||
result.ctx.emit('agent/inbox/dequeue', other, discarded[0]!)
|
||
result.ctx.emit('agent/inbox/dequeue', result.agent, {
|
||
id: AgentMessageId('never-queued'), content: [{ type: 'text', text: 'x' }], source: { kind: 'user' },
|
||
})
|
||
result.ctx.emit('agent/inbox/discard', other, discarded)
|
||
result.ctx.emit('agent/inbox/discard', result.agent, [
|
||
{ id: AgentMessageId('never-queued'), content: [{ type: 'text', text: 'x' }], source: { kind: 'user' } },
|
||
])
|
||
await tick()
|
||
expect(result.terminal.output).toContain('2 queued')
|
||
result.terminal.output = ''
|
||
result.ctx.emit('agent/inbox/discard', result.agent, discarded)
|
||
await tick()
|
||
expect(result.terminal.output).not.toContain('queued')
|
||
|
||
await dispose(result)
|
||
})
|
||
|
||
it('accumulates exclusive timing buckets across a multi-step turn', async () => {
|
||
let clock = 1_700_000_000_000
|
||
const nowSpy = vi.spyOn(Date, 'now').mockImplementation(() => clock)
|
||
const result = await setup({ status: 'running' })
|
||
|
||
clock += 1_000
|
||
result.session.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'block-start', index: 0, blockType: 'reasoning' } })
|
||
clock += 2_000
|
||
result.session.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 1, text: 'answering' } })
|
||
clock += 1_000
|
||
result.session.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'reasoning-delta', index: 0, text: 'reconsidering' } })
|
||
clock += 2_000
|
||
result.session.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 1, text: 'revised' } })
|
||
clock += 3_000
|
||
result.session.append('tool/call', { turn: 1, step: 1, callId: 'c1' as never, name: 'bash', arguments: '{}' })
|
||
clock += 4_000
|
||
result.session.append('step/end', { turn: 1, step: 1 })
|
||
result.session.append('step/start', { turn: 1, step: 2 })
|
||
clock += 1_000
|
||
result.session.append('assistant/chunk', { turn: 1, step: 2, chunk: { type: 'usage', usage: { inputTokens: 1, outputTokens: 1 } } })
|
||
clock += 2_000
|
||
result.session.append('assistant/chunk', { turn: 1, step: 2, chunk: { type: 'text-delta', index: 0, text: 'done' } })
|
||
clock += 3_000
|
||
result.terminal.output = ''
|
||
result.session.append('step/end', { turn: 1, step: 2 })
|
||
await tick()
|
||
|
||
expect(result.terminal.output).toContain('Model wait 1.0s · Thinking 4.0s · Response 4.0s · Tools 4.0s')
|
||
expect(result.terminal.output).toContain('Model wait 1.0s · Response 3.0s · Completed')
|
||
expect(result.terminal.output).not.toContain('Thinking 0s')
|
||
nowSpy.mockRestore()
|
||
await dispose(result)
|
||
})
|
||
|
||
it('rebuilds used subsecond buckets and the durable local completion time', async () => {
|
||
let clock = new Date(2026, 6, 21, 14, 32, 6).getTime()
|
||
const nowSpy = vi.spyOn(Date, 'now').mockImplementation(() => clock)
|
||
let result: Awaited<ReturnType<typeof setup>> | undefined
|
||
try {
|
||
result = await setup({
|
||
beforeMount(session) {
|
||
clock += 250
|
||
session.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'fast' } })
|
||
clock += 500
|
||
session.append('step/end', { turn: 1, step: 1 })
|
||
clock += 86_400_000
|
||
},
|
||
})
|
||
const completed = 'Model wait 0.2s · Response 0.5s · Completed 2026-07-21 14:32:06'
|
||
expect(result.terminal.output).toContain(completed)
|
||
|
||
result.terminal.output = ''
|
||
appendUser(result.session, 'rebuild the transcript')
|
||
result.terminal.resize(result.terminal.columns + 1)
|
||
await tick()
|
||
expect(result.terminal.output).toContain(completed)
|
||
} finally {
|
||
if (result !== undefined) await dispose(result)
|
||
nowSpy.mockRestore()
|
||
}
|
||
})
|
||
|
||
it('renders completion for a step whose opening event is unavailable', async () => {
|
||
const result = await setup({ omitInitialLifecycle: true })
|
||
result.session.append('step/end', { turn: 1, step: 1 })
|
||
await tick()
|
||
expect(result.terminal.output).toContain('Completed ')
|
||
expect(result.terminal.output).toContain('Assistant')
|
||
expect(result.terminal.output).toContain('Model wait 0.0s · Completed')
|
||
await dispose(result)
|
||
})
|
||
|
||
it('does not reuse a completed turn before the next turn starts', async () => {
|
||
let clock = 1_700_000_000_000
|
||
const nowSpy = vi.spyOn(Date, 'now').mockImplementation(() => clock)
|
||
let result: Awaited<ReturnType<typeof setup>> | undefined
|
||
try {
|
||
result = await setup({
|
||
beforeMount(session) {
|
||
clock += 2_000
|
||
session.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'done' } })
|
||
clock += 1_000
|
||
session.append('step/end', { turn: 1, step: 1 })
|
||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||
},
|
||
})
|
||
result.agent.status = 'running'
|
||
result.terminal.output = ''
|
||
result.ctx.emit('agent/status', result.agent, 'running')
|
||
await tick()
|
||
expect(result.terminal.output).not.toContain('Model wait')
|
||
expect(result.terminal.output).toContain('press enter to steer and esc to cancel')
|
||
expect(result.terminal.output).not.toContain('│')
|
||
expect(result.terminal.output).not.toContain('Response 1s')
|
||
|
||
result.session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||
result.session.append('step/start', { turn: 2, step: 1 })
|
||
clock += 1_000
|
||
result.terminal.output = ''
|
||
result.session.append('assistant/chunk', { turn: 2, step: 1, chunk: { type: 'text-delta', index: 0, text: 'next' } })
|
||
await tick()
|
||
expect(result.terminal.output).toContain('Model wait 1.0s')
|
||
expect(result.terminal.output).not.toContain('Model wait 3.0s')
|
||
} finally {
|
||
if (result !== undefined) await dispose(result)
|
||
nowSpy.mockRestore()
|
||
}
|
||
})
|
||
|
||
it('starts a running status before lifecycle events arrive', async () => {
|
||
const result = await setup({ status: 'running', omitInitialLifecycle: true })
|
||
expect(result.terminal.output).not.toContain('Model wait')
|
||
await dispose(result)
|
||
})
|
||
|
||
it('replaces the prompt caret with a phase-specific status glyph while running', async () => {
|
||
// Hold the clock past the fade-in so the glyph is at full opacity; with
|
||
// color off the settled glyph renders as its bare character.
|
||
let clock = 0
|
||
const result = await setup({ status: 'running', now: () => clock })
|
||
clock = 1_000
|
||
|
||
// A space separates `dsh` from the caret slot: the prompt reads
|
||
// `dsh <glyph> ` with the same visible width as the idle `dsh > `, so the
|
||
// cursor never shifts. Assert both the glyph slot and that constant width
|
||
// (color is off in this harness, so output carries no ANSI to strip).
|
||
const promptWidth = (): number => {
|
||
const row = result.terminal.output.split('\n').find(line => line.includes('dsh'))
|
||
if (row === undefined) throw new Error('prompt row not rendered')
|
||
return visibleWidth(row.slice(row.indexOf('dsh'), row.indexOf('dsh') + 6))
|
||
}
|
||
|
||
// Each phase swaps only the glyph character in the same slot at equal width.
|
||
const phaseGlyph: [() => void, string][] = [
|
||
[() => result.session.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'reasoning-delta', index: 0, text: 'weighing' } }), 'dsh ✻ '],
|
||
[() => result.session.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 1, text: 'answer' } }), 'dsh ● '],
|
||
[() => result.session.append('tool/call', { turn: 1, step: 1, callId: 'c1' as never, name: 'bash', arguments: '{}' }), 'dsh ⚙ '],
|
||
]
|
||
let runningWidth: number | undefined
|
||
for (const [drive, expected] of phaseGlyph) {
|
||
result.terminal.output = ''
|
||
drive()
|
||
await tick()
|
||
expect(result.terminal.output).toContain(expected)
|
||
runningWidth ??= promptWidth()
|
||
expect(promptWidth()).toBe(runningWidth)
|
||
}
|
||
|
||
// Idle begins a fade-out; once it settles (clock past the fade window) the
|
||
// plain `>` caret returns at the same width — no horizontal shift. The
|
||
// fade-out timer emits intermediate frames, so read the terminal's final
|
||
// rendered prompt row rather than the accumulated stream.
|
||
result.agent.status = 'idle'
|
||
result.ctx.emit('agent/status', result.agent, 'idle')
|
||
clock = 2_000
|
||
await new Promise(resolve => setTimeout(resolve, 150))
|
||
await tick()
|
||
const promptRow = (): string => {
|
||
const rows = result.terminal.output.split(/\r?\n|\x1b\[[0-9;]*[A-Za-z]/u).filter(r => r.includes('dsh'))
|
||
return rows.at(-1) ?? ''
|
||
}
|
||
expect(promptRow()).toContain('dsh > ')
|
||
expect(promptRow()).not.toMatch(/dsh(?:\x1b\[[0-9;]*m| )*[◍✻●⚙]/u)
|
||
expect(promptWidth()).toBe(runningWidth)
|
||
|
||
await dispose(result)
|
||
})
|
||
|
||
// Extract the running glyph's interpolated gray channel from a rendered frame.
|
||
const glyphGray = (frame: string): number => {
|
||
const m = /\x1b\[38;2;(\d+);(\d+);(\d+)m●/u.exec(frame)
|
||
if (m === null) throw new Error('frame did not paint a truecolor glyph')
|
||
const [r, g, b] = [Number(m[1]), Number(m[2]), Number(m[3])]
|
||
// Pure gray: equal channels, never the blue-dominant accent.
|
||
expect(r).toBe(g)
|
||
expect(g).toBe(b)
|
||
return r
|
||
}
|
||
|
||
it('throbs the running glyph in dim gray, breathing between trough and full without blanking, never accent', async () => {
|
||
let clock = 0
|
||
let chunkIndex = 0
|
||
const result = await setup({ status: 'running', config: { theme: { color: true, truecolor: true } }, now: () => clock })
|
||
// A fresh chunk index each frame changes the streamed line, forcing the
|
||
// diffing terminal to repaint the prompt row and re-emit the glyph slot.
|
||
const frameAt = async (t: number): Promise<string> => {
|
||
clock = t
|
||
chunkIndex += 1
|
||
result.terminal.output = ''
|
||
result.session.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: chunkIndex, text: '.' } })
|
||
await tick()
|
||
return result.terminal.output
|
||
}
|
||
|
||
// The pulse breathes between the dimmest trough gray and the settled peak
|
||
// and back, never blanking. At phase 0 (t=1400, pulse level 0, past fade-in)
|
||
// the glyph still paints the trough gray, so the breath dims but never
|
||
// disappears — a symmetric bold→dim→bold throb.
|
||
const trough = await frameAt(1_400)
|
||
expect(trough).toMatch(/●/u)
|
||
expect(glyphGray(trough)).toBe(43)
|
||
// Half a period later (t=2100, pulse peak) it paints the brightest gray.
|
||
const peak = await frameAt(2_100)
|
||
expect(glyphGray(peak)).toBe(136)
|
||
// A frame partway up the swell paints a gray strictly between trough and
|
||
// full, and the glyph is never the accent color.
|
||
const rising = await frameAt(1_680)
|
||
const grey = glyphGray(rising)
|
||
expect(grey).toBeGreaterThan(43)
|
||
expect(grey).toBeLessThan(136)
|
||
expect(rising).not.toMatch(/\x1b\[94m●/u)
|
||
|
||
await dispose(result)
|
||
})
|
||
|
||
it('fades the running glyph out to the plain caret after the turn ends', async () => {
|
||
let clock = 0
|
||
const result = await setup({ status: 'running', config: { theme: { color: true, truecolor: true } }, now: () => clock })
|
||
clock = 1_000
|
||
result.session.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: '.' } })
|
||
await tick()
|
||
|
||
// End the turn: the last glyph fades out over 300 ms rather than vanishing.
|
||
result.agent.status = 'idle'
|
||
result.ctx.emit('agent/status', result.agent, 'idle')
|
||
await tick()
|
||
// Just after the end the glyph still paints a gray, not `>`.
|
||
expect(result.terminal.output).toMatch(/\x1b\[38;2;\d+;\d+;\d+m●/u)
|
||
expect(result.terminal.output).not.toContain('dsh \x1b[90m>')
|
||
|
||
// While the clock stays within the fade window the timer keeps ticking
|
||
// without clearing the fade (the not-yet-elapsed branch): the last frame is
|
||
// still the fading glyph, and the plain caret has not returned.
|
||
result.terminal.output = ''
|
||
await new Promise(resolve => setTimeout(resolve, 120))
|
||
const lastPromptRow = result.terminal.output.split(/\x1b\[[0-9;]*[A-Za-z]/u).filter(r => r.includes('dsh')).at(-1) ?? ''
|
||
expect(lastPromptRow).not.toContain('dsh \x1b[90m>')
|
||
|
||
// Past the fade window the fade timer clears and the plain caret returns.
|
||
clock = 2_000
|
||
result.terminal.output = ''
|
||
await new Promise(resolve => setTimeout(resolve, 120))
|
||
await tick()
|
||
expect(result.terminal.output).not.toMatch(/dsh(?:\x1b\[[0-9;]*m| )*●/u)
|
||
expect(result.terminal.output).toContain('>')
|
||
|
||
await dispose(result)
|
||
})
|
||
|
||
it('appears past the fade midpoint and disappears without truecolor, still dim not accent', async () => {
|
||
let clock = 0
|
||
const result = await setup({ status: 'running', config: { theme: { color: true } }, now: () => clock })
|
||
const frameAt = async (t: number): Promise<string> => {
|
||
clock = t
|
||
result.terminal.output = ''
|
||
result.session.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: '.' } })
|
||
await tick()
|
||
return result.terminal.output
|
||
}
|
||
|
||
// Without truecolor there is no per-frame gray: below the fade midpoint the
|
||
// glyph slot is blank; past it the glyph shows in the palette muted role
|
||
// (ANSI 90), never the accent (SGR 94).
|
||
const early = await frameAt(60)
|
||
expect(early).not.toMatch(/dsh(?:\x1b\[[0-9;]*m| )*●/u)
|
||
const shown = await frameAt(300)
|
||
expect(shown).toMatch(/\x1b\[90m●/u)
|
||
expect(shown).not.toMatch(/\x1b\[94m●/u)
|
||
|
||
await dispose(result)
|
||
})
|
||
|
||
it('shows the plain prompt caret while idle', async () => {
|
||
const result = await setup({ now: () => 0 })
|
||
expect(result.terminal.output).toContain('dsh > ')
|
||
expect(result.terminal.output).not.toMatch(/dsh [◍✻●⚙]/u)
|
||
await dispose(result)
|
||
})
|
||
|
||
it('escapes configured prompt controls while preserving registry-owned styling', async () => {
|
||
const result = await setup({ config: { theme: { leftPrompt: 'LEFT\u001B]2;unsafe\u0007 ${custom}' } } })
|
||
result.ctx.tuiPrompt.register('custom', '\u001B[1mTRUSTED\u001B[22m')
|
||
await tick()
|
||
expect(result.terminal.output).toContain('LEFT\\x1b]2;unsafe\\x07')
|
||
expect(result.terminal.output).toContain('\u001B[1mTRUSTED\u001B[22m')
|
||
expect(result.terminal.output).not.toContain('\u001B]2;unsafe\u0007')
|
||
await dispose(result)
|
||
})
|
||
|
||
it('redraws when an out-of-band prompt value changes on its own schedule', async () => {
|
||
// A plugin-owned value that changes without any other UI event must still
|
||
// repaint: the registry notifies the renderer through its subscription.
|
||
const result = await setup({ config: { theme: { leftPrompt: '${custom}${model}' } } })
|
||
const handle = result.ctx.tuiPrompt.register('custom', 'BEFORE ')
|
||
await tick()
|
||
expect(result.terminal.output).toContain('BEFORE ')
|
||
|
||
result.terminal.output = ''
|
||
handle.set('AFTER ')
|
||
// The coalesced notification lands on a microtask; no session/agent event fires.
|
||
await tick()
|
||
expect(result.terminal.output).toContain('AFTER ')
|
||
expect(result.terminal.output).not.toContain('BEFORE ')
|
||
await dispose(result)
|
||
})
|
||
|
||
it('tracks steering drains without a running status line', async () => {
|
||
const result = await setup()
|
||
const source = { kind: 'user' as const }
|
||
result.ctx.emit('agent/inbox/enqueue', result.agent, { id: AgentMessageId('stub'), content: [{ type: 'text', text: 'early' }], source }, 'steering')
|
||
result.session.append('steering/message', { turn: 1, content: [{ type: 'text', text: 'early' }], source }, { surfaceOp: 'append' })
|
||
await tick()
|
||
expect(result.terminal.output).not.toContain('queued')
|
||
await dispose(result)
|
||
})
|
||
|
||
it('refreshes the running turn timing on its own timer', async () => {
|
||
let now = 1_700_000_000_000
|
||
const nowSpy = vi.spyOn(Date, 'now').mockImplementation(() => now)
|
||
const intervals = vi.spyOn(globalThis, 'setInterval')
|
||
let result: Awaited<ReturnType<typeof setup>> | undefined
|
||
try {
|
||
result = await setup({ status: 'running', now: () => now })
|
||
// The running prompt animates at ~20 fps (50 ms); the same tick keeps the
|
||
// elapsed timing text current, so no separate timing-only timer exists.
|
||
const refresh = intervals.mock.calls.find(([, interval]) => interval === 50)?.[0]
|
||
if (typeof refresh !== 'function') throw new Error('TUI did not register its running-status refresh interval')
|
||
result.terminal.output = ''
|
||
now += 1_000
|
||
refresh()
|
||
await tick()
|
||
expect(result.terminal.output).toContain('Model wait 1.0s')
|
||
} finally {
|
||
if (result !== undefined) await dispose(result)
|
||
intervals.mockRestore()
|
||
nowSpy.mockRestore()
|
||
}
|
||
})
|
||
|
||
it('shows minutes and seconds in accumulated timing', async () => {
|
||
let clock = 1_700_000_000_000
|
||
const nowSpy = vi.spyOn(Date, 'now').mockImplementation(() => clock)
|
||
const result = await setup({ status: 'running' })
|
||
clock += 95_000
|
||
result.terminal.output = ''
|
||
result.session.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'hi' } })
|
||
await tick()
|
||
expect(result.terminal.output).toContain('Model wait 1m35.0s')
|
||
nowSpy.mockRestore()
|
||
await dispose(result)
|
||
})
|
||
|
||
it('trails the completed step timing below the step tool cards, not above them', async () => {
|
||
const clock = new Date(2026, 6, 21, 12, 0, 0).getTime()
|
||
const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(clock)
|
||
const result = await setup({ status: 'running' })
|
||
// A step whose assistant message drives a tool call: the tool card is
|
||
// appended after the assistant text, so the timing footer must follow the
|
||
// tool output rather than sit above it (its first message).
|
||
appendAssistant(result.session, [
|
||
{ type: 'text', text: 'Running a command' },
|
||
{ type: 'tool-call', id: 'c1' as never, name: 'bash', arguments: '{}' },
|
||
])
|
||
result.session.append('tool/call', { turn: 1, step: 1, callId: 'c1' as never, name: 'bash', arguments: '{}' })
|
||
result.session.append('tool/result', {
|
||
turn: 1, step: 1, callId: 'c1' as never, content: [{ type: 'text', text: 'command output' }], isError: false,
|
||
}, { surfaceOp: 'append' })
|
||
result.terminal.output = ''
|
||
result.session.append('step/end', { turn: 1, step: 1 })
|
||
await tick()
|
||
|
||
const frame = result.terminal.output
|
||
const toolAt = frame.indexOf('command output')
|
||
const timingAt = frame.indexOf('Completed 2026-07-21 12:00:00')
|
||
expect(toolAt).toBeGreaterThanOrEqual(0)
|
||
expect(timingAt).toBeGreaterThan(toolAt)
|
||
nowSpy.mockRestore()
|
||
await dispose(result)
|
||
})
|
||
|
||
it('preserves accumulated timing across a mid-turn color-scheme change', async () => {
|
||
let clock = 1_700_000_000_000
|
||
const nowSpy = vi.spyOn(Date, 'now').mockImplementation(() => clock)
|
||
const result = await setup({ status: 'running' })
|
||
clock += 1_000
|
||
result.session.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'answering' } })
|
||
clock += 4_000
|
||
result.terminal.output = ''
|
||
result.terminal.send('\x1b[?997;2n')
|
||
await tick()
|
||
await tick()
|
||
expect(result.terminal.output).toContain('Model wait 1.0s · Response 4.0s')
|
||
|
||
nowSpy.mockRestore()
|
||
await dispose(result)
|
||
})
|
||
|
||
it('renders the ANSI palette and every markdown/content style', async () => {
|
||
const result = await setup({
|
||
cwd: '/workspace',
|
||
config: { theme: { 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\n\n```ts\nconst answer = 42\n```' },
|
||
], { 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('\x1b[36mconst answer = 42\x1b[39m')
|
||
expect(result.terminal.output).not.toContain('```')
|
||
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: 1,
|
||
step: 1,
|
||
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 })
|
||
},
|
||
})
|
||
await vi.waitFor(() => {
|
||
expect(homeResult.terminal.output).toContain('~ (tui-staging) deepseek-v4-flash ↑25k ↓10k')
|
||
})
|
||
await dispose(homeResult)
|
||
|
||
const childResult = await setup({ cwd: join(home, 'projects', 'dsh-tui') })
|
||
await vi.waitFor(() => {
|
||
expect(childResult.terminal.output).toContain(join('~', 'projects', 'dsh-tui'))
|
||
})
|
||
await dispose(childResult)
|
||
|
||
const unsetResult = await setup({ cwd: null })
|
||
await vi.waitFor(() => {
|
||
expect(unsetResult.terminal.output).toContain('cwd unset')
|
||
})
|
||
await dispose(unsetResult)
|
||
|
||
const homeParent = resolve(home, '..')
|
||
const parentResult = await setup({ cwd: homeParent })
|
||
await vi.waitFor(() => {
|
||
expect(parentResult.terminal.output).toContain(homeParent)
|
||
})
|
||
await dispose(parentResult)
|
||
|
||
const outsideResult = await setup({ cwd: '/opt' })
|
||
await vi.waitFor(() => {
|
||
expect(outsideResult.terminal.output).toContain('/opt')
|
||
})
|
||
await dispose(outsideResult)
|
||
|
||
const logicalResult = await setup({
|
||
cwd: '/w',
|
||
formatCwd: cwd => `logical:${cwd}\x1b`,
|
||
})
|
||
await vi.waitFor(() => {
|
||
expect(logicalResult.terminal.output).toContain('logical:/w\\x1b')
|
||
})
|
||
await dispose(logicalResult)
|
||
})
|
||
|
||
it('shows the session cache hit rate in the footer and updates it live', async () => {
|
||
// Empty session: no input billed yet, so the cache segment is hidden.
|
||
// A cwd without "cache" in it keeps the negative assertion unambiguous.
|
||
const empty = await setup({ cwd: '/opt' })
|
||
expect(empty.terminal.output).toContain('↑0 ↓0')
|
||
expect(empty.terminal.output).not.toContain('cache')
|
||
await dispose(empty)
|
||
|
||
const result = await setup({
|
||
// Pin a short cwd so the footer never clips the cache segment: the
|
||
// default is process.cwd(), and a deep worktree path truncates
|
||
// `cache 60%` at the terminal width.
|
||
cwd: '/opt',
|
||
beforeMount(session) {
|
||
// Cold call: 10 billed input tokens, none served from cache.
|
||
appendAssistant(session, [{ type: 'text', text: 'cold' }], { inputTokens: 10, outputTokens: 5 })
|
||
},
|
||
})
|
||
expect(result.terminal.output).toContain('cache 0%')
|
||
|
||
result.terminal.output = ''
|
||
// Warm call lands live on the next step (same-step usage replaces rather
|
||
// than accumulates): 5 uncached + 30 cache-read + 5 cache-write billed
|
||
// input, so 30 of the 50 total prompt tokens are hits → 60%.
|
||
appendAssistant(result.session, [{ type: 'text', text: 'warm' }], {
|
||
inputTokens: 5,
|
||
outputTokens: 5,
|
||
cacheReadTokens: 30,
|
||
cacheWriteTokens: 5,
|
||
}, { turn: 1, step: 2 })
|
||
await tick()
|
||
expect(result.terminal.output).toContain('cache 60%')
|
||
expect(result.terminal.output).not.toContain('cache 0%')
|
||
await dispose(result)
|
||
})
|
||
|
||
it('shows detailed session diagnostics while the agent is running', async () => {
|
||
const timestamp = Date.parse('2026-07-22T09:10:11.000Z')
|
||
const dateNow = vi.spyOn(Date, 'now').mockReturnValue(timestamp)
|
||
const result = await setup({
|
||
cwd: '/workspace/status',
|
||
contextWindow: 128_000,
|
||
contextTokens: 42_000,
|
||
config: { showReasoning: false },
|
||
agentOptions: { provider: 'deepseek', model: 'deepseek-v4-pro' },
|
||
tools: {
|
||
read: {
|
||
name: 'read', description: 'Read a file', parameters: {},
|
||
output: { schema: { type: 'null' }, render: () => [] }, execute: async () => null,
|
||
},
|
||
write: {
|
||
name: 'write', description: 'Write a file', parameters: {},
|
||
output: { schema: { type: 'null' }, render: () => [] }, execute: async () => null,
|
||
},
|
||
},
|
||
beforeMount(session) {
|
||
session.append('session/title', {
|
||
title: 'Inspect status \u001B]2;unsafe\u0007',
|
||
messageSeqs: [1],
|
||
source: { kind: 'fallback' },
|
||
})
|
||
appendAssistant(session, [{ type: 'text', text: 'measured' }], {
|
||
inputTokens: 1_250,
|
||
outputTokens: 340,
|
||
cacheReadTokens: 3_000,
|
||
cacheWriteTokens: 250,
|
||
})
|
||
session.append('tool/call', {
|
||
turn: 1, step: 1, callId: 'status-call-1' as never, name: 'read', arguments: '{}',
|
||
})
|
||
session.append('tool/call', {
|
||
turn: 1, step: 1, callId: 'status-call-2' as never, name: 'write', arguments: '{}',
|
||
})
|
||
},
|
||
})
|
||
result.ctx.systemPrompt.section({
|
||
name: 'test:status',
|
||
order: 1,
|
||
text: 'Current instructions \u001B]2;prompt-unsafe\u0007',
|
||
})
|
||
result.agent.status = 'running'
|
||
agentEvents(result.ctx, result.agent).emit('agent/status', 'running')
|
||
result.terminal.send('/status')
|
||
result.terminal.send('\r')
|
||
await vi.waitFor(() => {
|
||
expect(result.terminal.output).toContain('Session status')
|
||
})
|
||
|
||
expect(result.terminal.output).toContain('Session status')
|
||
expect(result.terminal.output).toContain('main-session')
|
||
expect(result.terminal.output).toContain('Inspect status \\x1b]2;unsafe\\x07')
|
||
expect(result.terminal.output).toContain('/workspace/status')
|
||
expect(result.terminal.output).toContain('deepseek/deepseek-v4-pro (effort default; reasoning blocks')
|
||
expect(result.terminal.output).toContain('hidden)')
|
||
expect(result.terminal.output).toContain('running · 6 events · 1 turn · 1 step · 2 tool calls')
|
||
expect(result.terminal.output).toContain('1,250 input + 340 output')
|
||
expect(result.terminal.output).toContain('[███████████░░░░░] 67% hit (3,000 read + 250 write)')
|
||
expect(result.terminal.output).toContain('[█████░░░░░░░░░░░] 33% used (42,000 / 128,000)')
|
||
expect(result.terminal.output).toContain('2026-07-22 09:10:11 UTC')
|
||
expect(result.terminal.output).toContain('System prompt')
|
||
expect(result.terminal.output).toContain('You are an AI agent powered by the DeepSeek Harness SDK.')
|
||
expect(result.terminal.output).toContain('Current instructions \\x1b]2;prompt-unsafe\\x07')
|
||
expect(result.terminal.output).toContain('Registered tools')
|
||
expect(result.terminal.output).toContain('read, write')
|
||
expect(result.terminal.output).not.toContain('\u001B]2;unsafe\u0007')
|
||
|
||
result.terminal.resize(56)
|
||
result.terminal.send('/redraw')
|
||
result.terminal.send('\r')
|
||
await tick()
|
||
|
||
await dispose(result)
|
||
dateNow.mockRestore()
|
||
})
|
||
|
||
it('labels unavailable status diagnostics without inventing values', async () => {
|
||
const timestamp = Date.parse('2026-07-22T10:11:12.000Z')
|
||
const dateNow = vi.spyOn(Date, 'now').mockReturnValue(timestamp)
|
||
const result = await setup({
|
||
cwd: null,
|
||
omitInitialLifecycle: true,
|
||
contextTokens: 7,
|
||
agentOptions: {},
|
||
catalog: {
|
||
providers: [],
|
||
models: [],
|
||
resolveModelInfo: () => Promise.resolve({}),
|
||
},
|
||
})
|
||
result.terminal.send('/status')
|
||
result.terminal.send('\r')
|
||
await tick()
|
||
|
||
expect(result.terminal.output).toContain('untitled')
|
||
expect(result.terminal.output).toContain('unset (effort unset; reasoning blocks shown)')
|
||
expect(result.terminal.output).toContain('idle · 0 events · 0 turns · 0 steps · 0 tool calls')
|
||
expect(result.terminal.output).toContain('n/a (0 read + 0 write)')
|
||
expect(result.terminal.output).toContain('7 used · capacity unknown')
|
||
expect(result.terminal.output).toContain('2026-07-22 10:11:12 UTC')
|
||
expect(result.terminal.output).toContain('You are an AI agent powered by the DeepSeek Harness SDK.')
|
||
expect(result.terminal.output).toContain('(none)')
|
||
await dispose(result)
|
||
dateNow.mockRestore()
|
||
})
|
||
|
||
it('/quit exits while idle', async () => {
|
||
const result = await setup()
|
||
result.terminal.send('/quit')
|
||
result.terminal.send('\r')
|
||
await tick()
|
||
|
||
expect(result.exit).toHaveBeenCalledWith(0)
|
||
await dispose(result)
|
||
})
|
||
|
||
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.ctx.emit('agent/status', result.agent, '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')
|
||
expect(result.agent.cancelled).toContainEqual({ kind: 'user' })
|
||
|
||
result.agent.status = 'idle'
|
||
for (const command of ['/help', '/reasoning', '/tools', '/redraw', '/reload']) {
|
||
result.terminal.send(command)
|
||
result.terminal.send('\r')
|
||
await tick()
|
||
}
|
||
for (const command of ['/clear', '/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('Unknown command')
|
||
// /reload without a Loader in the context degrades to a warning.
|
||
expect(result.terminal.output).toContain('/reload needs the cordis Loader')
|
||
expect(result.exit).toHaveBeenCalledWith(0)
|
||
|
||
// The exit above left the TUI disposed (the mocked runtime.exit returns):
|
||
// a message submitted now is refused instead of reaching the agent. The
|
||
// refusal notice lands in the transcript, but the stopped UI no longer
|
||
// paints, so assert the refusal through the agent surface.
|
||
const sentBefore = result.agent.sent.length
|
||
const steeredBefore = result.agent.steered.length
|
||
result.terminal.send('after shutdown')
|
||
result.terminal.send('\r')
|
||
await tick()
|
||
expect(result.agent.sent).toHaveLength(sentBefore)
|
||
expect(result.agent.steered).toHaveLength(steeredBefore)
|
||
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()
|
||
|
||
})
|
||
|
||
it('combines session autocomplete with files and prepares send/steer references asynchronously', async () => {
|
||
const sourceId = SessionId('source-session')
|
||
const sourceHeader: SessionHeader = {
|
||
version: 0,
|
||
id: sourceId,
|
||
cwd: '/workspace',
|
||
createdAt: 1,
|
||
}
|
||
const noCwdHeader: SessionHeader = {
|
||
version: 0,
|
||
id: SessionId('no-cwd'),
|
||
createdAt: 2,
|
||
}
|
||
const sourceEvents: SessionEvent[] = [
|
||
{
|
||
type: 'user/message',
|
||
seq: 0,
|
||
time: 1,
|
||
data: { content: [{ type: 'text', text: 'source background' }], source: { kind: 'user' } },
|
||
surfaceOp: 'append',
|
||
},
|
||
{
|
||
type: 'session/title',
|
||
seq: 1,
|
||
time: 2,
|
||
data: {
|
||
title: 'Source chat',
|
||
messageSeqs: [0],
|
||
source: { kind: 'fallback' },
|
||
},
|
||
},
|
||
]
|
||
const result = await setup({
|
||
sessionPersistence: {
|
||
list: async () => [noCwdHeader, sourceHeader],
|
||
load: async (id) => {
|
||
if (id === sourceId) return { meta: sourceHeader, events: sourceEvents }
|
||
if (id === noCwdHeader.id) return { meta: noCwdHeader, events: [] }
|
||
throw new Error(`unexpected persisted session ${id}`)
|
||
},
|
||
},
|
||
async configureContext(ctx) {
|
||
ctx.provide('tools', { get: () => undefined } as never)
|
||
await ctx.plugin(TestSessionQueryService)
|
||
await ctx.plugin(SessionReferenceService)
|
||
},
|
||
})
|
||
|
||
result.terminal.send('@no-cwd')
|
||
await vi.waitFor(() => { expect(result.terminal.output).toContain('Session · no-cwd') })
|
||
expect(result.terminal.output).toContain('(no cwd)')
|
||
result.terminal.send('\x03')
|
||
|
||
result.terminal.send('@chat')
|
||
await vi.waitFor(() => { expect(result.terminal.output).toContain('Session · Source chat') })
|
||
expect(result.terminal.output).toContain('source-session')
|
||
result.terminal.send('\t')
|
||
await tick()
|
||
result.terminal.send('\r')
|
||
await vi.waitFor(() => { expect(result.agent.sent).toHaveLength(1) })
|
||
expect(result.agent.sent).toEqual([[{ type: 'text', text: '@Source chat' }]])
|
||
// Idle: the snapshot rides the prompt's admission (additionalContexts on
|
||
// the allow decision), not a separate pre-admission inject.
|
||
expect(result.agent.injected).toHaveLength(0)
|
||
const decision = await agentEvents(result.ctx, result.agent).waterfall(
|
||
'agent/prompt-submit', result.agent.sent[0]!, { kind: 'user' },
|
||
new AbortController().signal, () => Promise.resolve({ kind: 'allow' as const }),
|
||
)
|
||
expect(decision.kind).toBe('allow')
|
||
expect(decision.kind === 'allow' && decision.additionalContexts?.[0]?.source)
|
||
.toMatchObject({ kind: 'session-reference', references: [{ sessionId: 'source-session' }] })
|
||
|
||
// The one-shot wrapper detached itself at admission: replaying the
|
||
// waterfall attaches nothing a second time.
|
||
const replay = await agentEvents(result.ctx, result.agent).waterfall(
|
||
'agent/prompt-submit', result.agent.sent[0]!, { kind: 'user' },
|
||
new AbortController().signal, () => Promise.resolve({ kind: 'allow' as const }),
|
||
)
|
||
expect(replay.kind === 'allow' && replay.additionalContexts).toBeUndefined()
|
||
|
||
const mention = formatSessionReferenceMention({ sessionId: sourceId, label: 'Source chat' })
|
||
result.agent.status = 'running'
|
||
result.terminal.send(`steer ${mention}`)
|
||
result.terminal.send('\r')
|
||
await vi.waitFor(() => { expect(result.agent.steered).toHaveLength(1) })
|
||
expect(result.agent.steered).toEqual([[{ type: 'text', text: 'steer @Source chat' }]])
|
||
// Steering bypasses admission, so its snapshot still arrives via inject.
|
||
expect(result.agent.injected).toHaveLength(1)
|
||
await dispose(result)
|
||
})
|
||
|
||
it('keeps a referenced prompt on admission when running no longer accepts next-step input', async () => {
|
||
const result = await setup({
|
||
status: 'running',
|
||
acceptsNextStep: false,
|
||
omitInitialLifecycle: true,
|
||
async configureContext(ctx) {
|
||
ctx.provide('tools', { get: () => undefined } as never)
|
||
await ctx.plugin(TestSessionQueryService)
|
||
await ctx.plugin(SessionReferenceService)
|
||
const source = ctx.sessions.create(SessionId('admission-src'), {
|
||
meta: { cwd: process.cwd(), createdAt: 1 },
|
||
})
|
||
appendUser(source, 'source background')
|
||
source.append('session/title', {
|
||
title: 'Admission source',
|
||
messageSeqs: [0],
|
||
source: { kind: 'fallback' },
|
||
})
|
||
},
|
||
})
|
||
|
||
result.terminal.send(formatSessionReferenceMention({
|
||
sessionId: SessionId('admission-src'),
|
||
label: 'Admission source',
|
||
}))
|
||
result.terminal.send('\r')
|
||
await vi.waitFor(() => { expect(result.agent.sent).toHaveLength(1) })
|
||
|
||
expect(result.agent.steered).toHaveLength(0)
|
||
expect(result.agent.injected).toHaveLength(0)
|
||
const decision = await agentEvents(result.ctx, result.agent).waterfall(
|
||
'agent/prompt-submit', result.agent.sent[0]!, { kind: 'user' },
|
||
new AbortController().signal, () => Promise.resolve({ kind: 'allow' as const }),
|
||
)
|
||
expect(decision.kind === 'allow' && decision.additionalContexts?.[0]?.source)
|
||
.toMatchObject({ kind: 'session-reference', references: [{ sessionId: 'admission-src' }] })
|
||
await dispose(result)
|
||
})
|
||
|
||
it('releases the reference-admission wrapper on the ordinary allowed path', async () => {
|
||
const result = await setup({
|
||
async configureContext(ctx) {
|
||
ctx.provide('tools', { get: () => undefined } as never)
|
||
await ctx.plugin(TestSessionQueryService)
|
||
await ctx.plugin(SessionReferenceService)
|
||
const source = ctx.sessions.create(SessionId('leak-source'), { meta: { cwd: process.cwd(), createdAt: 1 } })
|
||
appendUser(source, 'source background')
|
||
},
|
||
})
|
||
const send = async (): Promise<void> => {
|
||
result.terminal.send('@leak-source')
|
||
await vi.waitFor(() => { expect(result.terminal.output).toContain('Session · leak-source') })
|
||
result.terminal.send('\t')
|
||
await tick()
|
||
result.terminal.send('\r')
|
||
}
|
||
await send()
|
||
await vi.waitFor(() => { expect(result.agent.sent).toHaveLength(1) })
|
||
await send()
|
||
await vi.waitFor(() => { expect(result.agent.sent).toHaveLength(2) })
|
||
|
||
// Each wrapper releases on its own allowed admission — matched by the
|
||
// message content it carries, not the returned id, which real send()
|
||
// assigns as a random UUID only after followup() returns. Running each
|
||
// prompt's admission waterfall detaches its wrapper.
|
||
for (const sent of result.agent.sent) {
|
||
await agentEvents(result.ctx, result.agent).waterfall(
|
||
'agent/prompt-submit', sent, { kind: 'user' },
|
||
new AbortController().signal, () => Promise.resolve({ kind: 'allow' as const }),
|
||
)
|
||
}
|
||
// Both wrappers now gone: a discard naming either prompt's content finds
|
||
// no armed listener, and an unrelated admission is untouched. The leak
|
||
// regression: a listener installed after its cleanup already ran would
|
||
// survive every future cleanup.
|
||
result.ctx.emit('agent/inbox/discard', result.agent, [{
|
||
id: AgentMessageId('stub'), content: result.agent.sent[0]!, source: { kind: 'user' },
|
||
}])
|
||
const unrelated = await agentEvents(result.ctx, result.agent).waterfall(
|
||
'agent/prompt-submit', [{ type: 'text', text: 'unrelated' }], { kind: 'user' },
|
||
new AbortController().signal, () => Promise.resolve({ kind: 'allow' as const }),
|
||
)
|
||
expect(unrelated.kind === 'allow' && unrelated.additionalContexts).toBeUndefined()
|
||
// Replaying either sent prompt attaches nothing: the one-shot wrappers
|
||
// are gone, not merely spent.
|
||
for (const sent of result.agent.sent) {
|
||
const replay = await agentEvents(result.ctx, result.agent).waterfall(
|
||
'agent/prompt-submit', sent, { kind: 'user' },
|
||
new AbortController().signal, () => Promise.resolve({ kind: 'allow' as const }),
|
||
)
|
||
expect(replay.kind === 'allow' && replay.additionalContexts).toBeUndefined()
|
||
}
|
||
await dispose(result)
|
||
})
|
||
|
||
it('releases the reference wrapper when enqueue synchronously discards before followup returns', async () => {
|
||
const result = await setup({
|
||
async configureContext(ctx) {
|
||
ctx.provide('tools', { get: () => undefined } as never)
|
||
await ctx.plugin(TestSessionQueryService)
|
||
await ctx.plugin(SessionReferenceService)
|
||
const source = ctx.sessions.create(SessionId('sync-source'), { meta: { cwd: process.cwd(), createdAt: 1 } })
|
||
appendUser(source, 'source background')
|
||
},
|
||
})
|
||
// Real send() publishes its snapshotted message, then an enqueue listener
|
||
// may synchronously cancel and discard it before followup() returns the
|
||
// already-assigned id. This stub reproduces that ordering.
|
||
const foreign = { ...result.agent, id: SessionId('foreign') } as unknown as Agent
|
||
result.agent.followup = (input) => {
|
||
result.agent.sent.push(input.content)
|
||
const message = {
|
||
id: AgentMessageId('stub'),
|
||
content: structuredClone(input.content),
|
||
source: structuredClone(input.source),
|
||
}
|
||
result.ctx.emit('agent/inbox/enqueue', foreign, message, 'queued')
|
||
result.ctx.emit('agent/inbox/enqueue', result.agent, message, 'queued')
|
||
result.ctx.emit('agent/inbox/discard', result.agent, [message])
|
||
return message.id
|
||
}
|
||
|
||
result.terminal.send('@sync-source')
|
||
await vi.waitFor(() => { expect(result.terminal.output).toContain('Session · sync-source') })
|
||
result.terminal.send('\t')
|
||
await tick()
|
||
result.terminal.send('\r')
|
||
await vi.waitFor(() => { expect(result.agent.sent).toHaveLength(1) })
|
||
|
||
// The synchronous discard released the listeners even though followup()
|
||
// had not returned the id yet: replaying the prompt's admission attaches
|
||
// no stranded snapshot, and nothing leaks for the TUI lifetime.
|
||
const replay = await agentEvents(result.ctx, result.agent).waterfall(
|
||
'agent/prompt-submit', result.agent.sent[0]!, { kind: 'user' },
|
||
new AbortController().signal, () => Promise.resolve({ kind: 'allow' as const }),
|
||
)
|
||
expect(replay.kind === 'allow' && replay.additionalContexts).toBeUndefined()
|
||
await dispose(result)
|
||
})
|
||
|
||
it('discards the reference snapshot with its blocked or cancelled prompt', async () => {
|
||
const result = await setup({
|
||
async configureContext(ctx) {
|
||
ctx.provide('tools', { get: () => undefined } as never)
|
||
await ctx.plugin(TestSessionQueryService)
|
||
await ctx.plugin(SessionReferenceService)
|
||
const source = ctx.sessions.create(SessionId('blocked-source'), { meta: { cwd: process.cwd(), createdAt: 1 } })
|
||
appendUser(source, 'source background')
|
||
},
|
||
})
|
||
// A downstream admission hook blocks the prompt: the attached snapshot
|
||
// must be discarded with it, not stranded for the next prompt.
|
||
let blockPrompts = true
|
||
result.ctx.on('agent/prompt-submit', async (_agent, _content, _source, _signal, next) =>
|
||
blockPrompts ? { kind: 'block' as const, reason: 'policy' } : next())
|
||
|
||
result.terminal.send('@blocked-source')
|
||
await vi.waitFor(() => { expect(result.terminal.output).toContain('Session · blocked-source') })
|
||
result.terminal.send('\t')
|
||
await tick()
|
||
result.terminal.send('\r')
|
||
await vi.waitFor(() => { expect(result.agent.sent).toHaveLength(1) })
|
||
|
||
const blocked = await agentEvents(result.ctx, result.agent).waterfall(
|
||
'agent/prompt-submit', result.agent.sent[0]!, { kind: 'user' },
|
||
new AbortController().signal, () => Promise.resolve({ kind: 'allow' as const }),
|
||
)
|
||
expect(blocked.kind).toBe('block')
|
||
// Nothing entered history and nothing waits for a later prompt: a fresh
|
||
// unrelated admission sees no leftover contexts.
|
||
expect(result.agent.injected).toHaveLength(0)
|
||
blockPrompts = false
|
||
const unrelated = await agentEvents(result.ctx, result.agent).waterfall(
|
||
'agent/prompt-submit', [{ type: 'text', text: 'unrelated' }], { kind: 'user' },
|
||
new AbortController().signal, () => Promise.resolve({ kind: 'allow' as const }),
|
||
)
|
||
expect(unrelated.kind === 'allow' && unrelated.additionalContexts).toBeUndefined()
|
||
|
||
// Second referenced prompt, this time dropped by a broad cancel before
|
||
// any admission runs: the discard listener releases the wrapper.
|
||
result.terminal.send('@blocked-source')
|
||
await vi.waitFor(() => { expect(result.terminal.output).toContain('Session · blocked-source') })
|
||
result.terminal.send('\t')
|
||
await tick()
|
||
result.terminal.send('\r')
|
||
await vi.waitFor(() => { expect(result.agent.sent).toHaveLength(2) })
|
||
// A different prompt passing the still-armed wrapper delegates untouched.
|
||
const passthrough = await agentEvents(result.ctx, result.agent).waterfall(
|
||
'agent/prompt-submit', [{ type: 'text', text: 'different prompt' }], { kind: 'user' },
|
||
new AbortController().signal, () => Promise.resolve({ kind: 'allow' as const }),
|
||
)
|
||
expect(passthrough.kind === 'allow' && passthrough.additionalContexts).toBeUndefined()
|
||
// A foreign agent's discard leaves the wrapper armed.
|
||
const foreign = { ...result.agent, id: SessionId('foreign') } as unknown as Agent
|
||
result.ctx.emit('agent/inbox/discard', foreign, [{
|
||
id: AgentMessageId('stub'),
|
||
content: result.agent.sent.at(-1)!,
|
||
source: { kind: 'user' },
|
||
}])
|
||
result.ctx.emit('agent/inbox/discard', result.agent, [{
|
||
id: AgentMessageId('stub'),
|
||
content: result.agent.sent.at(-1)!,
|
||
source: { kind: 'user' },
|
||
}])
|
||
await tick()
|
||
// Idempotent: a repeat discard after cleanup is a no-op.
|
||
result.ctx.emit('agent/inbox/discard', result.agent, [{
|
||
id: AgentMessageId('stub'),
|
||
content: result.agent.sent.at(-1)!,
|
||
source: { kind: 'user' },
|
||
}])
|
||
const afterDiscard = await agentEvents(result.ctx, result.agent).waterfall(
|
||
'agent/prompt-submit', result.agent.sent.at(-1)!, { kind: 'user' },
|
||
new AbortController().signal, () => Promise.resolve({ kind: 'allow' as const }),
|
||
)
|
||
expect(afterDiscard.kind === 'allow' && afterDiscard.additionalContexts).toBeUndefined()
|
||
await dispose(result)
|
||
})
|
||
|
||
it('fuzzy-completes files and directories while sending only the selected path text', async () => {
|
||
const cwd = await mkdtemp(join(tmpdir(), 'dsh-tui-file-completion-'))
|
||
await mkdir(join(cwd, 'src'), { recursive: true })
|
||
await mkdir(join(cwd, 'docs'), { recursive: true })
|
||
await writeFile(join(cwd, 'src', 'source-file.ts'), 'export const source = true\n')
|
||
await writeFile(join(cwd, 'docs', 'design notes.md'), '# Design\n')
|
||
await writeFile(join(cwd, 'unsafe\nfile.ts'), 'unsafe name\n')
|
||
const result = await setup({
|
||
cwd,
|
||
tools: {
|
||
read: {
|
||
name: 'read',
|
||
description: 'Read a file.',
|
||
parameters: {},
|
||
output: UNUSED_TOOL_OUTPUT,
|
||
execute: () => Promise.resolve([]),
|
||
},
|
||
},
|
||
})
|
||
try {
|
||
const assembly = await result.ctx.systemPrompt.assemble(assembleContextFor(result.agent))
|
||
expect(assembly.sections).toContainEqual({
|
||
name: 'ui:tui-file-reference',
|
||
text: FILE_REFERENCE_PROMPT,
|
||
})
|
||
|
||
result.terminal.send('@sfts')
|
||
await vi.waitFor(() => {
|
||
expect(result.terminal.output).toContain('File · source-file.ts')
|
||
})
|
||
expect(result.terminal.output).toContain('src/source-file.ts')
|
||
result.terminal.send('\t')
|
||
await tick()
|
||
result.terminal.send('\r')
|
||
await vi.waitFor(() => { expect(result.agent.sent).toHaveLength(1) })
|
||
expect(result.agent.sent[0]).toEqual([{ type: 'text', text: '@src/source-file.ts' }])
|
||
|
||
result.terminal.send('@do')
|
||
await vi.waitFor(() => {
|
||
expect(result.terminal.output).toContain('Folder · docs/')
|
||
})
|
||
result.terminal.send('\t')
|
||
await vi.waitFor(() => {
|
||
expect(result.terminal.output).toContain('File · design notes.md')
|
||
})
|
||
result.terminal.send('\t')
|
||
await vi.waitFor(() => {
|
||
expect(result.terminal.output).toContain('@"docs/design notes.md"')
|
||
})
|
||
result.terminal.send('\r')
|
||
await vi.waitFor(() => { expect(result.agent.sent).toHaveLength(2) })
|
||
expect(result.agent.sent[1]).toEqual([{ type: 'text', text: '@"docs/design notes.md"' }])
|
||
|
||
result.terminal.send('@unsafe')
|
||
await tick()
|
||
expect(result.terminal.output).not.toContain('File · unsafe')
|
||
result.terminal.send('\x03')
|
||
} finally {
|
||
await result.controller.dispose()
|
||
const assembly = await result.ctx.systemPrompt.assemble(assembleContextFor(result.agent))
|
||
expect(assembly.sections).not.toContainEqual({
|
||
name: 'ui:tui-file-reference',
|
||
text: FILE_REFERENCE_PROMPT,
|
||
})
|
||
await result.ctx.fiber.dispose()
|
||
await rm(cwd, { recursive: true, force: true })
|
||
}
|
||
})
|
||
|
||
it('isolates failed file discovery from editor autocomplete', async () => {
|
||
const list = vi.spyOn(WorkspaceFileSearch.prototype, 'list').mockRejectedValue(new Error('search failed'))
|
||
const result = await setup()
|
||
try {
|
||
result.terminal.send('@failed')
|
||
await vi.waitFor(() => { expect(list).toHaveBeenCalled() })
|
||
await tick()
|
||
expect(result.agent.sent).toEqual([])
|
||
} finally {
|
||
list.mockRestore()
|
||
await dispose(result)
|
||
}
|
||
})
|
||
|
||
it('shows file-reference guidance only while read is visible to the agent', async () => {
|
||
const read: ToolDefinition = {
|
||
name: 'read',
|
||
description: 'Read a file.',
|
||
parameters: {},
|
||
output: UNUSED_TOOL_OUTPUT,
|
||
execute: () => Promise.resolve([]),
|
||
}
|
||
let visibility: 'none' | 'global' | 'agent' = 'none'
|
||
const result = await setup({
|
||
async configureContext(ctx) {
|
||
ctx.provide('tools', {
|
||
get(name: string, scope?: Agent) {
|
||
if (name !== 'read' || visibility === 'none') return undefined
|
||
return (scope === undefined) === (visibility === 'global') ? read : undefined
|
||
},
|
||
} as never)
|
||
},
|
||
})
|
||
const fileReferenceText = async (): Promise<string | undefined> => {
|
||
const assembly = await result.ctx.systemPrompt.assemble(assembleContextFor(result.agent))
|
||
return assembly.sections.find(section => section.name === 'ui:tui-file-reference')?.text
|
||
}
|
||
try {
|
||
expect(await fileReferenceText()).toBe('')
|
||
visibility = 'global'
|
||
expect(await fileReferenceText()).toBe('')
|
||
visibility = 'agent'
|
||
expect(await fileReferenceText()).toBe(FILE_REFERENCE_PROMPT)
|
||
visibility = 'none'
|
||
expect(await fileReferenceText()).toBe('')
|
||
} finally {
|
||
await dispose(result)
|
||
}
|
||
})
|
||
|
||
it('escapes session autocomplete metadata while preserving the referenced session id', async () => {
|
||
const unsafeId = SessionId('evil\x1b\x07\u009b\ns')
|
||
const unsafeCwd = '/x/\x1b\x07\u009b\nf'
|
||
const result = await setup({
|
||
async configureContext(ctx) {
|
||
ctx.provide('tools', { get: () => undefined } as never)
|
||
await ctx.plugin(TestSessionQueryService)
|
||
await ctx.plugin(SessionReferenceService)
|
||
const source = ctx.sessions.create(unsafeId, { meta: { cwd: unsafeCwd, createdAt: 1 } })
|
||
appendUser(source, 'safe background')
|
||
},
|
||
})
|
||
|
||
result.terminal.send('@evil')
|
||
await vi.waitFor(() => {
|
||
expect(result.terminal.output).toContain('Session · evil\\x1b\\x07\\x9b\\x0a')
|
||
})
|
||
expect(result.terminal.output).toContain('/x/\\x1b\\x07\\x9b\\x0af')
|
||
expect(result.terminal.output).not.toContain('evil\x1b\x07')
|
||
expect(result.terminal.output).not.toContain('/x/\x1b\x07')
|
||
|
||
result.terminal.send('\t')
|
||
await tick()
|
||
result.terminal.send('\r')
|
||
await vi.waitFor(() => { expect(result.agent.sent).toHaveLength(1) })
|
||
expect(result.agent.sent).toEqual([[
|
||
{ type: 'text', text: '@evil\\x1b\\x07\\x9b\\x0as' },
|
||
]])
|
||
const decision = await agentEvents(result.ctx, result.agent).waterfall(
|
||
'agent/prompt-submit', result.agent.sent[0]!, { kind: 'user' },
|
||
new AbortController().signal, () => Promise.resolve({ kind: 'allow' as const }),
|
||
)
|
||
expect(decision.kind === 'allow' && decision.additionalContexts?.[0]?.source)
|
||
.toMatchObject({ references: [{ sessionId: unsafeId }] })
|
||
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(TestSessionQueryService)
|
||
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 releaseBase: (() => void) | undefined
|
||
const baseSuggestions = vi.spyOn(CombinedAutocompleteProvider.prototype, 'getSuggestions')
|
||
.mockImplementationOnce(async () => {
|
||
await new Promise<void>((resolve) => { releaseBase = resolve })
|
||
return null
|
||
})
|
||
listCandidates.mockResolvedValueOnce([])
|
||
result.terminal.send('@base-slow')
|
||
await vi.waitFor(() => { expect(releaseBase).toBeTypeOf('function') })
|
||
const baseWaitSignal = listCandidates.mock.calls.at(-1)?.[3]
|
||
result.terminal.send('x')
|
||
await vi.waitFor(() => { expect(baseWaitSignal?.aborted).toBe(true) })
|
||
releaseBase?.()
|
||
await tick()
|
||
baseSuggestions.mockRestore()
|
||
|
||
let delayedSignal: AbortSignal | undefined
|
||
let delayed = true
|
||
listCandidates.mockImplementation(async (...args) => {
|
||
if (!delayed) return originalListCandidates(...args)
|
||
delayed = false
|
||
delayedSignal = args[3]
|
||
if (delayedSignal === undefined) throw new Error('expected autocomplete cancellation signal')
|
||
await new Promise<void>((_resolve, reject) => {
|
||
delayedSignal?.addEventListener('abort', () => { reject(new Error('superseded')) }, { once: true })
|
||
})
|
||
return []
|
||
})
|
||
result.terminal.send('@slow')
|
||
await vi.waitFor(() => { expect(delayedSignal).toBeDefined() })
|
||
result.terminal.send('x')
|
||
await vi.waitFor(() => { expect(delayedSignal?.aborted).toBe(true) })
|
||
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(TestSessionQueryService)
|
||
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('user/message', {
|
||
content: [{ type: 'text', text: 'hidden snapshot payload' }],
|
||
source: {
|
||
kind: 'session-reference',
|
||
references: [{ sessionId: 'prefixed', label: 'Prefixed source' }],
|
||
} as never,
|
||
}, { surfaceOp: 'append' })
|
||
result.session.append('user/message', {
|
||
content: [{ type: 'text', text: 'visible referenced question' }],
|
||
source: { kind: 'user' },
|
||
}, { surfaceOp: 'append' })
|
||
await tick()
|
||
expect(result.terminal.output).toContain('visible referenced question')
|
||
expect(result.terminal.output).toContain('Referenced sessions · Prefixed source (prefixed)')
|
||
expect(result.terminal.output).not.toContain('hidden snapshot payload')
|
||
|
||
result.session.append('user/message', {
|
||
content: [{ type: 'text', text: 'hidden steering context' }],
|
||
source: {
|
||
kind: 'session-reference',
|
||
references: [{ sessionId: 'steering-source', label: 'Steering source' }],
|
||
} as never,
|
||
}, { surfaceOp: 'append' })
|
||
result.session.append('steering/message', {
|
||
turn: 1,
|
||
content: [{ type: 'text', text: 'visible steering prompt' }],
|
||
source: { kind: 'user' },
|
||
}, { surfaceOp: 'append' })
|
||
await tick()
|
||
expect(result.terminal.output).toContain('visible steering prompt')
|
||
expect(result.terminal.output).toContain('Referenced sessions · Steering source (steering-source)')
|
||
expect(result.terminal.output).not.toContain('hidden steering context')
|
||
|
||
result.session.append('user/message', {
|
||
content: [{ type: 'text', text: 'secret full snapshot payload' }],
|
||
source: {
|
||
kind: 'session-reference',
|
||
version: 1,
|
||
references: [{ sessionId: 'source', label: 'Source', capturedThroughSeq: 2 }],
|
||
} as never,
|
||
}, { 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][] = [
|
||
['plain-string-source', 'invalid-shape'],
|
||
[{ kind: 'other' }, 'invalid-kind'],
|
||
[{ kind: 'session-reference', references: [null] }, 'invalid-entry'],
|
||
[{ kind: 'session-reference', references: [{}] }, 'invalid-fields'],
|
||
]
|
||
for (const [source, text] of invalidCards) {
|
||
result.session.append('user/message', {
|
||
content: [{ type: 'text', text }],
|
||
source: source as never,
|
||
}, { surfaceOp: 'append' })
|
||
}
|
||
result.session.append('user/message', {
|
||
content: [{ type: 'text', text: 'same-label snapshot' }],
|
||
source: { kind: 'session-reference', references: [{ sessionId: 'same', label: 'same' }] } as never,
|
||
}, { 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(TestSessionQueryService)
|
||
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 }) }
|
||
}),
|
||
)
|
||
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(TestSessionQueryService)
|
||
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 }) }
|
||
}),
|
||
)
|
||
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('opens a keyboard selector and switches the session model without sending slash text to the agent', async () => {
|
||
const initialContext = Promise.withResolvers<{ contextWindow: number }>()
|
||
let deferInitialContext = true
|
||
const result = await setup({
|
||
agentOptions: { provider: 'alpha', model: 'a1' },
|
||
contextTokens: 50,
|
||
catalog: {
|
||
providers: [{ id: 'alpha', name: 'Alpha' }, { id: 'beta', name: 'Beta' }],
|
||
models: [
|
||
{ provider: 'alpha', id: 'a1', name: 'Alpha One', description: 'Fast' },
|
||
{ provider: 'alpha', id: 'shared', name: 'Alpha Shared' },
|
||
{ provider: 'beta', id: 'b1', name: 'Beta One' },
|
||
{ provider: 'beta', id: 'shared', name: 'Beta Shared' },
|
||
],
|
||
async resolveModelInfo(provider, model) {
|
||
const shouldDeferContext = provider === 'alpha' && model === 'a1' && deferInitialContext
|
||
if (shouldDeferContext) deferInitialContext = false
|
||
const context = shouldDeferContext
|
||
? await initialContext.promise
|
||
: { contextWindow: 200 }
|
||
let reasoning: LlmModelReasoningInfo | undefined
|
||
if (model === 'a1') {
|
||
reasoning = {
|
||
efforts: [
|
||
{ id: ReasoningEffortId('low'), name: 'Low' },
|
||
{ id: ReasoningEffortId('high'), name: 'High' },
|
||
],
|
||
defaultEffort: ReasoningEffortId('low'),
|
||
}
|
||
} else if (model === 'b1') {
|
||
reasoning = {
|
||
efforts: [
|
||
{ id: ReasoningEffortId('high'), name: 'High' },
|
||
{ id: ReasoningEffortId('max'), name: 'Max' },
|
||
],
|
||
defaultEffort: ReasoningEffortId('high'),
|
||
}
|
||
} else if (provider === 'alpha' && model === 'shared') {
|
||
reasoning = {
|
||
efforts: [
|
||
{ id: ReasoningEffortId('standard'), name: 'Standard' },
|
||
{ id: ReasoningEffortId('ultra'), name: 'Ultra' },
|
||
],
|
||
}
|
||
}
|
||
return {
|
||
context,
|
||
...reasoning === undefined ? {} : { reasoning },
|
||
}
|
||
},
|
||
},
|
||
})
|
||
|
||
for (const command of ['/model too many model arguments', '/model missing', '/model shared', '/model alpha/a1', '/model alpha a1']) {
|
||
result.terminal.send(command)
|
||
result.terminal.send('\r')
|
||
await tick()
|
||
}
|
||
expect(result.terminal.output).toContain('Usage: /model')
|
||
expect(result.terminal.output).toContain('Unknown model: missing')
|
||
expect(result.terminal.output).toContain('advertised by multiple providers')
|
||
expect(result.terminal.output).toContain('already alpha/a1')
|
||
|
||
result.terminal.send('/model')
|
||
result.terminal.send('\r')
|
||
result.terminal.send('/model')
|
||
result.terminal.send('\r')
|
||
await tick()
|
||
expect(result.terminal.output).toContain('Select model')
|
||
result.terminal.send('\x1b')
|
||
await tick()
|
||
|
||
const providerDefaultOutput = result.terminal.output.length
|
||
result.terminal.send('/model alpha/shared')
|
||
result.terminal.send('\r')
|
||
await vi.waitFor(() => {
|
||
expect(result.terminal.output.slice(providerDefaultOutput)).toContain('Reasoning effort: provider default.')
|
||
})
|
||
result.terminal.send('/model')
|
||
result.terminal.send('\r')
|
||
await vi.waitFor(() => {
|
||
expect(result.terminal.output.slice(providerDefaultOutput)).toContain('Select model')
|
||
})
|
||
expect(result.terminal.output.slice(providerDefaultOutput)).toContain('Alpha Shared — provider default')
|
||
result.terminal.send('\x1b[Z')
|
||
await tick()
|
||
expect(result.terminal.output.slice(providerDefaultOutput)).toContain('Alpha Shared — Standard')
|
||
result.terminal.send('\r')
|
||
await tick()
|
||
expect(result.terminal.output.slice(providerDefaultOutput)).toContain('Reasoning effort: Standard.')
|
||
|
||
const resetDefaultOutput = result.terminal.output.length
|
||
result.terminal.send('/model')
|
||
result.terminal.send('\r')
|
||
await vi.waitFor(() => {
|
||
expect(result.terminal.output.slice(resetDefaultOutput)).toContain('Alpha Shared — Standard — current')
|
||
})
|
||
result.terminal.send('\x1b[Z')
|
||
await tick()
|
||
expect(result.terminal.output.slice(resetDefaultOutput)).toContain('Alpha Shared — Ultra — current')
|
||
result.terminal.send('\x1b[Z')
|
||
await tick()
|
||
expect(result.terminal.output.slice(resetDefaultOutput)).toContain('Alpha Shared — provider default')
|
||
result.terminal.send('\r')
|
||
await tick()
|
||
expect(result.terminal.output.slice(resetDefaultOutput)).toContain('Reasoning effort: provider default.')
|
||
const explicitResetSeed: LlmCallConfig = {
|
||
provider: 'beta',
|
||
model: 'b1',
|
||
reasoningEffort: ReasoningEffortId('max'),
|
||
}
|
||
await result.ctx.systemPrompt.assemble(assembleContextFor(result.agent))
|
||
await expect(agentEvents(result.ctx, result.agent).waterfall(
|
||
'agent/request',
|
||
0,
|
||
0,
|
||
new AbortController().signal,
|
||
() => Promise.resolve(explicitResetSeed),
|
||
)).resolves.toEqual({ provider: 'alpha', model: 'shared' })
|
||
|
||
const nonReasoningOutput = result.terminal.output.length
|
||
result.terminal.send('/model')
|
||
result.terminal.send('\r')
|
||
await vi.waitFor(() => {
|
||
expect(result.terminal.output.slice(nonReasoningOutput)).toContain('Select model')
|
||
})
|
||
result.terminal.send('\x1b[B')
|
||
result.terminal.send('\x1b[B')
|
||
result.terminal.send('\x1b[Z')
|
||
result.terminal.send('\r')
|
||
await tick()
|
||
expect(result.terminal.output.slice(nonReasoningOutput)).toContain('Model selected: beta/shared.')
|
||
result.terminal.send('/model beta/shared')
|
||
result.terminal.send('\r')
|
||
await vi.waitFor(() => {
|
||
expect(result.terminal.output.slice(nonReasoningOutput)).toContain('Model is already beta/shared.')
|
||
})
|
||
await result.ctx.systemPrompt.assemble(assembleContextFor(result.agent))
|
||
result.terminal.send('/model alpha/a1')
|
||
result.terminal.send('\r')
|
||
await vi.waitFor(() => {
|
||
expect(result.terminal.output.slice(nonReasoningOutput)).toContain('Reasoning effort: Low.')
|
||
})
|
||
const inheritedEffort: LlmCallConfig = {
|
||
provider: 'alpha',
|
||
model: 'a1',
|
||
reasoningEffort: ReasoningEffortId('max'),
|
||
}
|
||
await expect(agentEvents(result.ctx, result.agent).waterfall(
|
||
'agent/request',
|
||
0,
|
||
0,
|
||
new AbortController().signal,
|
||
() => Promise.resolve(inheritedEffort),
|
||
)).resolves.toEqual({ provider: 'beta', model: 'shared' })
|
||
|
||
result.agent.status = 'running'
|
||
result.terminal.send('/model')
|
||
result.terminal.send('\r')
|
||
await tick()
|
||
expect(result.terminal.output).toContain('Select model')
|
||
expect(result.terminal.output).toContain('alpha/a1')
|
||
expect(result.terminal.output).toContain('Alpha One — Fast — Low — current')
|
||
expect(result.terminal.output).toContain('Beta One — High')
|
||
result.terminal.send('\x1b[B')
|
||
result.terminal.send('\x1b[B')
|
||
result.terminal.send('\x1b[Z')
|
||
await tick()
|
||
expect(result.terminal.output).toContain('Beta One — Max')
|
||
result.terminal.send('\r')
|
||
await tick()
|
||
expect(result.terminal.output).toContain('Model selected: beta/b1')
|
||
expect(result.terminal.output).toContain('Reasoning effort: Max.')
|
||
expect(result.agent.sent).toEqual([])
|
||
expect(result.agent.steered).toEqual([])
|
||
initialContext.resolve({ contextWindow: 100 })
|
||
await tick()
|
||
expect(result.terminal.output).not.toContain('50% context')
|
||
|
||
result.terminal.send('/model')
|
||
result.terminal.send('\r')
|
||
await tick()
|
||
result.terminal.send('\x1b')
|
||
await tick()
|
||
expect(result.agent.cancelled).not.toContain('cancelled from terminal')
|
||
result.agent.status = 'idle'
|
||
result.ctx.emit('agent/status', result.agent, 'idle')
|
||
await tick()
|
||
expect(result.terminal.output).toContain('b1 max ')
|
||
expect(result.terminal.output).toContain('25% context')
|
||
expect(result.terminal.output).not.toContain('tools:collapsed')
|
||
result.terminal.send('/status')
|
||
result.terminal.send('\r')
|
||
await tick()
|
||
expect(result.terminal.output).toContain('beta/b1 (effort max; reasoning blocks shown)')
|
||
|
||
const assembly = await result.ctx.systemPrompt.assemble(assembleContextFor(result.agent))
|
||
expect(assembly.variables).toMatchObject({ provider: 'beta', model: 'b1' })
|
||
const seed: LlmCallConfig = { provider: 'alpha', model: 'a1', temperature: 0.2 }
|
||
const request = await agentEvents(result.ctx, result.agent).waterfall(
|
||
'agent/request', 1, 0, new AbortController().signal, () => Promise.resolve(seed),
|
||
)
|
||
expect(request).toEqual({
|
||
provider: 'beta',
|
||
model: 'b1',
|
||
reasoningEffort: ReasoningEffortId('max'),
|
||
temperature: 0.2,
|
||
})
|
||
await dispose(result)
|
||
})
|
||
|
||
it('restores the logged model, keeps an unlisted current model visible, and reports catalog failures', async () => {
|
||
const resumed = await setup({
|
||
agentOptions: { provider: 'alpha', model: 'configured' },
|
||
catalog: { providers: [{ id: 'beta', name: 'Beta' }], models: [] },
|
||
beforeMount(session) {
|
||
session.append('request/header', {
|
||
header: {
|
||
config: {
|
||
provider: 'beta',
|
||
model: 'private',
|
||
reasoningEffort: ReasoningEffortId('ultra'),
|
||
},
|
||
},
|
||
reason: 'initial',
|
||
})
|
||
},
|
||
})
|
||
resumed.terminal.send('/model')
|
||
resumed.terminal.send('\r')
|
||
await tick()
|
||
expect(resumed.terminal.output).toContain('Select model')
|
||
expect(resumed.terminal.output).toContain('beta/private')
|
||
expect(resumed.terminal.output).toContain('private — ultra — current')
|
||
resumed.terminal.send('\x1b')
|
||
await tick()
|
||
resumed.terminal.send('/model beta/private')
|
||
resumed.terminal.send('\r')
|
||
await tick()
|
||
expect(resumed.terminal.output).toContain('with reasoning effort ultra')
|
||
await dispose(resumed)
|
||
|
||
const resumedDefault = await setup({
|
||
catalog: {
|
||
providers: [{ id: 'alpha', name: 'Alpha' }],
|
||
models: [{ provider: 'alpha', id: 'default', name: 'Default Model' }],
|
||
},
|
||
beforeMount(session) {
|
||
session.append('request/header', {
|
||
header: { config: { provider: 'alpha', model: 'default' } },
|
||
reason: 'initial',
|
||
})
|
||
},
|
||
})
|
||
expect(resumedDefault.terminal.output).toContain('default ↑0 ↓0')
|
||
await dispose(resumedDefault)
|
||
|
||
const unset = await setup({
|
||
agentOptions: {},
|
||
catalog: {
|
||
providers: [{ id: 'alpha', name: 'Alpha' }],
|
||
models: [{ provider: 'alpha', id: 'a1', name: 'Alpha One' }],
|
||
resolveModelInfo: () => Promise.resolve({}),
|
||
},
|
||
})
|
||
unset.terminal.send('/model')
|
||
unset.terminal.send('\r')
|
||
await tick()
|
||
unset.terminal.send('\r')
|
||
await tick()
|
||
expect(unset.terminal.output).toContain('Model selected: alpha/a1')
|
||
expect(unset.terminal.output).toContain('a1 ')
|
||
expect(unset.terminal.output).not.toContain('% context')
|
||
await dispose(unset)
|
||
|
||
const empty = await setup({ agentOptions: {}, catalog: { providers: [], models: [] } })
|
||
empty.terminal.send('/model')
|
||
empty.terminal.send('\r')
|
||
await tick()
|
||
expect(empty.terminal.output).toContain('Current model: unset')
|
||
expect(empty.terminal.output).toContain('No models are advertised')
|
||
const assembly = await empty.ctx.systemPrompt.assemble(assembleContextFor(empty.agent))
|
||
expect(assembly.variables).toEqual({})
|
||
const seed: LlmCallConfig = { provider: 'fallback', model: 'fallback' }
|
||
await expect(agentEvents(empty.ctx, empty.agent).waterfall(
|
||
'agent/request', 1, 0, new AbortController().signal, () => Promise.resolve(seed),
|
||
)).resolves.toBe(seed)
|
||
await dispose(empty)
|
||
|
||
const failed = await setup({
|
||
catalog: {
|
||
providers: [{ id: 'deepseek', name: 'DeepSeek' }],
|
||
models: [],
|
||
listModels: () => Promise.reject(new Error('catalog offline')),
|
||
resolveModelInfo: () => Promise.reject(new Error('capacity offline')),
|
||
},
|
||
})
|
||
failed.terminal.send('/model')
|
||
failed.terminal.send('\r')
|
||
await vi.waitFor(() => {
|
||
expect(failed.terminal.output).toContain('Could not read the model catalog: catalog offline')
|
||
})
|
||
expect(failed.terminal.output).toContain('Could not resolve model context: capacity offline')
|
||
await dispose(failed)
|
||
|
||
const reasoningFailed = await setup({
|
||
catalog: {
|
||
providers: [{ id: 'deepseek', name: 'DeepSeek' }],
|
||
models: [{ provider: 'deepseek', id: 'model-1', name: 'Model One' }],
|
||
resolveModelInfo: () => Promise.reject(new Error('reasoning metadata offline')),
|
||
},
|
||
})
|
||
reasoningFailed.terminal.send('/model')
|
||
reasoningFailed.terminal.send('\r')
|
||
await vi.waitFor(() => {
|
||
expect(reasoningFailed.terminal.output).toContain('Could not read the model catalog: reasoning metadata offline')
|
||
})
|
||
await dispose(reasoningFailed)
|
||
})
|
||
|
||
it('does not render a model catalog that resolves after TUI disposal', async () => {
|
||
const deferred = Promise.withResolvers<never[]>()
|
||
const result = await setup({
|
||
catalog: {
|
||
providers: [{ id: 'deepseek', name: 'DeepSeek' }],
|
||
models: [],
|
||
listModels: () => deferred.promise,
|
||
},
|
||
})
|
||
result.terminal.send('/model')
|
||
result.terminal.send('\r')
|
||
await result.controller.dispose()
|
||
deferred.resolve([])
|
||
await tick()
|
||
expect(result.terminal.output).not.toContain('Available models')
|
||
await result.ctx.fiber.dispose()
|
||
|
||
const rejected = Promise.withResolvers<never[]>()
|
||
const rejectedResult = await setup({
|
||
catalog: {
|
||
providers: [{ id: 'deepseek', name: 'DeepSeek' }],
|
||
models: [],
|
||
listModels: () => rejected.promise,
|
||
},
|
||
})
|
||
rejectedResult.terminal.send('/model')
|
||
rejectedResult.terminal.send('\r')
|
||
await rejectedResult.controller.dispose()
|
||
rejected.reject(new Error('late catalog failure'))
|
||
await tick()
|
||
expect(rejectedResult.terminal.output).not.toContain('late catalog failure')
|
||
await rejectedResult.ctx.fiber.dispose()
|
||
|
||
const context = Promise.withResolvers<{ contextWindow: number }>()
|
||
const contextResult = await setup({
|
||
contextTokens: 99,
|
||
catalog: {
|
||
providers: [{ id: 'deepseek', name: 'DeepSeek' }],
|
||
models: [],
|
||
resolveModelInfo: () => context.promise.then(value => ({ context: value })),
|
||
},
|
||
})
|
||
await contextResult.controller.dispose()
|
||
context.resolve({ contextWindow: 100 })
|
||
await tick()
|
||
expect(contextResult.terminal.output).not.toContain('99% context')
|
||
await contextResult.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.ctx.commands.register({
|
||
name: 'plugin-error',
|
||
description: 'Return an error result',
|
||
handler: () => ({ kind: 'error' as const, text: 'plugin error result' }),
|
||
})
|
||
|
||
result.terminal.send('/plugin-ch')
|
||
await tick()
|
||
expect(result.terminal.output).toContain('<value> — Run a plugin command')
|
||
result.terminal.send('\x03')
|
||
|
||
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('/plugin-error')
|
||
result.terminal.send('\r')
|
||
await tick()
|
||
expect(result.terminal.output).toContain('plugin error result')
|
||
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-error',
|
||
'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 /quit while running and handles agent errors/disposal', async () => {
|
||
const result = await setup({ status: 'running' })
|
||
result.terminal.send('/quit')
|
||
result.terminal.send('\r')
|
||
await tick()
|
||
expect(result.agent.cancelled).toContainEqual({ kind: 'user' })
|
||
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('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||
unrelatedSession.append('todo/write', { todos: [{ content: 'hidden', status: 'pending' }] })
|
||
agentEvents(events.ctx, unrelatedAgent).emit('agent/status', 'running')
|
||
agentEvents(events.ctx, unrelatedAgent).emit('agent/error', 1, 1, new Error('hidden error'))
|
||
agentEvents(events.ctx, unrelatedAgent).emit('agent/disposed')
|
||
agentEvents(events.ctx, events.agent).emit('agent/error', 1, 1, new Error('live failure'))
|
||
events.session.append('step/end', { turn: 1, step: 1 })
|
||
events.session.append('turn/end', { turn: 1, reason: { kind: 'error', step: 1, message: 'live failure' } })
|
||
events.session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||
events.session.append('turn/end', { turn: 2, reason: { kind: 'error', step: 1, message: 'durable failure' } })
|
||
events.session.append('turn/start', { turn: 3, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||
events.session.append('turn/end', { turn: 3, reason: { kind: 'aborted' } })
|
||
events.session.append('turn/start', { turn: 4, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||
events.session.append('turn/end', { turn: 4, reason: { kind: 'max-tokens' } })
|
||
events.session.append('turn/start', { turn: 5, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||
events.session.append('turn/end', { turn: 5, reason: { kind: 'interrupted' } })
|
||
events.session.append('turn/start', { turn: 6, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||
events.session.append('turn/end', {
|
||
turn: 6,
|
||
reason: { kind: 'error', step: 1, failure: { message: 'structured provider failure', code: 'SERVER' } },
|
||
})
|
||
events.session.append('turn/start', { turn: 8, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||
events.session.append('turn/end', { turn: 8, reason: { kind: 'disposed' } })
|
||
events.session.append('turn/start', { turn: 9, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||
// Merge-extensible reason kind unknown to the TUI still names the stop.
|
||
events.session.append('turn/end', { turn: 9, reason: { kind: 'plugin-policy' } as never })
|
||
agentEvents(events.ctx, events.agent).emit('agent/disposed')
|
||
await tick()
|
||
expect(events.terminal.output).toContain('live failure')
|
||
expect(events.terminal.output).toContain('durable failure')
|
||
expect(events.terminal.output).toContain('Turn cancelled')
|
||
expect(events.terminal.output).toContain('structured provider failure')
|
||
expect(events.terminal.output).toContain('output-token limit')
|
||
expect(events.terminal.output).toContain('previous process ended')
|
||
expect(events.terminal.output).toContain('Turn stopped: the agent was disposed')
|
||
expect(events.terminal.output).toContain('Turn ended: plugin-policy')
|
||
expect(events.terminal.output).toContain('was disposed')
|
||
await dispose(events)
|
||
})
|
||
|
||
it('rejects input after the agent is disposed out from under the TUI', async () => {
|
||
const result = await setup()
|
||
|
||
// The agent leaves the registry (e.g. an agent-loop-only reload) while the
|
||
// TUI stays mounted. A later send must report disposal, not drive the
|
||
// detached zombie agent.
|
||
agentEvents(result.ctx, result.agent).emit('agent/disposed')
|
||
await tick()
|
||
expect(result.terminal.output).toContain('was disposed')
|
||
|
||
result.terminal.send('drive the zombie')
|
||
result.terminal.send('\r')
|
||
await tick()
|
||
expect(result.agent.sent).toHaveLength(0)
|
||
expect(result.agent.steered).toHaveLength(0)
|
||
expect(result.terminal.output).toContain('is disposed')
|
||
await dispose(result)
|
||
})
|
||
})
|
||
|
||
describe('skill slash command', () => {
|
||
const withSkills = async (ctx: Context): Promise<void> => {
|
||
ctx.provide('tools', { get() { return undefined } } as never)
|
||
await ctx.plugin(SkillService)
|
||
const skills = ctx.get('skills')
|
||
if (skills === undefined) throw new Error('skills service not mounted')
|
||
skills.register({ name: 'demo-skill', description: 'Demo skill for tests', source: 'runtime', provider: 'runtime', content: 'Demo instructions body.' })
|
||
skills.register({ name: 'project-skill', description: 'Project skill for tests', source: 'project-dsh', provider: 'runtime', content: 'Project instructions body.' })
|
||
skills.register({ name: 'hidden-skill', description: 'Model-hidden skill', source: 'runtime', provider: 'runtime', content: 'Hidden instructions body.', disableModelInvocation: true })
|
||
}
|
||
|
||
it('labels slash completions by scope and hides model-disabled skills', async () => {
|
||
const result = await setup({ configureContext: withSkills })
|
||
result.terminal.send('/skill')
|
||
await tick()
|
||
expect(result.terminal.output).toContain('demo-skill')
|
||
expect(result.terminal.output).toContain('(user)')
|
||
expect(result.terminal.output).toContain('project-skill')
|
||
expect(result.terminal.output).toContain('(project)')
|
||
expect(result.terminal.output).not.toContain('[instructions]')
|
||
expect(result.terminal.output).not.toContain('hidden-skill')
|
||
await dispose(result)
|
||
})
|
||
|
||
it('loads a skill as a user turn, appending typed instructions', async () => {
|
||
const result = await setup({ configureContext: withSkills })
|
||
result.terminal.send('/skill:demo-skill')
|
||
result.terminal.send('\r')
|
||
await tick()
|
||
expect(result.agent.sent).toEqual([[{ type: 'text', text: '<skill name="demo-skill">\nDemo instructions body.\n</skill>' }]])
|
||
|
||
result.agent.status = 'running'
|
||
result.terminal.send('/skill:demo-skill focus on tests')
|
||
result.terminal.send('\r')
|
||
await tick()
|
||
expect(result.agent.steered).toEqual([[{ type: 'text', text: '<skill name="demo-skill">\nDemo instructions body.\n</skill>\n\nfocus on tests' }]])
|
||
await dispose(result)
|
||
})
|
||
|
||
it('invokes a model-disabled skill by its exact name', async () => {
|
||
const result = await setup({ configureContext: withSkills })
|
||
result.terminal.send('/skill:hidden-skill')
|
||
result.terminal.send('\r')
|
||
await tick()
|
||
expect(result.agent.sent).toEqual([[{ type: 'text', text: '<skill name="hidden-skill">\nHidden instructions body.\n</skill>' }]])
|
||
await dispose(result)
|
||
})
|
||
|
||
it('reports an unknown skill and an empty skill name without sending', async () => {
|
||
const result = await setup({ configureContext: withSkills })
|
||
result.terminal.send('/skill:does-not-exist')
|
||
result.terminal.send('\r')
|
||
await tick()
|
||
result.terminal.send('/skill:')
|
||
result.terminal.send('\r')
|
||
await tick()
|
||
// A space right after the colon parses to an empty name, not a name of
|
||
// "focus"; the documented syntax puts the name immediately after the colon.
|
||
result.terminal.send('/skill: focus')
|
||
result.terminal.send('\r')
|
||
await tick()
|
||
expect(result.terminal.output).toContain('Unknown skill: does-not-exist')
|
||
expect(result.terminal.output).toContain('Usage: /skill:<name>')
|
||
expect(result.agent.sent).toEqual([])
|
||
await dispose(result)
|
||
})
|
||
|
||
it('warns when no skill service is mounted', async () => {
|
||
const result = await setup()
|
||
result.terminal.send('/skill:demo-skill')
|
||
result.terminal.send('\r')
|
||
await tick()
|
||
expect(result.terminal.output).toContain('Skills are not available')
|
||
expect(result.agent.sent).toEqual([])
|
||
await dispose(result)
|
||
})
|
||
|
||
it('surfaces skill lookup failures as an error notice', async () => {
|
||
const result = await setup({
|
||
configureContext: async (ctx) => {
|
||
ctx.provide('tools', { get() { return undefined } } as never)
|
||
ctx.provide('skills', {
|
||
list: () => Promise.reject(new Error('list boom')),
|
||
get: () => Promise.reject(new Error('get boom')),
|
||
} as never)
|
||
},
|
||
})
|
||
result.terminal.send('/skill:demo-skill')
|
||
result.terminal.send('\r')
|
||
await tick()
|
||
expect(result.terminal.output).toContain('failed to load')
|
||
expect(result.terminal.output).toContain('get boom')
|
||
await dispose(result)
|
||
})
|
||
|
||
it('drops skill list and lookup results that settle after disposal', async () => {
|
||
const pendingList: Array<(value: SkillSummary[]) => void> = []
|
||
const pendingGet: Array<{ resolve: (value: SkillDefinition | undefined) => void; reject: (error: unknown) => void }> = []
|
||
const result = await setup({
|
||
configureContext: async (ctx) => {
|
||
ctx.provide('tools', { get() { return undefined } } as never)
|
||
ctx.provide('skills', {
|
||
list: () => new Promise<SkillSummary[]>((resolve) => { pendingList.push(resolve) }),
|
||
get: () => new Promise<SkillDefinition | undefined>((resolve, reject) => { pendingGet.push({ resolve, reject }) }),
|
||
} as never)
|
||
},
|
||
})
|
||
result.terminal.send('/skill:demo-skill')
|
||
result.terminal.send('\r')
|
||
await tick()
|
||
result.terminal.send('/skill:other-skill')
|
||
result.terminal.send('\r')
|
||
await tick()
|
||
await dispose(result)
|
||
|
||
for (const resolve of pendingList) resolve([{ name: 'late', description: 'late', source: 'runtime', provider: 'runtime' }])
|
||
pendingGet[0]?.resolve({ name: 'demo-skill', description: 'late', source: 'runtime', provider: 'runtime', content: 'late body' })
|
||
pendingGet[1]?.reject(new Error('late failure'))
|
||
await tick()
|
||
expect(result.agent.sent).toEqual([])
|
||
expect(result.terminal.output).not.toContain('late failure')
|
||
expect(result.terminal.output).not.toContain('late body')
|
||
})
|
||
})
|
||
|
||
describe('renderSkillInvocation', () => {
|
||
const skill: SkillDefinition = {
|
||
name: 'demo-skill',
|
||
description: 'Demo skill',
|
||
source: 'runtime',
|
||
provider: 'runtime',
|
||
content: 'Body text.',
|
||
}
|
||
|
||
it('renders directory, url, opaque, and absent resource bases', () => {
|
||
expect(renderSkillInvocation({ ...skill, resourceBase: { kind: 'directory', path: '/skills/demo' } }, '')).toBe(
|
||
'<skill name="demo-skill">\nReferences in this skill are relative to /skills/demo.\n\nBody text.\n</skill>',
|
||
)
|
||
expect(renderSkillInvocation({ ...skill, resourceBase: { kind: 'url', url: 'https://x/y' } }, 'go')).toBe(
|
||
'<skill name="demo-skill">\nReferences in this skill are relative to https://x/y.\n\nBody text.\n</skill>\n\ngo',
|
||
)
|
||
expect(renderSkillInvocation({ ...skill, resourceBase: { kind: 'opaque', description: 'held in memory' } }, '')).toBe(
|
||
'<skill name="demo-skill">\nheld in memory\n\nBody text.\n</skill>',
|
||
)
|
||
expect(renderSkillInvocation(skill, '')).toBe('<skill name="demo-skill">\nBody text.\n</skill>')
|
||
})
|
||
|
||
it('throws on an unknown resource base kind', () => {
|
||
expect(() => renderSkillInvocation({ ...skill, resourceBase: { kind: 'future' } as never }, '')).toThrow('unreachable variant')
|
||
})
|
||
})
|
||
|
||
describe('tool cards and surface replay', () => {
|
||
const tools: Record<string, ToolDefinition> = {
|
||
bash: {
|
||
name: 'bash', description: '', parameters: {}, output: UNUSED_TOOL_OUTPUT, 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: {}, output: UNUSED_TOOL_OUTPUT, execute: async () => [],
|
||
presentCall: () => ({ card: 'terminal', title: 'sleep 10' }),
|
||
presentResult: () => ({ card: 'terminal', signal: 'SIGTERM' }),
|
||
},
|
||
edit: {
|
||
name: 'edit', description: '', parameters: {}, output: UNUSED_TOOL_OUTPUT, 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' }] }),
|
||
},
|
||
singleDiff: {
|
||
name: 'singleDiff', description: '', parameters: {}, output: UNUSED_TOOL_OUTPUT, execute: async () => [],
|
||
presentCall: () => ({
|
||
card: 'diff',
|
||
title: 'Edit src/only.ts',
|
||
diffs: [{ path: 'src/only.ts', oldText: 'old', newText: 'new' }],
|
||
}),
|
||
},
|
||
generic: {
|
||
name: 'generic', description: '', parameters: {}, output: UNUSED_TOOL_OUTPUT, execute: async () => [],
|
||
presentCall: () => ({ card: 'generic', title: 'Inspect value', rawInput: { alpha: 1 } }),
|
||
presentResult: () => ({
|
||
card: 'generic',
|
||
title: 'Inspected',
|
||
content: [{ type: 'text', text: 'result **text**\n\n```console\nstarted background task bash-5\n```' }],
|
||
}),
|
||
},
|
||
throwing: {
|
||
name: 'throwing', description: '', parameters: {}, output: UNUSED_TOOL_OUTPUT, execute: async () => [],
|
||
presentCall: () => { throw new Error('call presenter boom') },
|
||
presentResult: () => { throw new Error('result presenter boom') },
|
||
},
|
||
rawTerminal: {
|
||
name: 'rawTerminal', description: '', parameters: {}, output: UNUSED_TOOL_OUTPUT, execute: async () => [],
|
||
presentCall: () => ({ card: 'terminal', title: 'raw command' }),
|
||
},
|
||
// An empty-string description is treated as no description: the header omits
|
||
// the ` / <desc>` segment, exactly as an absent description does.
|
||
emptyDescTerminal: {
|
||
name: 'emptyDescTerminal', description: '', parameters: {}, output: UNUSED_TOOL_OUTPUT, execute: async () => [],
|
||
presentCall: () => ({ card: 'terminal', title: 'blank desc command', description: '' }),
|
||
},
|
||
// A generic card whose title only repeats the tool name and carries no
|
||
// content or rawInput renders a header with an empty body block.
|
||
emptyBody: {
|
||
name: 'emptyBody', description: '', parameters: {}, output: UNUSED_TOOL_OUTPUT, execute: async () => [],
|
||
presentCall: () => ({ card: 'generic', title: 'emptyBody' }),
|
||
},
|
||
multilineTerminal: {
|
||
name: 'multilineTerminal', description: '', parameters: {}, output: UNUSED_TOOL_OUTPUT, execute: async () => [],
|
||
// A multi-line bash command as the title/description: the card title and the
|
||
// meta rows are single logical lines and must render inline, not break rows.
|
||
presentCall: () => ({ card: 'terminal', title: 'S=/tmp\necho "$S"', description: 'set\nand echo' }),
|
||
},
|
||
undefinedViews: {
|
||
name: 'undefinedViews', description: '', parameters: {}, output: UNUSED_TOOL_OUTPUT, execute: async () => [],
|
||
presentCall: () => undefined,
|
||
presentResult: () => undefined,
|
||
},
|
||
empty: {
|
||
name: 'empty', description: '', parameters: {}, output: UNUSED_TOOL_OUTPUT, execute: async () => [],
|
||
presentCall: () => ({ card: 'generic', title: 'Empty card' }),
|
||
},
|
||
terminalResult: {
|
||
name: 'terminalResult', description: '', parameters: {}, output: UNUSED_TOOL_OUTPUT, execute: async () => [],
|
||
presentCall: () => ({ card: 'generic', title: 'Becomes terminal' }),
|
||
presentResult: () => ({ card: 'terminal', output: 'converted terminal' }),
|
||
},
|
||
symbolic: {
|
||
name: 'symbolic', description: '', parameters: {}, output: UNUSED_TOOL_OUTPUT, execute: async () => [],
|
||
presentCall: () => ({ card: 'generic', title: 'Symbol input', rawInput: Symbol('input') }),
|
||
},
|
||
knownXml: {
|
||
name: 'knownXml', description: '', parameters: {}, output: UNUSED_TOOL_OUTPUT, execute: async () => [],
|
||
presentCall: () => ({ card: 'generic', title: 'Known XML' }),
|
||
},
|
||
}
|
||
|
||
it('uses terminal, diff, generic, fallback, and collapsed tool presentations', async () => {
|
||
const result = await setup({ tools, config: { maxToolOutputLines: 4 } })
|
||
const calls = [
|
||
['c1', 'bash', '{"command":"printf hello"}'],
|
||
['c2', 'signal', '{}'],
|
||
['c3', 'edit', '{}'],
|
||
['c4', 'generic', '{}'],
|
||
['c5', 'throwing', '{}'],
|
||
['c6', 'unknown', 'not-json'],
|
||
['c7', 'rawTerminal', '{"value":"raw"}'],
|
||
['c14', 'emptyDescTerminal', '{}'],
|
||
['c15', 'emptyBody', '{}'],
|
||
['c9', 'multilineTerminal', '{}'],
|
||
['c8', 'undefinedViews', '{"value":8}'],
|
||
['c10', 'empty', '{}'],
|
||
['c11', 'terminalResult', '{}'],
|
||
['c12', 'symbolic', '{}'],
|
||
['c13', 'knownXml', '{}'],
|
||
] 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: 1, 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: 1, callId: 'c1' as never, content: [{ type: 'text', text: 'raw bash' }], isError: false,
|
||
}, { surfaceOp: 'append' })
|
||
result.session.append('tool/result', {
|
||
turn: 1, step: 1, callId: 'c2' as never, content: [{ type: 'text', text: 'stopped' }], isError: true,
|
||
}, { surfaceOp: 'append' })
|
||
result.session.append('tool/result', {
|
||
turn: 1, step: 1, callId: 'c3' as never, content: [{ type: 'text', text: 'done' }], isError: false,
|
||
}, { surfaceOp: 'append' })
|
||
result.session.append('tool/result', {
|
||
turn: 1, step: 1, callId: 'c4' as never, content: [{ type: 'text', text: 'raw generic' }], isError: false,
|
||
}, { surfaceOp: 'append' })
|
||
result.session.append('tool/result', {
|
||
turn: 1, step: 1, 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: 1, 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: 1, 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: 1, 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: 1, callId: 'c13' as never,
|
||
content: [{ type: 'text', text: '<known><value>literal</value></known>' }],
|
||
isError: false,
|
||
}, { surfaceOp: 'append' })
|
||
result.session.append('tool/result', {
|
||
turn: 1,
|
||
step: 1,
|
||
callId: 'orphan' as never,
|
||
content: [{ type: 'text', text: '<result><path>/tmp/a.txt</path><content><line number="1">hello</line><line number="2">world</line></content></result>' }],
|
||
isError: true,
|
||
error: { name: 'InterruptedError', code: 'interrupted' },
|
||
}, { surfaceOp: 'append' })
|
||
await tick()
|
||
|
||
const output = result.terminal.output
|
||
expect(output).toContain('Run command')
|
||
expect(output).toContain('printf hello')
|
||
// A multi-line terminal title and description render inline (newline escaped
|
||
// to `\x0a`), so they cannot break onto extra rows and collide with the body.
|
||
expect(output).toContain('S=/tmp\\x0aecho "$S"')
|
||
expect(output).toContain('set\\x0aand echo')
|
||
expect(output).toContain('lines (Ctrl+O to expand)')
|
||
expect(output).toContain('SIGTERM')
|
||
// The header is a fixed `Tool / <name>` frame; the tool name shows there.
|
||
expect(output).toContain('Tool / bash')
|
||
expect(output).toContain('Tool / edit')
|
||
// An empty-string terminal description contributes no ` / <desc>` segment;
|
||
// the header ends at the tool name, and the command shows as the body $-line.
|
||
expect(output).toContain('Tool / emptyDescTerminal')
|
||
expect(output).not.toContain('Tool / emptyDescTerminal /')
|
||
expect(output).toContain('$ blank desc command')
|
||
// A card whose title only repeats the name renders header-only (empty body).
|
||
expect(output).toContain('Tool / emptyBody')
|
||
// A diff card drops its title (the paths + change footer carry the meaning).
|
||
// The first file's path is head-visible; the second file and the change
|
||
// footer sit past this card's 4-line budget and appear only when expanded.
|
||
expect(output).not.toContain('Edit files')
|
||
expect(output).toContain('a.txt')
|
||
// A generic card's presenter title moves from the header into the body.
|
||
expect(output).toContain('Inspected')
|
||
expect(output).toContain('result text')
|
||
expect(output).toContain('started background task bash-5')
|
||
expect(output).not.toContain('```console')
|
||
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('<known><value>literal</value></known>')
|
||
expect(output).toContain('path: /tmp/a.txt')
|
||
expect(output).toContain('line (number="1"): hello')
|
||
expect(output).not.toContain('<result>')
|
||
|
||
result.terminal.send('/redraw')
|
||
result.terminal.send('\r')
|
||
await tick()
|
||
const collapsed = result.terminal.output.slice(result.terminal.output.lastIndexOf('\x1b[2J'))
|
||
expect(collapsed).toContain('Run command')
|
||
expect(collapsed).toContain('[exit 0]')
|
||
expect(collapsed).not.toContain('▌ hello')
|
||
expect(collapsed).not.toContain('▌ world')
|
||
result.terminal.send('\x0f')
|
||
await tick()
|
||
expect(result.terminal.output).toContain('world')
|
||
expect(result.terminal.output).toContain('Tool cards expanded.')
|
||
expect(result.terminal.output).not.toContain('tools:expanded')
|
||
expect(result.terminal.output).toContain('+ created')
|
||
expect(result.terminal.output).toContain('console')
|
||
// The multi-file diff's second-file change and its footer surface once
|
||
// expanded (`+ after` is b.txt's new text; the footer counts both files).
|
||
expect(result.terminal.output).toContain('+ after')
|
||
expect(result.terminal.output).toContain('· 2 files')
|
||
await dispose(result)
|
||
})
|
||
|
||
it('names a single-file diff in the body once, under a fixed Tool header', async () => {
|
||
const result = await setup({ tools })
|
||
appendUser(result.session, 'edit one file')
|
||
appendAssistant(result.session, [
|
||
{ type: 'text', text: 'Editing' },
|
||
{ type: 'tool-call', id: 'single' as never, name: 'singleDiff', arguments: '{}' },
|
||
])
|
||
result.session.append('tool/call', {
|
||
turn: 1, step: 1, callId: 'single' as never, name: 'singleDiff', arguments: '{}',
|
||
})
|
||
await tick()
|
||
const output = result.terminal.output
|
||
// The header is a fixed `Tool / <name>` frame; the diff title is dropped and
|
||
// the file path shows once in the body, above the change footer.
|
||
expect(output).toContain('Tool / singleDiff')
|
||
expect(output).not.toContain('Edit src/only.ts')
|
||
expect(output.split('src/only.ts').length - 1).toBe(1)
|
||
expect(output).toContain('- old')
|
||
expect(output).toContain('+ new')
|
||
expect(output).toContain('· 1 file')
|
||
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: 1,
|
||
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: 1, callId: 'old-call' as never, name: 'bash', arguments: '{}',
|
||
})
|
||
const toolResult = result.session.append('tool/result', {
|
||
turn: 1, step: 1, 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('user/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', detail: 'This choice controls the next turn.',
|
||
options: [{ label: 'Safe', description: 'Use checks' }, { label: 'Fast' }],
|
||
}],
|
||
})
|
||
await tick()
|
||
expect(result.terminal.output).toContain('Choose a mode')
|
||
expect(result.terminal.output).toContain('This choice controls the next turn.')
|
||
expect(result.terminal.output).toContain('Question 1/1 (1 unanswered) · 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()
|
||
const singleOptionRender = result.terminal.output.slice(result.terminal.output.lastIndexOf('Choose or type'))
|
||
expect(singleOptionRender).not.toContain('↑/↓ navigate')
|
||
result.terminal.send('\t')
|
||
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: { theme: { 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 vi.waitFor(() => {
|
||
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()
|
||
expect(result.terminal.output).toContain('Question 1/2 (2 unanswered)')
|
||
result.terminal.send('\r')
|
||
await tick()
|
||
expect(result.terminal.output).toContain('Second?')
|
||
expect(result.terminal.output).toContain('Question 2/2 (1 unanswered)')
|
||
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()
|
||
})
|
||
|
||
it('rejects malformed questions when a dialog cannot be constructed', async () => {
|
||
const result = await setup()
|
||
const broken = {
|
||
id: 'broken',
|
||
question: 'Broken question',
|
||
get options(): never {
|
||
throw new Error('question setup failed')
|
||
},
|
||
}
|
||
const answer = result.ctx.userInteraction.ask({ questions: [broken] })
|
||
await expect(answer).rejects.toThrow('ask_user_question TUI failed: question setup failed')
|
||
await tick()
|
||
expect(result.terminal.output).toContain('TUI overlay failed: question setup failed')
|
||
await dispose(result)
|
||
})
|
||
})
|
||
|
||
describe('TUI extension service', () => {
|
||
it('renders effect-owned plugin overlays in the shared FIFO and restores editor input', async () => {
|
||
const result = await setup()
|
||
const sessions: TuiOverlaySession[] = []
|
||
const hosts: TuiOverlayHost[] = []
|
||
const plugin = result.ctx.inject(['tui'], (pluginCtx) => {
|
||
expect(pluginCtx.tui.agent).toBe(result.agent)
|
||
for (const label of ['first', 'second']) {
|
||
sessions.push(pluginCtx.tui.openOverlay({
|
||
create(host) {
|
||
hosts.push(host)
|
||
return {
|
||
focused: false,
|
||
render: width => [
|
||
host.theme.accent(`${label} plugin overlay`),
|
||
[
|
||
host.theme.text('text'),
|
||
host.theme.muted('muted'),
|
||
host.theme.dim('dim'),
|
||
host.theme.success('success'),
|
||
host.theme.warning('warning'),
|
||
host.theme.error('error'),
|
||
host.theme.bold('bold'),
|
||
].join(' '),
|
||
`${String(host.viewport.columns)}x${String(host.viewport.rows)} · ${String(width)}`,
|
||
],
|
||
handleInput(data) {
|
||
host.invalidate()
|
||
if (data === label[0]) host.close()
|
||
},
|
||
invalidate() {},
|
||
}
|
||
},
|
||
options: { width: 50, maxHeight: 8, anchor: 'center', margin: 1 },
|
||
}))
|
||
}
|
||
})
|
||
await plugin
|
||
await vi.waitFor(() => {
|
||
expect(result.terminal.output).toContain('first plugin overlay')
|
||
})
|
||
expect(sessions.map(session => session.state)).toEqual(['active', 'queued'])
|
||
expect(hosts).toHaveLength(1)
|
||
|
||
const question = result.ctx.userInteraction.ask({
|
||
questions: [{ id: 'after-plugin', question: 'Question after plugins?', options: [{ label: 'Yes' }] }],
|
||
})
|
||
result.terminal.send('f')
|
||
await expect(sessions[0]!.closed).resolves.toEqual({ reason: 'closed' })
|
||
await vi.waitFor(() => {
|
||
expect(result.terminal.output).toContain('second plugin overlay')
|
||
})
|
||
expect(hosts).toHaveLength(2)
|
||
expect(sessions[1]?.state).toBe('active')
|
||
|
||
result.terminal.send('s')
|
||
await expect(sessions[1]!.closed).resolves.toEqual({ reason: 'closed' })
|
||
await vi.waitFor(() => {
|
||
expect(result.terminal.output).toContain('Question after plugins?')
|
||
})
|
||
result.terminal.send('\r')
|
||
await expect(question).resolves.toEqual({
|
||
answers: [{ id: 'after-plugin', selected: ['Yes'] }],
|
||
})
|
||
|
||
result.terminal.send('editor works again')
|
||
result.terminal.send('\r')
|
||
expect(result.agent.sent.at(-1)).toEqual([{ type: 'text', text: 'editor works again' }])
|
||
await plugin.dispose()
|
||
await dispose(result)
|
||
})
|
||
|
||
it('unloads and reloads dependent plugins with the mounted TUI', async () => {
|
||
const result = await setup()
|
||
const sessions: TuiOverlaySession[] = []
|
||
const signals: AbortSignal[] = []
|
||
let starts = 0
|
||
const plugin = result.ctx.inject(['tui'], (pluginCtx) => {
|
||
starts += 1
|
||
sessions.push(pluginCtx.tui.openOverlay({
|
||
create(host) {
|
||
signals.push(host.signal)
|
||
return {
|
||
render: () => [`plugin mount ${String(starts)}`],
|
||
invalidate() {},
|
||
}
|
||
},
|
||
}))
|
||
})
|
||
await plugin
|
||
await vi.waitFor(() => {
|
||
expect(result.terminal.output).toContain('plugin mount 1')
|
||
})
|
||
|
||
await result.controller.dispose()
|
||
await expect(sessions[0]!.closed).resolves.toEqual({ reason: 'owner-disposed' })
|
||
expect(signals[0]?.aborted).toBe(true)
|
||
expect(result.ctx.get('tui')).toBeUndefined()
|
||
|
||
const secondTerminal = new FakeTerminal()
|
||
const secondController = createTuiChat(result.ctx, {
|
||
sessionId: result.agent.id,
|
||
theme: { color: false },
|
||
welcome: 'Mounted again.',
|
||
}, {
|
||
terminal: secondTerminal,
|
||
exit: vi.fn(),
|
||
})
|
||
await vi.waitFor(() => {
|
||
expect(starts).toBe(2)
|
||
expect(secondTerminal.output).toContain('plugin mount 2')
|
||
})
|
||
await sessions[1]?.close()
|
||
await secondController.dispose()
|
||
await plugin.dispose()
|
||
await result.ctx.fiber.dispose()
|
||
})
|
||
})
|
||
|
||
describe('terminal mounting', () => {
|
||
it('starts immediately when the configured agent already exists', async () => {
|
||
const ctx = new Context()
|
||
provideTokenMeter(ctx)
|
||
await ctx.plugin(SessionStore)
|
||
await ctx.plugin(AgentRegistry)
|
||
await ctx.plugin(CommandService)
|
||
await ctx.plugin(UserInteractionService)
|
||
await ctx.plugin(TuiPromptService)
|
||
ctx.provide('tools', { get: () => undefined } as never)
|
||
const session = ctx.sessions.create(SessionId('main'))
|
||
ctx.agents.register({
|
||
id: session.id, options: {}, session, status: 'idle', acceptsNextStep: false, ctx,
|
||
followup: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), send: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(),
|
||
})
|
||
const terminal = new FakeTerminal()
|
||
mountTui(ctx, { theme: { color: false } }, { terminal, exit: vi.fn() })
|
||
await tick()
|
||
expect(terminal.started).toBe(1)
|
||
await ctx.fiber.dispose()
|
||
})
|
||
|
||
it('degrades /reload to a warning when mounted as a real plugin without a Loader', async () => {
|
||
// Production shape: the TUI runs inside a plugin fiber, where a bare
|
||
// `ctx.loader` proxy read would THROW `cannot get property without
|
||
// inject` — only the non-throwing `ctx.get` lookup degrades gracefully.
|
||
const ctx = new Context()
|
||
provideTokenMeter(ctx)
|
||
provideLlmCatalog(ctx)
|
||
await ctx.plugin(SessionStore)
|
||
await ctx.plugin(AgentRegistry)
|
||
await ctx.plugin(CommandService)
|
||
await ctx.plugin(UserInteractionService)
|
||
await ctx.plugin(TuiPromptService)
|
||
ctx.provide('tools', { get: () => undefined } as never)
|
||
const session = ctx.sessions.create(SessionId('main'))
|
||
ctx.agents.register({
|
||
id: session.id, options: {}, session, status: 'idle', acceptsNextStep: false, ctx,
|
||
followup: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), send: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(),
|
||
})
|
||
const terminal = new FakeTerminal()
|
||
// Mirror dsh-tui's own inject (minus loader, the absence under test).
|
||
await ctx.plugin({
|
||
inject: ['agents', 'commands', 'userInteraction', 'tools', 'llm', 'tokenMeter', 'tuiPrompt'],
|
||
apply: (pluginCtx: Context) => {
|
||
mountTui(pluginCtx, { theme: { color: false } }, { terminal, exit: vi.fn() })
|
||
},
|
||
})
|
||
await tick()
|
||
expect(terminal.started).toBe(1)
|
||
terminal.send('/reload')
|
||
terminal.send('\r')
|
||
await tick()
|
||
expect(terminal.output).toContain('/reload needs the cordis Loader')
|
||
await ctx.fiber.dispose()
|
||
})
|
||
|
||
it('waits for its configured agent before starting the TUI', async () => {
|
||
const ctx = new Context()
|
||
provideTokenMeter(ctx)
|
||
await ctx.plugin(SessionStore)
|
||
await ctx.plugin(AgentRegistry)
|
||
await ctx.plugin(CommandService)
|
||
await ctx.plugin(UserInteractionService)
|
||
await ctx.plugin(TuiPromptService)
|
||
ctx.provide('tools', { get: () => undefined } as never)
|
||
const terminal = new FakeTerminal()
|
||
mountTui(ctx, { sessionId: 'late-session', theme: { 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', acceptsNextStep: false, ctx,
|
||
followup: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), send: () => AgentMessageId('stub'), 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', acceptsNextStep: false, ctx,
|
||
followup: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), send: () => AgentMessageId('stub'), 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()
|
||
provideTokenMeter(ctx)
|
||
await ctx.plugin(SessionStore)
|
||
await ctx.plugin(AgentRegistry)
|
||
await ctx.plugin(CommandService)
|
||
await ctx.plugin(UserInteractionService)
|
||
await ctx.plugin(TuiPromptService)
|
||
ctx.provide('tools', { get: () => undefined } as never)
|
||
const terminal = new FakeTerminal()
|
||
const exit = vi.fn()
|
||
mountTui(ctx, { sessionId: 'main-session', theme: { 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', acceptsNextStep: false, ctx,
|
||
followup: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), send: () => AgentMessageId('stub'), 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()
|
||
provideTokenMeter(ctx)
|
||
await ctx.plugin(SessionStore)
|
||
await ctx.plugin(AgentRegistry)
|
||
await ctx.plugin(CommandService)
|
||
await ctx.plugin(UserInteractionService)
|
||
await ctx.plugin(TuiPromptService)
|
||
ctx.provide('tools', { get: () => undefined } as never)
|
||
const terminal = new FakeTerminal()
|
||
const exit = vi.fn()
|
||
|
||
mountTui(ctx, { sessionId: 'main-session', theme: { 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()
|
||
provideTokenMeter(ctx)
|
||
await ctx.plugin(SessionStore)
|
||
await ctx.plugin(AgentRegistry)
|
||
await ctx.plugin(CommandService)
|
||
await ctx.plugin(UserInteractionService)
|
||
await ctx.plugin(TuiPromptService)
|
||
ctx.provide('tools', { get: () => undefined } as never)
|
||
const session = ctx.sessions.create(SessionId('failed-start-session'))
|
||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||
session.append('step/start', { turn: 1, step: 1 })
|
||
ctx.agents.register({
|
||
id: session.id, options: {}, session, status: 'running', acceptsNextStep: true, ctx,
|
||
followup: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), send: () => AgentMessageId('stub'), cancel() {}, whenIdle: () => Promise.resolve(),
|
||
})
|
||
const terminal = new FakeTerminal()
|
||
terminal.start = () => { throw new Error('terminal startup failed') }
|
||
|
||
expect(() => createTuiChat(ctx, { sessionId: 'failed-start-session', theme: { 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])
|
||
expect(ctx.get('tui')).toBeUndefined()
|
||
await expect(ctx.userInteraction.ask({ questions: [{ id: 'late', question: 'Late?' }] }))
|
||
.rejects.toMatchObject({ code: 'NO_PROVIDER' })
|
||
session.append('assistant/chunk', {
|
||
turn: 1,
|
||
step: 1,
|
||
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()
|
||
provideTokenMeter(ctx)
|
||
await ctx.plugin(AgentRegistry)
|
||
await ctx.plugin(CommandService)
|
||
await ctx.plugin(UserInteractionService)
|
||
await ctx.plugin(TuiPromptService)
|
||
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()
|
||
})
|
||
|
||
it('detects a light terminal color scheme and switches from dark- to light-optimised ANSI codes', async () => {
|
||
const result = await setup({ config: { theme: { color: true } } })
|
||
// Initial render uses dark-optimised palette: SGR 2 (dim) for dim text.
|
||
expect(result.terminal.output).toContain('\x1b[90mdeepseek-v4-flash')
|
||
|
||
// A report matching the current scheme is a no-op: no palette rebuild or
|
||
// re-render (ESC [?997;1n = dark, the startup default).
|
||
const beforeSameScheme = result.terminal.output.length
|
||
result.terminal.send('\x1b[?997;1n')
|
||
await tick()
|
||
expect(result.terminal.output.length).toBe(beforeSameScheme)
|
||
|
||
// Simulate the terminal responding with a light color scheme report
|
||
// (ESC [?997;2n = light, ESC [?997;1n = dark).
|
||
result.terminal.send('\x1b[?997;2n')
|
||
await tick()
|
||
await tick()
|
||
|
||
// After switching to light-optimised palette: palette.dim uses ANSI 90
|
||
// (gray) instead of SGR 2. The header now uses \x1b[90m for the detail
|
||
// line. The cumulative output still contains the initial SGR 2 render,
|
||
// so we assert that a LATER write (appended after the scheme switch)
|
||
// uses ANSI 90 for the same header text.
|
||
expect(result.terminal.output).toContain('\x1b[90mdeepseek-v4-flash')
|
||
|
||
// Switch back to dark scheme.
|
||
result.terminal.send('\x1b[?997;1n')
|
||
await tick()
|
||
await tick()
|
||
// After switching back, a new write uses SGR 2 for the header detail.
|
||
expect(result.terminal.output).toContain('\x1b[90mdeepseek-v4-flash')
|
||
await dispose(result)
|
||
})
|
||
|
||
it('keeps the dark palette when the terminal rejects the color-scheme query', async () => {
|
||
class QueryFailTerminal extends FakeTerminal {
|
||
override write(data: string): void {
|
||
// The device-status query is the only write that fails; the promise
|
||
// rejects and the swallowed `.catch` leaves the dark palette in place.
|
||
if (data === '\x1b[?996n') throw new Error('query write failed')
|
||
super.write(data)
|
||
}
|
||
}
|
||
const terminal = new QueryFailTerminal()
|
||
// Anchor cwd under $HOME so the prompt renders the `~/` abbreviation
|
||
// deterministically; process.cwd() is not guaranteed under $HOME in CI.
|
||
const result = await createTuiTestHarness(terminal, vi.fn(), {
|
||
config: { theme: { color: true } },
|
||
cwd: join(homedir(), 'projects', 'dsh-tui'),
|
||
})
|
||
await tick()
|
||
expect(terminal.output).toContain('\x1b[94m~/')
|
||
expect(terminal.output).toContain('\x1b[90m (tui-staging)')
|
||
await disposeTuiTestHarness(result)
|
||
})
|
||
it('runs /reload against every file-backed loader subtree, reports completion, and rejects re-entry while in flight', async () => {
|
||
const refreshed: string[] = []
|
||
let releaseRefresh!: () => void
|
||
const gate = new Promise<void>((resolve) => { releaseRefresh = resolve })
|
||
const result = await setup({
|
||
configureContext: async (ctx) => {
|
||
ctx.provide('tools', { get: () => undefined } as never)
|
||
// A structural Loader: two file-backed subtrees and one plain entry.
|
||
// The first subtree blocks on a gate so re-entry can be probed
|
||
// deterministically mid-flight.
|
||
ctx.provide('loader', {
|
||
entries: () => [
|
||
{ subtree: { refresh: async () => { refreshed.push('root'); await gate } } },
|
||
{},
|
||
{ subtree: { refresh: async () => { refreshed.push('nested') } } },
|
||
],
|
||
} as never)
|
||
},
|
||
})
|
||
result.terminal.send('/reload')
|
||
result.terminal.send('\r')
|
||
await tick()
|
||
expect(result.terminal.output).toContain('Reloading 2 config tree(s)')
|
||
// Second /reload while the first is gated: refused, no extra refreshes.
|
||
result.terminal.send('/reload')
|
||
result.terminal.send('\r')
|
||
await tick()
|
||
expect(result.terminal.output).toContain('A config reload is already running.')
|
||
expect(refreshed.sort()).toEqual(['nested', 'root'])
|
||
releaseRefresh()
|
||
await tick()
|
||
expect(result.terminal.output).toContain('Config reload complete.')
|
||
// The guard released: a third /reload runs again.
|
||
result.terminal.send('/reload')
|
||
result.terminal.send('\r')
|
||
await tick()
|
||
expect(refreshed).toHaveLength(4)
|
||
await dispose(result)
|
||
})
|
||
|
||
it('reports a /reload failure if a refresh ever rejects', async () => {
|
||
const result = await setup({
|
||
configureContext: async (ctx) => {
|
||
ctx.provide('tools', { get: () => undefined } as never)
|
||
ctx.provide('loader', {
|
||
entries: () => [{ subtree: { refresh: () => Promise.reject(new Error('disk gone')) } }],
|
||
} as never)
|
||
},
|
||
})
|
||
result.terminal.send('/reload')
|
||
result.terminal.send('\r')
|
||
await tick()
|
||
expect(result.terminal.output).toContain('Config reload failed: disk gone')
|
||
// The failure arm also releases the re-entrancy guard.
|
||
result.terminal.send('/reload')
|
||
result.terminal.send('\r')
|
||
await tick()
|
||
expect(result.terminal.output).not.toContain('A config reload is already running.')
|
||
await dispose(result)
|
||
})
|
||
|
||
it('refuses /reload while the agent is running and allows it back at idle', async () => {
|
||
const refreshed: string[] = []
|
||
const result = await setup({
|
||
status: 'running',
|
||
configureContext: async (ctx) => {
|
||
ctx.provide('tools', { get: () => undefined } as never)
|
||
ctx.provide('loader', {
|
||
entries: () => [{ subtree: { refresh: async () => { refreshed.push('tree') } } }],
|
||
} as never)
|
||
},
|
||
})
|
||
result.terminal.send('/reload')
|
||
result.terminal.send('\r')
|
||
await tick()
|
||
expect(result.terminal.output).toContain('/reload requires an idle agent (status: running).')
|
||
expect(refreshed).toHaveLength(0)
|
||
// Back at idle the same command runs.
|
||
result.agent.status = 'idle'
|
||
result.terminal.send('/reload')
|
||
result.terminal.send('\r')
|
||
await tick()
|
||
expect(refreshed).toHaveLength(1)
|
||
expect(result.terminal.output).toContain('Config reload complete.')
|
||
await dispose(result)
|
||
})
|
||
|
||
})
|
||
|
||
describe('banner sweep reveal', () => {
|
||
it('renders the product name through the brand-gradient path when truecolor is enabled', async () => {
|
||
// The product name carries a per-letter 24-bit gradient from the brand
|
||
// indigo to light blue; the per-letter layout is pinned by the
|
||
// `banner-gradient` terminal snapshot.
|
||
const result = await setup({ config: { theme: { color: true, truecolor: true } } })
|
||
expect(result.terminal.output).toContain('\x1b[38;2;77;107;254m')
|
||
expect(result.terminal.output).toContain('\x1b[38;2;36;152;255m')
|
||
expect(result.terminal.output).toContain('HARNESS')
|
||
await dispose(result)
|
||
})
|
||
|
||
it('sweeps the whole borderless banner in when no welcome is configured, ending complete', async () => {
|
||
const intervals = vi.spyOn(globalThis, 'setInterval')
|
||
const cleared = vi.spyOn(globalThis, 'clearInterval')
|
||
const result = await setup({ omitWelcome: true })
|
||
const revealHandle = intervals.mock.results.at(-1)?.value as ReturnType<typeof setInterval>
|
||
// Run the sweep to natural completion — it clears its own timer at the end.
|
||
const done = (): boolean => cleared.mock.calls.some(call => call[0] === revealHandle)
|
||
const deadline = Date.now() + 5000
|
||
while (!done() && Date.now() < deadline) await tick()
|
||
intervals.mockRestore()
|
||
cleared.mockRestore()
|
||
// The finished banner carries the title and the model • session detail.
|
||
expect(result.terminal.output).toContain('DEEPSEEK')
|
||
expect(result.terminal.output).toContain('HARNESS')
|
||
expect(result.terminal.output).toContain('main-session')
|
||
// Borderless: no box-drawing frame around the banner.
|
||
expect(result.terminal.output).not.toContain('╭')
|
||
expect(result.terminal.output).not.toContain('╮')
|
||
// A mid-sweep frame rendered a clipped title: `DEEPSEEK` with no `HARNESS`
|
||
// on the same line.
|
||
const clipped = result.terminal.output
|
||
.split('\n')
|
||
.some(line => line.includes('DEEPSEEK') && !line.includes('HARNESS'))
|
||
expect(clipped).toBe(true)
|
||
await dispose(result)
|
||
})
|
||
|
||
it('renders a configured welcome verbatim in a complete banner with no sweep', async () => {
|
||
const result = await setup()
|
||
await tick()
|
||
expect(result.terminal.output).toContain('Coding agent ready.')
|
||
expect(result.terminal.output).toContain('DEEPSEEK')
|
||
expect(result.terminal.output).not.toContain('╭')
|
||
// No reveal frames: the banner is drawn whole from the first render, so no
|
||
// clipped-title frame ever appears.
|
||
const clipped = result.terminal.output
|
||
.split('\n')
|
||
.some(line => line.includes('DEEPSEEK') && !line.includes('HARNESS'))
|
||
expect(clipped).toBe(false)
|
||
await dispose(result)
|
||
})
|
||
|
||
it('omits the subtitle line entirely when no welcome is configured', async () => {
|
||
const result = await setup({ omitWelcome: true })
|
||
const deadline = Date.now() + 5000
|
||
while (!result.terminal.output.includes('main-session') && Date.now() < deadline) await tick()
|
||
// Banner is title + detail only — no subtitle between them.
|
||
expect(result.terminal.output).toContain('deepseek-v4-flash')
|
||
expect(result.terminal.output).not.toContain('ready.')
|
||
await dispose(result)
|
||
})
|
||
|
||
it('stops a mid-sweep animation on dispose', async () => {
|
||
// The output-stability probe alone is insensitive to a leaked interval
|
||
// (pi-tui's stopped guard silences post-stop renders), so capture the
|
||
// reveal's own interval handle and assert dispose clears exactly it.
|
||
const intervals = vi.spyOn(globalThis, 'setInterval')
|
||
const result = await setup({ omitWelcome: true })
|
||
const revealHandle = intervals.mock.results.at(-1)?.value as ReturnType<typeof setInterval>
|
||
expect(revealHandle).toBeDefined()
|
||
const cleared = vi.spyOn(globalThis, 'clearInterval')
|
||
await dispose(result)
|
||
expect(cleared.mock.calls.some(call => call[0] === revealHandle)).toBe(true)
|
||
intervals.mockRestore()
|
||
cleared.mockRestore()
|
||
const settled = result.terminal.output.length
|
||
await tick()
|
||
await tick()
|
||
expect(result.terminal.output.length).toBe(settled)
|
||
})
|
||
})
|