feat(tui): session resume — /resume command, exit hint, and dsh --resume <id>

Squashes feat/tui-resume-command, fix/tui-resume-desc, and
feat/tui-resume-flag.
This commit is contained in:
Turtle
2026-07-22 10:55:19 +08:00
parent 87899ae161
commit 2a5dfb7d35
10 changed files with 206 additions and 35 deletions

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, resolveConfigPath, type FailLoudProcess,
installFailLoud, loadEnv, parseResumeArg, resolveConfigPath, type FailLoudProcess,
} from '../src/index.ts'
const NAME = 'dsh-test-bin'
@@ -30,6 +30,26 @@ describe('resolveConfigPath', () => {
})
})
describe('parseResumeArg', () => {
it('returns no resume id and passes arguments through when the flag is absent', () => {
expect(parseResumeArg([])).toEqual({ resumeSessionId: undefined, rest: [] })
expect(parseResumeArg(['custom.yml'])).toEqual({ resumeSessionId: undefined, rest: ['custom.yml'] })
})
it('parses the space form, the inline form, and leaves a positional config path in any position', () => {
expect(parseResumeArg(['--resume', 'sess-1'])).toEqual({ resumeSessionId: 'sess-1', rest: [] })
expect(parseResumeArg(['--resume=sess-2'])).toEqual({ resumeSessionId: 'sess-2', rest: [] })
expect(parseResumeArg(['--resume', 'sess-3', 'app.yml'])).toEqual({ resumeSessionId: 'sess-3', rest: ['app.yml'] })
expect(parseResumeArg(['app.yml', '--resume', 'sess-4'])).toEqual({ resumeSessionId: 'sess-4', rest: ['app.yml'] })
})
it('fails loud on a valueless, empty, or repeated flag rather than silently starting fresh', () => {
expect(() => parseResumeArg(['--resume'])).toThrow('--resume requires a session id')
expect(() => parseResumeArg(['--resume='])).toThrow('--resume requires a session id')
expect(() => parseResumeArg(['--resume', 'a', '--resume', 'b'])).toThrow('--resume may be given only once')
})
})
describe('loadEnv', () => {
it('loads variables from .env in the given dir', () => {
const dir = tmp()

View File

@@ -34,13 +34,23 @@
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-llm-retry": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"@deepseek-ai/dsh-session-persistence": "^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",
"@deepseek-ai/dsh-token-meter": "^0.0.1",
"@deepseek-ai/dsh-tools": "^0.0.1",
"@deepseek-ai/dsh-user-interaction": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"peerDependenciesMeta": {
"@deepseek-ai/dsh-session-persistence": {
"optional": true
},
"@deepseek-ai/dsh-skill": {
"optional": true
}
},
"dependencies": {
"@earendil-works/pi-tui": "0.80.7",
"schemastery": "^3.18.0"
@@ -54,7 +64,9 @@
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-llm-retry": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-persistence": "workspace:^",
"@deepseek-ai/dsh-session-title": "workspace:^",
"@deepseek-ai/dsh-skill": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-token-meter": "workspace:^",
"@deepseek-ai/dsh-tool-cordis": "workspace:^",

View File

@@ -0,0 +1,28 @@
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=9 bufferRow=9
buffer
0| <blank>
1| " Snapshot agent ready. "
style 1-21 fg=bright-black
2| <blank>
3| " Resumable sessions "
style 1-18 fg=bright-blue bold
4| " 2024-01-02 03:04 (current) "
style 1-16 fg=bright-black
style 17-26 fg=green
5| " RESUME_SESSION_ID=main-session dsh "
6| " 2024-01-01 00:00 "
style 1-16 fg=bright-black
7| " RESUME_SESSION_ID=earlier-session dsh "
8| "────────────────────────────────────────────────────────────────────────────────────────────"
style 0-91 dim
9| " "
style 1-1 inverse
10| "────────────────────────────────────────────────────────────────────────────────────────────"
style 0-91 dim
11| "deepseek-v4-flash /workspace/project ↑0 ↓0 tools:collapsed"
style 0-43 dim
style 77-91 dim
12-31| <blank>

View File

@@ -7,6 +7,7 @@ import { agentEvents } from '@deepseek-ai/dsh-agent'
import { CallId, type ContentBlock } from '@deepseek-ai/dsh-llm'
import type {} from '@deepseek-ai/dsh-llm-retry'
import type { Session } from '@deepseek-ai/dsh-session'
import { SessionId } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { type ToolDefinition, type ToolResultView } from '@deepseek-ai/dsh-tools'
import * as ToolCordis from '@deepseek-ai/dsh-tool-cordis'
@@ -30,6 +31,7 @@ const CHECKPOINTS = [
'retry-recovered',
'retry-cancelled',
'retry-exhausted',
'banner-gradient',
'code-mode-pending',
'dynamic-workflow-pending',
'cordis-tools-pending',
@@ -45,6 +47,7 @@ const CHECKPOINTS = [
'model-switching',
'errors-and-help',
'disposed-terminal',
'resume-sessions',
] as const
type Checkpoint = typeof CHECKPOINTS[number]
@@ -56,9 +59,23 @@ async function checkpoint(
name: Checkpoint,
terminal: HeadlessTerminal,
options: TerminalSnapshotOptions = {},
bannerGradient = false,
): Promise<void> {
observedCheckpoints.add(name)
expect(terminal.themeViolations(), `${name} must remain theme-agnostic`).toEqual([])
const violations = terminal.themeViolations()
if (bannerGradient) {
// The banner paints its product name in the DeepSeek brand gradient with
// 24-bit foreground codes: the sole sanctioned truecolor. Require it to be
// present and to never leak a background or extended-palette color into the
// otherwise theme-agnostic UI.
expect(violations, `${name} must render the banner gradient`).not.toEqual([])
expect(
violations.every(entry => entry.endsWith('rgb-fg')),
`${name} must confine truecolor to the banner foreground`,
).toBe(true)
} else {
expect(violations, `${name} must remain theme-agnostic`).toEqual([])
}
const snapshot = await terminal.snapshot(options)
const path = join(SNAPSHOTS_DIR, `${name}.expected.txt`)
if (REFRESHING) {
@@ -308,6 +325,12 @@ describe('TUI terminal-state snapshots', () => {
await disposeSnapshot(harness)
})
it('paints the startup banner product name in the DeepSeek brand gradient on truecolor terminals', async () => {
const harness = await setupSnapshot({ config: { truecolor: true } })
await checkpoint('banner-gradient', harness.terminal, {}, true)
await disposeSnapshot(harness)
})
it('pins Code Mode run_code with its production presenter', async () => {
const harness = await setupSnapshot({ configureContext: configureAdvancedTools })
const call = {
@@ -596,6 +619,24 @@ describe('TUI terminal-state snapshots', () => {
await checkpoint('model-switching', harness.terminal, { includeScrollback: true })
await disposeSnapshot(harness)
})
it('lists this workspace\'s resumable sessions with their commands', async () => {
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' },
] },
}, { columns: 92, rows: 32 })
harness.terminal.send('/resume')
harness.terminal.send('\r')
// `/resume` scans persistence asynchronously, so the listing renders a tick
// after submit (the unit suite waits the same way); settle, then flush.
await new Promise(resolve => setTimeout(resolve, 60))
await harness.terminal.flush()
await checkpoint('resume-sessions', harness.terminal, { includeScrollback: true })
await disposeSnapshot(harness)
})
})
afterAll(async () => {

View File

@@ -818,37 +818,6 @@ describe('pi-tui chat lifecycle and transcript', () => {
await dispose(result)
})
it('shows the session cache hit rate in the footer and updates it live', async () => {
// Empty session: no input billed yet, so the cache segment is hidden.
// A cwd without "cache" in it keeps the negative assertion unambiguous.
const empty = await setup({ cwd: '/opt' })
expect(empty.terminal.output).toContain('↑0 ↓0')
expect(empty.terminal.output).not.toContain('cache')
await dispose(empty)
const result = await setup({
beforeMount(session) {
// Cold call: 10 billed input tokens, none served from cache.
appendAssistant(session, [{ type: 'text', text: 'cold' }], { inputTokens: 10, outputTokens: 5 })
},
})
expect(result.terminal.output).toContain('cache 0%')
result.terminal.output = ''
// Warm call lands live: 5 uncached + 30 cache-read + 5 cache-write billed
// input, so 30 of the 50 total prompt tokens are hits → 60%.
appendAssistant(result.session, [{ type: 'text', text: 'warm' }], {
inputTokens: 5,
outputTokens: 5,
cacheReadTokens: 30,
cacheWriteTokens: 5,
})
await tick()
expect(result.terminal.output).toContain('cache 60%')
expect(result.terminal.output).not.toContain('cache 0%')
await dispose(result)
})
it('sends, steers, handles commands, global keys, and disposed-agent input', async () => {
const result = await setup()