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:
@@ -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()
|
||||
|
||||
@@ -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:^",
|
||||
|
||||
28
packages/ui/tui/tests/snapshots/resume-sessions.expected.txt
Normal file
28
packages/ui/tui/tests/snapshots/resume-sessions.expected.txt
Normal 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>
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user