Merge remote-tracking branch 'origin/master' into worktree/web-multimodal-image-input

This commit is contained in:
creatixchu
2026-07-24 20:01:36 +08:00
107 changed files with 4589 additions and 502 deletions

View File

@@ -14,6 +14,7 @@ import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import type { ToolDefinition } from '@deepseek-ai/dsh-tools'
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
import { createTuiChat, type Config, type TuiRuntime } from '../src/index.ts'
import { TestSessionQueryService } from './session-query.ts'
interface FakeAgent extends Agent {
status: AgentStatus
@@ -48,7 +49,13 @@ export interface TuiHarnessOptions {
resolveModelContext?: (provider: string, model: string) => Promise<LlmModelContext | undefined>
}
/** Provide a fake `sessionPersistence` service so resume surfaces can list sessions. */
sessionPersistence?: { list(): Promise<SessionHeader[]> }
sessionPersistence?: {
list(): Promise<SessionHeader[]>
load?(id: ReturnType<typeof SessionId>): Promise<{ meta: SessionHeader; events: Session['events'] }>
}
handoffResume?: TuiRuntime['handoffResume']
/** Set false to exercise the optional session-query degradation path. */
mountSessionQuery?: boolean
}
export interface TuiHarness<TerminalType extends Terminal, Exit extends (code: number) => void> {
@@ -118,7 +125,22 @@ export async function createTuiTestHarness<TerminalType extends Terminal, Exit e
}
if (ctx.get('systemPrompt') === undefined) await ctx.plugin(SystemPrompt)
if (options.sessionPersistence !== undefined) {
ctx.provide('sessionPersistence', options.sessionPersistence as never)
const persistence = options.sessionPersistence
ctx.provide('sessionPersistence', {
...persistence,
locate: () => undefined,
create: () => Promise.resolve(),
append: () => Promise.resolve(),
load: persistence.load === undefined
? (id: ReturnType<typeof SessionId>) => Promise.reject(new Error(`session "${id}" not found`))
: (id: ReturnType<typeof SessionId>) => persistence.load!(id),
inspect: persistence.load === undefined
? (id: ReturnType<typeof SessionId>) => Promise.reject(new Error(`session "${id}" not found`))
: (id: ReturnType<typeof SessionId>) => persistence.load!(id),
} as never)
}
if (options.mountSessionQuery !== false && ctx.get('sessionQuery') === undefined) {
await ctx.plugin(TestSessionQueryService)
}
const sessionId = SessionId('main-session')
const session = ctx.sessions.create(
@@ -178,6 +200,7 @@ export async function createTuiTestHarness<TerminalType extends Terminal, Exit e
// test pins the clock only by passing `now` explicitly.
...(options.now === undefined ? {} : { now: options.now }),
...(options.formatCwd === undefined ? {} : { formatCwd: options.formatCwd }),
...(options.handoffResume === undefined ? {} : { handoffResume: options.handoffResume }),
})
return { ctx, session, agent, terminal, exit, controller }
}

View File

@@ -14,6 +14,7 @@ describe('dsh-tui plugin export shape', () => {
expect(unwrapped.name).toBe('ui-tui')
expect(unwrapped.inject).toEqual([
'agents',
'sessions',
'commands',
'userInteraction',
'tools',

View File

@@ -1,32 +1,51 @@
terminal 92x32 buffer=normal length=32 base=0 viewport=0
lifecycle started=1 stopped=0 progress=inactive
title "DSH snapshot"
cursor hidden column=1 viewportRow=10 bufferRow=10
cursor hidden column=6 viewportRow=4 bufferRow=4
buffer
0| " DEEPSEEK HARNESS"
style 1-8 fg=bright-blue bold
style 10-16 bold
1| " Snapshot agent ready."
style 1-21 fg=bright-black
2| " deepseek-v4-flash • main-session"
style 1-34 dim
3| <blank>
4| " Resumable sessions "
style 1-18 fg=bright-blue bold
5| " 2024-01-02 03:04 (current) "
style 1-16 fg=bright-black
style 17-26 fg=green
6| " RESUME_SESSION_ID=main-session dsh "
7| " 2024-01-01 00:00 "
style 1-16 fg=bright-black
8| " RESUME_SESSION_ID=earlier-session dsh "
9| "────────────────────────────────────────────────────────────────────────────────────────────"
style 0-91 dim
10| " "
style 1-1 inverse
11| "────────────────────────────────────────────────────────────────────────────────────────────"
style 0-91 dim
12| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context tools:collapsed"
style 0-43 dim
style 65-91 dim
13-31| <blank>
0| " "
1| " Resume session (1 of 2) "
style 2-24 fg=bright-blue bold
2| " "
3| " ╭──────────────────────────────────────────────────────────────────────────────────────╮ "
style 2-89 dim
4| " │ ⌕ │ "
style 2-2 dim
style 6-6 inverse
style 89-89 dim
5| " ╰──────────────────────────────────────────────────────────────────────────────────────╯ "
style 2-89 dim
6| " "
7| " /workspace/project "
style 2-19 fg=bright-black
8| " "
9| " ❯ Untitled session "
style 2-19 fg=bright-blue bold
10| " 2026-07-23T08:00:00.000Z · no completed turn · route unavailable "
style 2-67 fg=bright-black
11| " current · live · main-session "
style 2-32 dim
12| " unavailable: current session "
style 2-31 fg=yellow
13| " Resume selector design "
14| " 2024-01-01T00:00:08.000Z · turn 1: completed · deepseek/deepseek-v4-pro "
style 2-74 fg=bright-black
15| " persisted · earlier-session "
style 2-30 dim
16| " "
17| " "
18| " "
19| " "
20| " "
21| " "
22| " "
23| " "
24| " "
25| " "
26| " "
27| " "
28| " "
29| " "
30| " Type to search • ↑/↓ navigate • Enter resume • Esc clear/cancel "
style 2-70 dim
31| " "

View File

@@ -646,13 +646,27 @@ describe('TUI terminal-state snapshots', () => {
await disposeSnapshot(harness)
})
it('lists this workspace\'s resumable sessions with their commands', async () => {
it('opens the searchable resume selector with log-backed session summaries', async () => {
const dateNow = vi.spyOn(Date, 'now').mockReturnValue(Date.parse('2026-07-23T08:00:00.000Z'))
const earlier = { version: 0, id: SessionId('earlier-session'), createdAt: Date.parse('2024-01-01T00:00:00Z'), cwd: '/workspace/project' }
const harness = await setupSnapshot({
config: { resumeCommand: 'RESUME_SESSION_ID={session} dsh' },
sessionPersistence: { list: async () => [
{ version: 0, id: SessionId('main-session'), createdAt: Date.parse('2024-01-02T03:04:00Z'), cwd: '/workspace/project' },
{ version: 0, id: SessionId('earlier-session'), createdAt: Date.parse('2024-01-01T00:00:00Z'), cwd: '/workspace/project' },
] },
sessionPersistence: {
list: async () => [earlier],
load: async () => ({
meta: earlier,
events: [
{ type: 'turn/start', seq: 0, time: Date.parse('2024-01-01T00:00:01Z'), data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
{ type: 'user/message', seq: 1, time: Date.parse('2024-01-01T00:00:02Z'), data: { content: [{ type: 'text', text: 'restore the selector' }], source: { kind: 'user' } }, surfaceOp: 'append' },
{ type: 'step/start', seq: 2, time: Date.parse('2024-01-01T00:00:03Z'), data: { turn: 1, step: 1 } },
{ type: 'request/header', seq: 3, time: Date.parse('2024-01-01T00:00:04Z'), data: { header: { config: { provider: 'deepseek', model: 'deepseek-v4-pro' } }, reason: 'initial' } },
{ type: 'assistant/message', seq: 4, time: Date.parse('2024-01-01T00:00:05Z'), data: { turn: 1, step: 1, content: [{ type: 'text', text: 'ready' }], provenance: { provider: 'deepseek', model: 'deepseek-v4-pro' } }, surfaceOp: 'append' },
{ type: 'step/end', seq: 5, time: Date.parse('2024-01-01T00:00:06Z'), data: { turn: 1, step: 1 } },
{ type: 'turn/end', seq: 6, time: Date.parse('2024-01-01T00:00:07Z'), data: { turn: 1, reason: { kind: 'completed' } } },
{ type: 'session/title', seq: 7, time: Date.parse('2024-01-01T00:00:08Z'), data: { title: 'Resume selector design', messageSeqs: [1], source: { kind: 'fallback' } } },
],
}),
},
}, { columns: 92, rows: 32 })
harness.terminal.send('/resume')
harness.terminal.send('\r')
@@ -662,6 +676,7 @@ describe('TUI terminal-state snapshots', () => {
await harness.terminal.flush()
await checkpoint('resume-sessions', harness.terminal, { includeScrollback: true })
await disposeSnapshot(harness)
dateNow.mockRestore()
})
it('pins the detailed session diagnostics card', async () => {

View File

@@ -6,8 +6,10 @@ import { Context } from 'cordis'
import { CombinedAutocompleteProvider, type Terminal } from '@earendil-works/pi-tui'
import AgentRegistry, { agentEvents, assembleContextFor, type Agent } from '@deepseek-ai/dsh-agent'
import { type LlmCallConfig } 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 SessionHeader } from '@deepseek-ai/dsh-session'
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'
@@ -152,6 +154,7 @@ describe('TUI config', () => {
maxToolOutputLines: 6,
maxQuestionOptions: 8,
maxModelOptions: 8,
maxResumeOptions: 8,
questionDialogWidth: 200,
questionDialogMaxHeight: 20,
modelDialogWidth: 72,
@@ -169,6 +172,7 @@ describe('TUI config', () => {
maxToolOutputLines: 2,
maxQuestionOptions: 3,
maxModelOptions: 4,
maxResumeOptions: 5,
questionDialogWidth: 60,
questionDialogMaxHeight: 14,
modelDialogWidth: 64,
@@ -185,6 +189,7 @@ describe('TUI config', () => {
maxToolOutputLines: 2,
maxQuestionOptions: 3,
maxModelOptions: 4,
maxResumeOptions: 5,
questionDialogWidth: 60,
questionDialogMaxHeight: 14,
modelDialogWidth: 64,
@@ -204,6 +209,21 @@ describe('resume command and /resume', () => {
const RESUME = 'RESUME_SESSION_ID={session} dsh'
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({
@@ -243,73 +263,832 @@ describe('resume command and /resume', () => {
await dispose(result)
})
it('lists this workspace\'s sessions newest-first and marks the current one', async () => {
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 () => [
header('main-session', 1000, '/workspace'),
header('older-session', 500, '/workspace'),
header('newer-session', 2000, '/workspace'),
header('foreign-session', 3000, '/elsewhere'),
],
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: 'rejected', reason: 'policy' }, 'rejected'],
[{ 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()
const output = result.terminal.output
expect(output).toContain('Resumable sessions')
expect(output).toContain('RESUME_SESSION_ID=main-session dsh')
expect(output).toContain('(current)')
expect(output).toContain('RESUME_SESSION_ID=newer-session dsh')
expect(output).not.toContain('foreign-session')
// Newest-first: the newer session's command precedes the current session's.
// Match the full resume command, not the bare id: the banner detail line
// echoes the current session id (`main-session`) above the listing.
expect(output.indexOf('RESUME_SESSION_ID=newer-session')).toBeLessThan(
output.indexOf('RESUME_SESSION_ID=main-session'),
)
expect(output.indexOf('RESUME_SESSION_ID=main-session')).toBeLessThan(
output.indexOf('RESUME_SESSION_ID=older-session'),
)
expect(result.terminal.output).toContain('Resume session scan failed: index unavailable')
expect(result.terminal.stopped).toBe(0)
await dispose(result)
})
it('warns from /resume when resume is not configured', async () => {
const result = await setup({ cwd: '/workspace' })
result.terminal.send('/resume')
result.terminal.send('\r')
await tick()
expect(result.terminal.output).toContain('Resume is not configured')
await dispose(result)
})
it('warns from /resume when no persistence backend is mounted', async () => {
const result = await setup({ cwd: '/workspace', config: { resumeCommand: RESUME } })
result.terminal.send('/resume')
result.terminal.send('\r')
await tick()
expect(result.terminal.output).toContain('no persistence backend is mounted')
await dispose(result)
})
it('notes from /resume when no workspace sessions are persisted yet', async () => {
it('supersedes a slower prior selector scan', async () => {
const first = Promise.withResolvers<SessionRecord[]>()
let calls = 0
const result = await setup({
cwd: '/workspace',
config: { resumeCommand: RESUME },
sessionPersistence: { list: async () => [header('foreign-session', 10, '/elsewhere')] },
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()
expect(result.terminal.output).toContain('No resumable sessions found')
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 tick()
expect(result.terminal.output).toContain('This host cannot hand off in place. Exit and run:')
expect(result.terminal.output).toContain('RESUME_SESSION_ID=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 tick()
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('context/message', {
content: renderGoalChange(change),
source: { kind: 'goal', goalId: change.goal.id, revision: change.goal.revision, round: 0 },
meta: change as unknown as JsonValue,
}, { 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
@@ -1575,22 +2354,27 @@ describe('pi-tui chat lifecycle and transcript', () => {
expect(result.terminal.output).toContain('advertised by multiple providers')
expect(result.terminal.output).toContain('already alpha/a1')
const firstSelectorOutput = result.terminal.output.length
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')
await vi.waitFor(() => {
expect(result.terminal.output.slice(firstSelectorOutput)).toContain('Select model')
})
result.terminal.send('\x1b')
await tick()
result.agent.status = 'running'
const runningSelectorOutput = result.terminal.output.length
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 — current')
await vi.waitFor(() => {
const output = result.terminal.output.slice(runningSelectorOutput)
expect(output).toContain('Select model')
expect(output).toContain('alpha/a1')
expect(output).toContain('Alpha One — Fast — current')
})
result.terminal.send('\x1b[B')
result.terminal.send('\x1b[B')
result.terminal.send('\r')
@@ -1602,9 +2386,12 @@ describe('pi-tui chat lifecycle and transcript', () => {
await tick()
expect(result.terminal.output).not.toContain('50% context tools:collapsed')
const cancelledSelectorOutput = result.terminal.output.length
result.terminal.send('/model')
result.terminal.send('\r')
await tick()
await vi.waitFor(() => {
expect(result.terminal.output.slice(cancelledSelectorOutput)).toContain('Select model')
})
result.terminal.send('\x1b')
await tick()
expect(result.agent.cancelled).not.toContain('cancelled from terminal')