feat(tui): add safe session resume flow

This commit is contained in:
NI0317
2026-07-24 12:31:26 +08:00
committed by ZiyaZhang
parent 65d29da8a1
commit 2ae9f4fdf3
57 changed files with 2312 additions and 192 deletions

View File

@@ -6,11 +6,12 @@ Shared boot glue for the app bins ([`dsh-tui-demo`](../../examples/tui-demo/READ
|---|---|
| `resolveConfigPath(path, snapshotMode, cwd?)` | Absolute config path; `snapshotMode === 'replay'` swaps a `cordis.yml`/`.yaml` basename for its sibling `cordis.snapshot.yml` |
| `parseResumeArg(argv)` | Split the `--resume <id>` / `--resume=<id>` flag out of the arguments, returning `{ resumeSessionId, rest }`; a valueless, empty, or repeated flag throws so a mistyped resume fails loud instead of silently starting fresh |
| `replaceResumeArg(argv, sessionId)` | Remove an existing resume flag and append one canonical `--resume <sessionId>` pair while preserving positional arguments |
| `loadEnv(binName, dir?, warn?)` | Load the gitignored `.env` (Node `process.loadEnvFile`); absent file is fine, an unloadable one warns a single labelled line (default: stderr) |
| `installFailLoud(binName, proc?)` | Turn a post-`boot()` unhandled Loader rejection into one labelled stderr line + `exit(1)`; returns the uninstaller (for tests) |
| `assertEntriesLoaded(ctx, binName)` | Throw when a settled tree holds an enabled entry with no fiber (a plugin module that failed to import) |
| `loadPersonalPatches(binName, dir?)` | Parse the optional `config.yaml` in the Harness home (default [`resolveDshHome()`](../../util/paths/README.md): `$DSH_HOME`, else `~/.dsh`) — a top-level YAML array of include `PatchOptions` (id-targeted config overrides, `insert` lists, `!!js` allowed); absent file → `undefined`, an unreadable/unparsable/non-array file throws |
| `boot(binName, absoluteConfigPath, patches?)` | Mount the Loader, mount the statically imported include plugin as the `cordis:include` builtin (so the config may live outside `node_modules` reach), include the config by absolute `file://` URL with the optional overlay patches, await the whole tree, assert entries loaded, return the root context |
| `boot(binName, absoluteConfigPath, patches?, prepare?)` | Create the root context, run optional host preparation before plugins mount, then mount the Loader/include tree, await it, assert entries loaded, and return the root context |
| `addHarnessSourceSection(ctx, sourceRoot)` | Add a global `harness:source` prompt section (ordered just after the harness identity, before the persona) telling the agent the on-disk path to its own source checkout; a no-op returning `undefined` when the booted tree has no `systemPrompt` service. The section is registered against that service's fiber, so a dev HMR reload of the system prompt drops it until the next boot |
| `HARNESS_SOURCE_SECTION` | The `'harness:source'` section name `addHarnessSourceSection` registers under |

View File

@@ -80,6 +80,18 @@ export function parseResumeArg(
return { resumeSessionId, rest }
}
/**
* Replace any existing resume flag with one canonical trailing `--resume <id>` pair.
* @param argv - current arguments after command dispatch.
* @param sessionId - selected session id.
* @returns flag-normalized arguments for a process replacement.
*/
export function replaceResumeArg(argv: readonly string[], sessionId: string): string[] {
if (sessionId.length === 0) throw new Error(`${RESUME_FLAG} requires a non-empty session id`)
const { rest } = parseResumeArg(argv)
return [...rest, RESUME_FLAG, sessionId]
}
/**
* Load the optional gitignored `.env` from `dir`. Missing files fall back to the
* ambient environment; other read failures are reported through `warn`.
@@ -216,12 +228,17 @@ export function assertEntriesLoaded(ctx: Context, binName: string): void {
* (see {@link resolveConfigPath}).
* @param patches - optional overlay patches applied over the included tree
* (see {@link loadPersonalPatches}); an empty list mounts none.
* @param prepare - optional host setup run against the root context before any Loader entry mounts.
* @returns the root context once every entry has started.
*/
export async function boot(
binName: string, absoluteConfigPath: string, patches?: PatchOptions[],
binName: string,
absoluteConfigPath: string,
patches?: PatchOptions[],
prepare?: (ctx: Context) => Promise<void> | void,
): Promise<Context> {
const ctx = new Context()
await prepare?.(ctx)
ctx.baseUrl = pathToFileURL(dirname(absoluteConfigPath)).href + '/'
await ctx.plugin(Loader)
ctx.loader.builtins.include = Include

View File

@@ -6,7 +6,7 @@ import { Context } from 'cordis'
import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
import {
addHarnessSourceSection, assertEntriesLoaded, boot, HARNESS_SOURCE_SECTION,
installFailLoud, loadEnv, parseResumeArg, resolveConfigPath, type FailLoudProcess,
installFailLoud, loadEnv, parseResumeArg, replaceResumeArg, resolveConfigPath, type FailLoudProcess,
} from '../src/index.ts'
const NAME = 'dsh-test-bin'
@@ -55,6 +55,15 @@ describe('parseResumeArg', () => {
})
})
describe('replaceResumeArg', () => {
it('keeps positional arguments and replaces either existing flag form', () => {
expect(replaceResumeArg(['app.yml'], 'next')).toEqual(['app.yml', '--resume', 'next'])
expect(replaceResumeArg(['--resume', 'old', 'app.yml'], 'next')).toEqual(['app.yml', '--resume', 'next'])
expect(replaceResumeArg(['app.yml', '--resume=old'], 'next')).toEqual(['app.yml', '--resume', 'next'])
expect(() => replaceResumeArg([], '')).toThrow('non-empty session id')
})
})
describe('loadEnv', () => {
it('loads variables from .env in the given dir', () => {
const dir = tmp()
@@ -196,6 +205,19 @@ describe('boot', () => {
}
})
it('runs host preparation before the Loader tree mounts', async () => {
const dir = tmp()
writeFileSync(join(dir, 'noop.mjs'), 'export const name = "noop"\nexport function apply() {}\n')
writeFileSync(join(dir, 'cordis.yml'), '- id: noop\n name: ./noop.mjs\n')
const prepared: Context[] = []
const ctx = await boot(NAME, join(dir, 'cordis.yml'), undefined, (hostCtx) => { prepared.push(hostCtx) })
try {
expect(prepared).toEqual([ctx])
} finally {
await ctx.fiber.dispose()
}
})
it('rejects (never exits 0 half-empty) when a config names a plugin that cannot be imported', async () => {
const dir = tmp()
writeFileSync(join(dir, 'cordis.yml'), '- id: ghost\n name: ./missing.mjs\n')

View File

@@ -30,7 +30,9 @@ The footer sums the session's reported usage as `↑<uncached input> ↓<output>
`/status` adds a point-in-time diagnostics card to the transcript and remains available while the agent runs. It reports the session id, title, working directory, selected provider/model, reasoning-block visibility, agent state, event/turn/step/tool-call counts, exact input/output/cache token buckets, KV-cache hit rate, token-meter context use and capacity, creation time, and latest event time. Missing titles, models, cache input, or context capacity are labeled instead of inferred. The card is terminal-only and does not duplicate the compact footer.
When `resumeCommand` is set and a `sessionPersistence` backend is mounted, exiting prints the resume command for the current session (once it has been persisted, so an abandoned session yields no hint), and `/resume` lists this workspace's persisted sessions newest-first, each with its resume command and a marker on the current one. `{session}` in the template expands to the session id; the TUI only prints commands to copy and never resumes in place.
`/resume` opens a keyboard selector over the current workspace. Candidates are sorted by last logged activity and searchable by log-backed title or session id; each row reports current/live/persisted state, last turn outcome, recent provider/model, and durable goal phase when present. The current session, another live owner's session, an unreadable log, a mismatched cwd, or a session whose logged provider has no current adapter remains visible but disabled. Selection repeats those checks, requires the current agent to be idle, flushes it, stops the terminal UI, and calls the optional host-owned `TuiRuntime.handoffResume`; where `process.execve` is available, the shipped `dsh` host disposes the app and atomically replaces its process, so two runtimes never own the terminal together. Resume restores the same `SessionId`, transcript, title, todos, and durable goal; goal activation remains disarmed and the TUI asks for human confirmation or `/goal resume`.
`resumeCommand` remains the deployment-owned fallback: exiting prints it only after the current session is durable, and a host without in-place handoff shows the selected session's command. `{session}` expands to the session id. TUI code never executes the template or arbitrary shell text.
## Config
@@ -42,17 +44,20 @@ When `resumeCommand` is set and a `sessionPersistence` backend is mounted, exiti
| `maxToolOutputLines` | `6` | Output lines retained across a collapsed tool card's head/tail preview |
| `maxQuestionOptions` | `8` | Visible options in a question panel |
| `maxModelOptions` | `8` | Visible models in the model selector |
| `maxResumeOptions` | `8` | Visible sessions in the resume selector |
| `questionDialogWidth` | `200` | Question-panel width in columns, clamped to the terminal |
| `questionDialogMaxHeight` | `20` | Question-panel maximum rows |
| `modelDialogWidth` | `72` | Model-selector width in columns |
| `modelDialogMaxHeight` | `20` | Model-selector maximum rows |
| `resumeDialogWidth` | `88` | Resume-selector width in columns |
| `resumeDialogMaxHeight` | `24` | Resume-selector maximum rows |
| `fileSearchMaxResults` | `20` | Maximum file and directory candidates shown for one `@` query |
| `fileSearchMaxEntries` | `10000` | Maximum paths retained in the bounded workspace index used by bare fuzzy queries |
| `fileSearchExcludedDirectories` | `['.git', 'node_modules']` | Directory basenames omitted from traversal and direct completion |
| `showHardwareCursor` | `false` | Show the hardware cursor at pi-tui's IME marker |
| `color` | `true` | Apply the built-in ANSI palette (see [Color](#color)) |
| `title` | `DeepSeek Harness` | Product suffix for the terminal window title. |
| `resumeCommand` | — | Shell command template for the exit hint and `/resume`, with `{session}` expanded to the session id; unset disables both. Needs a `sessionPersistence` backend |
| `resumeCommand` | — | Shell command template for the exit hint and hosts without in-place handoff, with `{session}` expanded to the session id |
```yaml
- id: terminal

View File

@@ -33,9 +33,11 @@
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-llm-retry": "^0.0.1",
"@deepseek-ai/dsh-goal": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"@deepseek-ai/dsh-session-reference": "^0.0.1",
"@deepseek-ai/dsh-session-persistence": "^0.0.1",
"@deepseek-ai/dsh-session-query": "^0.0.1",
"@deepseek-ai/dsh-session-title": "^0.0.1",
"@deepseek-ai/dsh-skill": "^0.0.1",
"@deepseek-ai/dsh-system-prompt": "^0.0.1",
@@ -48,6 +50,12 @@
"@deepseek-ai/dsh-session-persistence": {
"optional": true
},
"@deepseek-ai/dsh-session-query": {
"optional": true
},
"@deepseek-ai/dsh-goal": {
"optional": true
},
"@deepseek-ai/dsh-skill": {
"optional": true
}
@@ -60,6 +68,7 @@
"@cordisjs/plugin-loader": "workspace:^",
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-agent-loop": "workspace:^",
"@deepseek-ai/dsh-goal": "workspace:^",
"@deepseek-ai/dsh-commands": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",

View File

@@ -66,12 +66,17 @@ import {
type SessionHeader,
type TodoItem,
} from '@deepseek-ai/dsh-session'
import { foldGoal, type GoalPhase } from '@deepseek-ai/dsh-goal'
import {
formatSessionReferenceMention,
parseSessionReferenceText,
type SessionReferenceService,
} from '@deepseek-ai/dsh-session-reference'
import { foldSessionTitle } from '@deepseek-ai/dsh-session-title'
import type {
SessionLogSnapshot,
SessionRecord,
} from '@deepseek-ai/dsh-session-query'
// Side-effect type import: declaration-merges the optional `sessionPersistence`
// service onto `Context` so `ctx.get('sessionPersistence')` is typed.
import type {} from '@deepseek-ai/dsh-session-persistence'
@@ -120,9 +125,22 @@ declare module 'cordis' {
interface Context {
/** Terminal-only interaction service, available only while a TUI is mounted. */
tui: TuiExtensionService
/** Optional process host that can replace this TUI with a resumed session. */
tuiResumeHost: TuiResumeHost
}
}
/** Process-lifecycle owner used by the shipped CLI for an atomic resume handoff. */
export interface TuiResumeHost {
/**
* Dispose the current app and replace it with a runtime for `sessionId`.
* Success does not return. A host may reject before it commits teardown;
* after commit it owns fatal reporting and process exit.
* @param sessionId - validated persisted session selected by the user.
*/
handoff(sessionId: SessionId): Promise<never>
}
/**
* Optional terminal-local interaction service provided by one mounted TUI.
*
@@ -162,7 +180,7 @@ export {
} from './file-autocomplete.ts'
export const name = 'ui-tui'
export const inject = ['agents', 'commands', 'userInteraction', 'tools', 'llm', 'systemPrompt', 'tokenMeter']
export const inject = ['agents', 'sessions', 'commands', 'userInteraction', 'tools', 'llm', 'systemPrompt', 'tokenMeter']
/** Model guidance for path-only file references selected through the TUI. */
export const FILE_REFERENCE_PROMPT = 'Paths prefixed with @ are files explicitly referenced by the user. Use the read tool when their contents are needed; do not claim to have inspected a file before reading it.'
@@ -177,6 +195,8 @@ export interface TuiConfig {
maxQuestionOptions?: number
/** Maximum models visible at once in the model selector. */
maxModelOptions?: number
/** Maximum sessions visible at once in the resume selector. */
maxResumeOptions?: number
/** User-question panel width in terminal columns, clamped to the terminal. */
questionDialogWidth?: number
/** User-question panel maximum height in terminal rows. */
@@ -185,6 +205,10 @@ export interface TuiConfig {
modelDialogWidth?: number
/** Model-selector maximum height in terminal rows. */
modelDialogMaxHeight?: number
/** Resume-selector width in terminal columns. */
resumeDialogWidth?: number
/** Resume-selector maximum height in terminal rows. */
resumeDialogMaxHeight?: number
/** Maximum fuzzy file candidates displayed for one `@` query. */
fileSearchMaxResults?: number
/** Maximum paths retained in one `@` workspace index. */
@@ -210,10 +234,13 @@ const showReasoningSchema = z.boolean().default(true)
const maxToolOutputLinesSchema = z.number().step(1).min(1).default(6)
const maxQuestionOptionsSchema = z.number().step(1).min(1).default(8)
const maxModelOptionsSchema = z.number().step(1).min(1).default(8)
const maxResumeOptionsSchema = z.number().step(1).min(1).default(8)
const questionDialogWidthSchema = z.number().step(1).min(20).default(200)
const questionDialogMaxHeightSchema = z.number().step(1).min(6).default(20)
const modelDialogWidthSchema = z.number().step(1).min(20).default(72)
const modelDialogMaxHeightSchema = z.number().step(1).min(6).default(20)
const resumeDialogWidthSchema = z.number().step(1).min(36).default(88)
const resumeDialogMaxHeightSchema = z.number().step(1).min(8).default(24)
const fileSearchMaxResultsSchema = z.number().step(1).min(1).default(DEFAULT_FILE_SEARCH_MAX_RESULTS)
const fileSearchMaxEntriesSchema = z.number().step(1).min(1).default(DEFAULT_FILE_SEARCH_MAX_ENTRIES)
const fileSearchExcludedDirectoriesSchema = z.array(z.string()).default([...DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES])
@@ -228,10 +255,13 @@ const tuiConfigSchemaFields = {
maxToolOutputLines: maxToolOutputLinesSchema,
maxQuestionOptions: maxQuestionOptionsSchema,
maxModelOptions: maxModelOptionsSchema,
maxResumeOptions: maxResumeOptionsSchema,
questionDialogWidth: questionDialogWidthSchema,
questionDialogMaxHeight: questionDialogMaxHeightSchema,
modelDialogWidth: modelDialogWidthSchema,
modelDialogMaxHeight: modelDialogMaxHeightSchema,
resumeDialogWidth: resumeDialogWidthSchema,
resumeDialogMaxHeight: resumeDialogMaxHeightSchema,
fileSearchMaxResults: fileSearchMaxResultsSchema,
fileSearchMaxEntries: fileSearchMaxEntriesSchema,
fileSearchExcludedDirectories: fileSearchExcludedDirectoriesSchema,
@@ -251,11 +281,10 @@ export interface Config extends TuiConfig {
/** Exact shared agent/session identity driven by this terminal. Defaults to `main`. */
sessionId?: string
/**
* Shell command template shown for resuming this session: printed on exit and
* listed by `/resume`, with every `{session}` occurrence replaced by the live
* session id. Absent disables both surfaces. Deployments set it only when a
* persistence backend makes the session resumable (e.g.
* `RESUME_SESSION_ID={session} dsh`).
* Shell command fallback printed on exit or after selecting a session when
* the host cannot hand off in place. Every `{session}` becomes the selected
* id; the TUI never executes this text. Absent disables only the fallback,
* not the interactive selector.
*/
resumeCommand?: string
}
@@ -268,10 +297,13 @@ export const Config: z<Config> = z.object({
maxToolOutputLines: tuiConfigSchemaFields.maxToolOutputLines,
maxQuestionOptions: tuiConfigSchemaFields.maxQuestionOptions,
maxModelOptions: tuiConfigSchemaFields.maxModelOptions,
maxResumeOptions: tuiConfigSchemaFields.maxResumeOptions,
questionDialogWidth: tuiConfigSchemaFields.questionDialogWidth,
questionDialogMaxHeight: tuiConfigSchemaFields.questionDialogMaxHeight,
modelDialogWidth: tuiConfigSchemaFields.modelDialogWidth,
modelDialogMaxHeight: tuiConfigSchemaFields.modelDialogMaxHeight,
resumeDialogWidth: tuiConfigSchemaFields.resumeDialogWidth,
resumeDialogMaxHeight: tuiConfigSchemaFields.resumeDialogMaxHeight,
fileSearchMaxResults: tuiConfigSchemaFields.fileSearchMaxResults,
fileSearchMaxEntries: tuiConfigSchemaFields.fileSearchMaxEntries,
fileSearchExcludedDirectories: tuiConfigSchemaFields.fileSearchExcludedDirectories,
@@ -287,10 +319,13 @@ export interface ResolvedTuiConfig {
maxToolOutputLines: number
maxQuestionOptions: number
maxModelOptions: number
maxResumeOptions: number
questionDialogWidth: number
questionDialogMaxHeight: number
modelDialogWidth: number
modelDialogMaxHeight: number
resumeDialogWidth: number
resumeDialogMaxHeight: number
fileSearchMaxResults: number
fileSearchMaxEntries: number
fileSearchExcludedDirectories: string[]
@@ -314,6 +349,8 @@ export interface TuiRuntime {
formatCwd?: (cwd: string | undefined) => string
/** Monotonic-enough wall clock for elapsed status rendering. Defaults to `Date.now`. */
now?(): number
/** Host-owned safe process handoff; absent leaves `resumeCommand` as the fallback. */
handoffResume?: TuiResumeHost['handoff']
}
/**
@@ -328,10 +365,13 @@ export function resolveTuiConfig(config: TuiConfig | undefined): ResolvedTuiConf
maxToolOutputLines: config?.maxToolOutputLines ?? 6,
maxQuestionOptions: config?.maxQuestionOptions ?? 8,
maxModelOptions: config?.maxModelOptions ?? 8,
maxResumeOptions: config?.maxResumeOptions ?? 8,
questionDialogWidth: config?.questionDialogWidth ?? 200,
questionDialogMaxHeight: config?.questionDialogMaxHeight ?? 20,
modelDialogWidth: config?.modelDialogWidth ?? 72,
modelDialogMaxHeight: config?.modelDialogMaxHeight ?? 20,
resumeDialogWidth: config?.resumeDialogWidth ?? 88,
resumeDialogMaxHeight: config?.resumeDialogMaxHeight ?? 24,
fileSearchMaxResults: config?.fileSearchMaxResults ?? DEFAULT_FILE_SEARCH_MAX_RESULTS,
fileSearchMaxEntries: config?.fileSearchMaxEntries ?? DEFAULT_FILE_SEARCH_MAX_ENTRIES,
fileSearchExcludedDirectories: [...(config?.fileSearchExcludedDirectories ?? DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES)],
@@ -1236,6 +1276,173 @@ class ModelDialog implements Component {
}
}
interface ResumeRoute {
provider: string
model: string
}
interface ResumeCandidate {
record: SessionRecord
occupied: boolean
title: string
lastActivityAt: number
lastTurn: string
route?: ResumeRoute
goalPhase?: GoalPhase
disabledReason?: string
}
function resumeTurnLabel(snapshot: SessionLogSnapshot): string {
const event = snapshot.events.findLast(item => item.type === 'turn/end')
if (event === undefined) return 'no completed turn'
const reason = event.data.reason
switch (reason.kind) {
case 'completed': return `turn ${event.data.turn}: completed`
case 'aborted': return `turn ${event.data.turn}: cancelled`
case 'error': return `turn ${event.data.turn}: error`
case 'disposed': return `turn ${event.data.turn}: disposed`
case 'max-tokens': return `turn ${event.data.turn}: max tokens`
case 'rejected': return `turn ${event.data.turn}: rejected`
case 'interrupted': return `turn ${event.data.turn}: interrupted`
default: return `turn ${event.data.turn}: unknown result`
}
}
function resumeRoute(snapshot: SessionLogSnapshot): ResumeRoute | undefined {
const header = snapshot.events.findLast(item => item.type === 'request/header')
if (header?.type === 'request/header') {
return { provider: header.data.header.config.provider, model: header.data.header.config.model }
}
const assistant = snapshot.events.findLast(item => item.type === 'assistant/message')
return assistant?.type === 'assistant/message'
? { provider: assistant.data.provenance.provider, model: assistant.data.provenance.model }
: undefined
}
function summarizeResumeCandidate(
record: SessionRecord,
snapshot: SessionLogSnapshot,
currentId: SessionId,
cwd: string | undefined,
occupied: boolean,
availableProviders: ReadonlySet<string>,
): ResumeCandidate {
const title = foldSessionTitle(snapshot.events)?.title ?? 'Untitled session'
const route = resumeRoute(snapshot)
const foldedGoal = foldGoal(snapshot.events).goal
let disabledReason: string | undefined
if (record.header.id === currentId) disabledReason = 'current session'
else if (record.live || occupied) disabledReason = 'occupied by another live agent'
else if (record.header.cwd !== cwd) disabledReason = 'different workspace'
else if (route !== undefined && !availableProviders.has(route.provider)) {
disabledReason = `session is complete, but route is currently unavailable (${route.provider}/${route.model})`
}
return {
record,
occupied,
title,
lastActivityAt: snapshot.events.at(-1)?.time ?? snapshot.session.createdAt,
lastTurn: resumeTurnLabel(snapshot),
...route === undefined ? {} : { route },
...foldedGoal === undefined ? {} : { goalPhase: foldedGoal.phase },
...disabledReason === undefined ? {} : { disabledReason },
}
}
/** Searchable keyboard selector over detached, preflighted resume summaries. */
class ResumeDialog implements Component, Focusable {
private query = ''
private selectedIndex = 0
private error = ''
focused = false
constructor(
private readonly candidates: readonly ResumeCandidate[],
private readonly maxVisible: number,
private readonly palette: Palette,
private readonly done: (candidate: ResumeCandidate) => void,
private readonly cancel: () => void,
) {}
invalidate(): void {}
private filtered(): ResumeCandidate[] {
const query = this.query.trim().toLocaleLowerCase()
if (query === '') return [...this.candidates]
return this.candidates.filter(candidate => candidate.title.toLocaleLowerCase().includes(query)
|| candidate.record.header.id.toLocaleLowerCase().includes(query))
}
handleInput(data: string): void {
this.invalidate()
const filtered = this.filtered()
if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl('c'))) {
this.cancel()
return
}
if (matchesKey(data, Key.up)) {
this.selectedIndex = filtered.length === 0
? 0
: (this.selectedIndex + filtered.length - 1) % filtered.length
} else if (matchesKey(data, Key.down)) {
this.selectedIndex = filtered.length === 0 ? 0 : (this.selectedIndex + 1) % filtered.length
} else if (matchesKey(data, Key.enter)) {
const selected = filtered[this.selectedIndex]
if (selected === undefined) this.error = 'No session matches this search.'
else if (selected.disabledReason !== undefined) this.error = selected.disabledReason
else this.done(selected)
} else if (data === '\x7f' || data === '\b') {
this.query = Array.from(this.query).slice(0, -1).join('')
this.selectedIndex = 0
this.error = ''
} else if (!Array.from(data).some(character => character < ' ' || character === '\x7f')) {
this.query += data
this.selectedIndex = 0
this.error = ''
}
}
render(width: number): string[] {
const innerWidth = Math.max(1, width - 4)
const filtered = this.filtered()
if (this.selectedIndex >= filtered.length) this.selectedIndex = Math.max(0, filtered.length - 1)
const start = Math.max(0, Math.min(
this.selectedIndex - Math.floor(this.maxVisible / 2),
filtered.length - this.maxVisible,
))
const end = Math.min(filtered.length, start + this.maxVisible)
const body: string[] = [
this.query === ''
? `${this.palette.muted('Search:')} ${this.palette.dim('title or session id')}`
: this.palette.text(`Search: ${displayText(this.query)}`),
'',
]
for (let index = start; index < end; index += 1) {
const candidate = filtered[index] as ResumeCandidate
const selected = index === this.selectedIndex
const status = [
candidate.disabledReason === 'current session' ? 'current' : undefined,
candidate.record.live || candidate.occupied ? 'live' : undefined,
candidate.record.persisted ? 'persisted' : undefined,
].filter((value): value is string => value !== undefined).join(' · ')
const lead = `${selected ? '' : ' '} ${displayText(candidate.title)}`
body.push(selected ? this.palette.bold(this.palette.accent(lead)) : lead)
const route = candidate.route === undefined ? 'route unavailable' : `${candidate.route.provider}/${candidate.route.model}`
const goal = candidate.goalPhase === undefined ? '' : ` · goal ${candidate.goalPhase}`
body.push(this.palette.muted(` ${new Date(candidate.lastActivityAt).toISOString()} · ${candidate.lastTurn} · ${route}${goal}`))
body.push(this.palette.dim(` ${status} · ${displayText(candidate.record.header.id)}`))
if (candidate.disabledReason !== undefined) {
body.push(this.palette.warning(` unavailable: ${displayText(candidate.disabledReason)}`))
}
}
if (filtered.length === 0) body.push(this.palette.warning('No matching sessions.'))
if (filtered.length > this.maxVisible) body.push(this.palette.dim(`${this.selectedIndex + 1}/${filtered.length}`))
body.push('', this.palette.dim('Type to search • ↑/↓ navigate • Enter resume • Esc cancel'))
if (this.error !== '') body.push(this.palette.error(displayText(this.error)))
return renderDialog('Resume session', body.flatMap(line => wrapTextWithAnsi(line, innerWidth)), width, this.palette)
}
}
class QuestionDialog implements Component, Focusable {
private selectedIndex = 0
private selected = new Set<number>()
@@ -1585,6 +1792,7 @@ export function createTuiChat(
const agent = ctx.agents.get(sessionId)
if (agent === undefined) throw new Error(`ui-tui: session "${sessionId}" is not running`)
const persistence = ctx.get('sessionPersistence')
const sessionQuery = ctx.get('sessionQuery')
const resolved = resolveTuiConfig(config)
const palette = createPalette(resolved.color)
const mdTheme = markdownTheme(palette)
@@ -1631,6 +1839,9 @@ export function createTuiChat(
const referenceControllers = new Set<AbortController>()
let activeQuestion: PendingQuestion | undefined
let modelOverlay: TuiOverlaySession | undefined
let resumeOverlay: TuiOverlaySession | undefined
let resumeInFlight = false
let resumeScan = 0
let tuiServiceFiber: Fiber | undefined
const target: AgentLlmTargetRef = { current: initialTarget(agent), assembled: undefined }
let contextWindow: number | undefined
@@ -1640,6 +1851,8 @@ export function createTuiChat(
> | undefined
let modelCommands = Promise.resolve()
const now = (): number => runtime.now?.() ?? Date.now()
const agentStatus = (): AgentStatus => agent.status
const isDisposed = (): boolean => disposed
// A configured subtitle renders as a banner line; when absent, the banner has
// no subtitle. The banner itself sweeps in on start (see startBannerReveal).
@@ -2204,7 +2417,6 @@ export function createTuiChat(
}
return all
.filter(header => header.cwd === agent.session.header.cwd)
.sort((a, b) => b.createdAt - a.createdAt)
}
/**
@@ -2597,37 +2809,153 @@ export function createTuiChat(
})
}
/**
* List this workspace's resumable sessions, newest first, each with its
* resume command and a marker on the current one. Warns when resume is not
* configured or no persistence backend is mounted; notes when nothing is
* persisted yet. The listing is asynchronous (a persistence scan), so the
* transcript updates once it resolves.
*/
const showResume = (): void => {
const template = config.resumeCommand
if (template === undefined) {
appendNotice('Resume is not configured for this app.', 'warning')
return
/** Build one display candidate without letting a corrupt neighbor abort the selector. */
const readResumeCandidate = async (
record: SessionRecord,
providers: ReadonlySet<string>,
): Promise<ResumeCandidate> => {
try {
const occupied = record.live || (record.persisted && persistence !== undefined
? await persistence.isLive(record.header.id)
: false)
let snapshot: SessionLogSnapshot
const live = ctx.sessions.get(record.header.id)
if (live !== undefined) {
snapshot = {
session: structuredClone(live.header),
events: live.events.map(event => structuredClone(event)),
}
} else {
/* v8 ignore next -- caller checks the optional service before mapping records */
if (sessionQuery === undefined) throw new Error('session query is unavailable')
snapshot = await sessionQuery.readSession(record.header.id)
}
return summarizeResumeCandidate(
record,
snapshot,
agent.session.id,
agent.session.header.cwd,
occupied,
providers,
)
} catch (error: unknown) {
return {
record,
occupied: record.live,
title: 'Unreadable session',
lastActivityAt: record.header.createdAt,
lastTurn: 'log unavailable',
disabledReason: `session cannot be loaded: ${errorChain(error)}`,
}
}
if (persistence === undefined) {
appendNotice('Resume is not available: no persistence backend is mounted.', 'warning')
return
}
void listWorkspaceSessions().then((sessions) => {
if (sessions.length === 0) {
appendNotice('No resumable sessions found for this workspace yet.', 'info')
}
/** Re-read every mutable precondition immediately before terminal handoff. */
const preflightResume = async (sessionId: SessionId): Promise<ResumeCandidate> => {
/* v8 ignore next -- only showResume can call this closure, after proving the optional service exists */
if (sessionQuery === undefined) throw new Error('Resume is unavailable: session query is not mounted.')
const initialStatus = agentStatus()
if (initialStatus !== 'idle') throw new Error(`Resume requires an idle agent (status: ${initialStatus}).`)
const record = (await sessionQuery.listSessions()).find(candidate => candidate.header.id === sessionId)
if (record === undefined) throw new Error(`Session "${sessionId}" is no longer available.`)
const candidate = await readResumeCandidate(
record,
new Set(ctx.llm.listProviders().map(provider => provider.id)),
)
if (candidate.disabledReason !== undefined) throw new Error(candidate.disabledReason)
const finalStatus = agentStatus()
if (finalStatus !== 'idle') throw new Error(`Resume requires an idle agent (status: ${finalStatus}).`)
return candidate
}
const handoffResume = async (candidate: ResumeCandidate, overlay: TuiOverlaySession): Promise<void> => {
if (resumeInFlight) return
resumeInFlight = true
try {
const checked = await preflightResume(candidate.record.header.id)
const hostHandoff = runtime.handoffResume
if (hostHandoff === undefined) {
const template = config.resumeCommand
const fallback = template?.replaceAll('{session}', checked.record.header.id)
await overlay.close()
resumeOverlay = undefined
appendNotice(fallback === undefined
? 'Session is resumable, but this host cannot hand it off in place.'
: `This host cannot hand off in place. Exit and run: ${fallback}`, 'warning')
return
}
chat.addChild(new Spacer(1))
chat.addChild(new Text(palette.bold(palette.accent('Resumable sessions')), 1, 0))
const lines = sessions.map((header) => {
const when = new Date(header.createdAt).toISOString().slice(0, 16).replace('T', ' ')
const marker = header.id === agent.session.id ? palette.success(' (current)') : ''
return `${palette.muted(when)}${marker}\n ${displayText(template.replaceAll('{session}', header.id))}`
await ctx.sessions.flush(agent.session)
if (agent.status !== 'idle') throw new Error(`Resume requires an idle agent (status: ${agent.status}).`)
await overlay.close()
resumeOverlay = undefined
await runtime.terminal.drainInput(100, 20)
ui.stop()
try {
await hostHandoff(checked.record.header.id)
throw new Error('resume host returned without replacing the process')
} catch (error: unknown) {
/* v8 ignore next -- a committed host disposes this TUI and never returns; pre-commit rejection keeps it live */
if (!disposed) {
ui.start()
ui.setFocus(editor)
appendNotice(`Resume handoff failed: ${errorChain(error)}`, 'error')
}
}
} catch (error: unknown) {
/* v8 ignore next -- disposal settles the overlay and suppresses late preflight diagnostics */
if (!disposed) {
await overlay.close()
resumeOverlay = undefined
appendNotice(`Resume failed: ${errorChain(error)}`, 'error')
}
} finally {
resumeInFlight = false
}
}
/** Open the current-workspace searchable session selector. */
const showResume = (): void => {
if (agent.status !== 'idle') {
appendNotice('Resume requires the current turn to finish or be cancelled first.', 'warning')
return
}
if (sessionQuery === undefined) {
appendNotice('Resume is not available: session query is not mounted.', 'warning')
return
}
const scan = ++resumeScan
void resumeOverlay?.close()
void sessionQuery.listSessions().then(async (records) => {
if (isDisposed() || scan !== resumeScan) return
const workspace = records.filter(record => record.header.cwd === agent.session.header.cwd)
const providers = new Set(ctx.llm.listProviders().map(provider => provider.id))
const candidates = await Promise.all(workspace.map(record => readResumeCandidate(record, providers)))
candidates.sort((a, b) => b.lastActivityAt - a.lastActivityAt
|| a.record.header.id.localeCompare(b.record.header.id))
if (isDisposed() || scan !== resumeScan) return
const session = overlayManager.open({
create: () => new ResumeDialog(
candidates,
resolved.maxResumeOptions,
palette,
(candidate) => { void handoffResume(candidate, session) },
() => { void session.close() },
),
options: {
width: resolved.resumeDialogWidth,
maxHeight: resolved.resumeDialogMaxHeight,
anchor: 'center',
margin: 1,
},
})
resumeOverlay = session
void session.closed.then(() => {
/* v8 ignore next -- overlay FIFO closes this session before a replacement can become the tracked resume overlay */
if (resumeOverlay === session) resumeOverlay = undefined
})
chat.addChild(new Text(lines.join('\n'), 1, 0))
requestRender()
}, (error: unknown) => {
if (!disposed && scan === resumeScan) appendNotice(`Resume session scan failed: ${errorChain(error)}`, 'error')
})
}
@@ -2828,6 +3156,14 @@ export function createTuiChat(
}
rebuildTranscript(true)
const restoredGoal = foldGoal(agent.session.events).goal
if (restoredGoal !== undefined && restoredGoal.phase !== 'complete') {
appendNotice(
`Goal restored (${restoredGoal.phase}) with automatic continuation disarmed. `
+ 'Human confirmation is required; send “继续” or run /goal resume.',
'warning',
)
}
setStatus(agent.status)
try {
ui.start()
@@ -2915,9 +3251,11 @@ export function apply(ctx: Context, config: Config): void {
// Truecolor is a terminal capability, so detect it here at the process
// boundary from COLORTERM; an explicit `truecolor` config value still wins.
const truecolor = config.truecolor ?? ['truecolor', '24bit'].includes(process.env.COLORTERM ?? '')
const resumeHost = ctx.get('tuiResumeHost')
mountTui(ctx, Object.assign({}, config, { truecolor }), {
terminal: new ProcessTerminal(),
exit: code => process.exit(code),
...resumeHost === undefined ? {} : { handoffResume: sessionId => resumeHost.handoff(sessionId) },
})
}
/* v8 ignore stop */

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,14 @@ 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'] }>
isLive?(id: ReturnType<typeof SessionId>): Promise<boolean>
}
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 +126,26 @@ 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),
claimLive: () => Promise.resolve({ release: () => Promise.resolve() }),
isLive: persistence.isLive === undefined
? () => Promise.resolve(false)
: (id: ReturnType<typeof SessionId>) => persistence.isLive!(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 +205,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,7 +1,7 @@
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=0 viewportRow=31 bufferRow=31
buffer
0| " DEEPSEEK HARNESS"
style 1-8 fg=bright-blue bold
@@ -10,23 +10,60 @@ buffer
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| "────────────────────────────────────────────────────────────────────────────────────────────"
3| "────────────────────────────────────────────────────────────────────────────────────────────"
style 0-91 dim
10| " "
4| " "
style 1-1 inverse
11| "────────────────────────────────────────────────────────────────────────────────────────────"
5| "────────────────────────────────────────────────────────────────────────────────────────────"
style 0-91 dim
12| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context tools:collapsed"
6| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context tools:collapsed"
style 0-43 dim
style 65-91 dim
13-31| <blank>
7-8| <blank>
9| " ╭ Resume session ──────────────────────────────────────────────────────────────────────╮ "
style 2-89 fg=bright-blue
10| " │ Search: title or session id │ "
style 2-2 fg=bright-blue
style 4-10 fg=bright-black
style 12-30 dim
style 89-89 fg=bright-blue
11| " │ │ "
style 2-2 fg=bright-blue
style 89-89 fg=bright-blue
12| " │ Untitled session │ "
style 2-2 fg=bright-blue
style 4-21 fg=bright-blue bold
style 89-89 fg=bright-blue
13| " │ 2026-07-23T08:00:00.000Z · no completed turn · route unavailable │ "
style 2-2 fg=bright-blue
style 4-69 fg=bright-black
style 89-89 fg=bright-blue
14| " │ current · live · main-session │ "
style 2-2 fg=bright-blue
style 4-34 dim
style 89-89 fg=bright-blue
15| " │ unavailable: current session │ "
style 2-2 fg=bright-blue
style 4-33 fg=yellow
style 89-89 fg=bright-blue
16| " │ Resume selector design │ "
style 2-2 fg=bright-blue
style 89-89 fg=bright-blue
17| " │ 2024-01-01T00:00:08.000Z · turn 1: completed · deepseek/deepseek-v4-pro │ "
style 2-2 fg=bright-blue
style 4-76 fg=bright-black
style 89-89 fg=bright-blue
18| " │ persisted · earlier-session │ "
style 2-2 fg=bright-blue
style 4-32 dim
style 89-89 fg=bright-blue
19| " │ │ "
style 2-2 fg=bright-blue
style 89-89 fg=bright-blue
20| " │ Type to search • ↑/↓ navigate • Enter resume • Esc cancel │ "
style 2-2 fg=bright-blue
style 4-60 dim
style 89-89 fg=bright-blue
21| " ╰──────────────────────────────────────────────────────────────────────────────────────╯ "
style 2-89 fg=bright-blue
22-31| <blank>

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,10 +154,13 @@ describe('TUI config', () => {
maxToolOutputLines: 6,
maxQuestionOptions: 8,
maxModelOptions: 8,
maxResumeOptions: 8,
questionDialogWidth: 200,
questionDialogMaxHeight: 20,
modelDialogWidth: 72,
modelDialogMaxHeight: 20,
resumeDialogWidth: 88,
resumeDialogMaxHeight: 24,
fileSearchMaxResults: 20,
fileSearchMaxEntries: 10_000,
fileSearchExcludedDirectories: ['.git', 'node_modules'],
@@ -169,10 +174,13 @@ describe('TUI config', () => {
maxToolOutputLines: 2,
maxQuestionOptions: 3,
maxModelOptions: 4,
maxResumeOptions: 5,
questionDialogWidth: 60,
questionDialogMaxHeight: 14,
modelDialogWidth: 64,
modelDialogMaxHeight: 16,
resumeDialogWidth: 84,
resumeDialogMaxHeight: 22,
fileSearchMaxResults: 7,
fileSearchMaxEntries: 123,
fileSearchExcludedDirectories: ['.git', 'generated'],
@@ -185,10 +193,13 @@ describe('TUI config', () => {
maxToolOutputLines: 2,
maxQuestionOptions: 3,
maxModelOptions: 4,
maxResumeOptions: 5,
questionDialogWidth: 60,
questionDialogMaxHeight: 14,
modelDialogWidth: 64,
modelDialogMaxHeight: 16,
resumeDialogWidth: 84,
resumeDialogMaxHeight: 22,
fileSearchMaxResults: 7,
fileSearchMaxEntries: 123,
fileSearchExcludedDirectories: ['.git', 'generated'],
@@ -204,6 +215,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 +269,589 @@ 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 cancels without side effects', 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('Search: 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()
expect(result.terminal.output).toContain('Search: title or session id')
result.terminal.send('\r')
await tick()
expect(result.terminal.output).toContain('current session')
result.terminal.send('\x1b')
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/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 when live-lease inspection is unavailable', 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 occupied or corrupt sessions without losing the current TUI', async () => {
const missing = header('missing-route', 10, '/workspace')
const occupied = header('occupied', 20, '/workspace')
const corrupt = header('corrupt', 30, '/workspace')
const result = await setup({
cwd: '/workspace',
config: { resumeCommand: RESUME },
sessionPersistence: {
list: async () => [missing, occupied, corrupt],
isLive: async id => id === occupied.id,
load: async (id) => {
if (id === corrupt.id) throw new Error('checksum mismatch')
return {
meta: id === missing.id ? missing : occupied,
events: resumeEvents(id === missing.id ? 'Missing adapter' : 'Busy session', id === missing.id ? 'absent-provider' : 'deepseek'),
}
},
},
})
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('Busy session')
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('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('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

View File

@@ -20,6 +20,9 @@
{
"path": "../../core/agent-loop"
},
{
"path": "../../goal/goal"
},
{
"path": "../../core/session"
},
@@ -29,6 +32,9 @@
{
"path": "../../session-persistence/session-persistence"
},
{
"path": "../../session-query/session-query"
},
{
"path": "../../session-title/session-title"
},