refactor(tui): resume rows fold titles only, timestamp from artifact mtime
Rows no longer read logs for anything but the batch title fold: the activity timestamp is a live session's last in-memory event time or the artifact mtime via the optional sessionPersistence.locate(), falling back to creation time; the last-turn, route, and goal columns are gone. Route availability moves to the Enter-time preflight, which already fully reads and replay-validates the one chosen log. The projectSessions public API this PR had added to session-query is reverted — the change is now confined to the TUI package.
This commit is contained in:
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/ui/tui/README.md
|
||||
README.md: 387c7df29f493650ac74649ab60916436f1a9b12
|
||||
README.zh.md: be3c757d343ac62703fcdec4912c481077e397e3
|
||||
README.md: 7e32af76b3cc7a3d5e99b4a587e66acd31d02496
|
||||
README.zh.md: be1cdcc2775536cf442d3e7cccaf0e93eea65ab9
|
||||
|
||||
@@ -34,9 +34,9 @@ The footer sums the session's reported usage as `↑<uncached input> ↓<output>
|
||||
|
||||
`/resume` opens a full-viewport keyboard selector instead of a centered dialog. The selector opens as soon as the command runs and takes input focus while the session scan is still pending, showing a loading placeholder until the rows arrive; Escape cancels an in-flight scan the same way it cancels the loaded list. Two scopes cover the same candidate set: the current workspace, which it opens on, and all workspaces, which Tab toggles to. The scope line under the search field names the active scope and the count the other holds, and each row in the all-workspaces scope also reports its own workspace. Toggling clears the search and selection so the highlighted row always belongs to the visible list.
|
||||
|
||||
Its focused search field starts immediately after the search glyph and emits pi-tui's cursor marker, so terminal IME composition remains anchored inside the field. Candidates are sorted by last logged activity and searchable by log-backed title or session id, and by workspace label in the all-workspaces scope; each row reports current/live/persisted state, last turn outcome, recent provider/model, and durable goal phase when present. Up/Down and Page Up/Page Down navigate, Enter resumes, Escape clears a non-empty search before a second Escape cancels, and Ctrl+C cancels directly. The current session, a session already live in this runtime, an unreadable log, a session with no recorded workspace to run in, or a session whose logged provider has no current adapter remains visible but disabled; a workspace other than the current one is a scope rather than a disabled reason, because resume enters that directory.
|
||||
Its focused search field starts immediately after the search glyph and emits pi-tui's cursor marker, so terminal IME composition remains anchored inside the field. Rows fold nothing but each log's title (one bounded batch read): candidates are sorted by metadata activity — a live session's last in-memory event time, otherwise the persisted artifact's mtime, falling back to creation time — and searchable by title or session id, and by workspace label in the all-workspaces scope; each row reports that timestamp plus current/live/persisted state and the id. Up/Down and Page Up/Page Down navigate, Enter resumes, Escape clears a non-empty search before a second Escape cancels, and Ctrl+C cancels directly. The current session, a session already live in this runtime, an unreadable log, or a session with no recorded workspace to run in remains visible but disabled; a workspace other than the current one is a scope rather than a disabled reason, because resume enters that directory.
|
||||
|
||||
Selection repeats those checks and requires the current agent to be idle before flushing the current session. The TUI then stops the terminal UI and calls the optional host-owned `TuiRuntime.handoffResume` with the selected id and the workspace re-read at preflight: process cwd, not the restored session header, is what filesystem and shell tools resolve against, so the host must enter that directory. Where `process.execve` is available, the shipped `dsh` host chdirs into it before disposing the app and replacing its process, and rejects an unreachable directory while the terminal can still be restored. 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`.
|
||||
Selection repeats those checks, fully reads and replay-validates the one chosen log, rejects it when its logged provider has no current adapter, and requires the current agent to be idle before flushing the current session. The TUI then stops the terminal UI and calls the optional host-owned `TuiRuntime.handoffResume` with the selected id and the workspace re-read at preflight: process cwd, not the restored session header, is what filesystem and shell tools resolve against, so the host must enter that directory. Where `process.execve` is available, the shipped `dsh` host chdirs into it before disposing the app and replacing its process, and rejects an unreachable directory while the terminal can still be restored. 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`.
|
||||
|
||||
The exit line is launcher-owned, not configurable. A launcher provides `TUI_GOODBYE_MESSAGE_KEY` on the boot context — for the shipped `dsh`, the command that resumes this session — and exiting prints it verbatim after the terminal is released; absent, exiting prints nothing. Only the launcher knows how it was invoked, so only it can name a command that works. The TUI escapes terminal controls before rendering and never executes the text. A launcher that also supplies `MAIN_SESSION_ID_KEY` fixes which session the mounted app binds to, so resume survives any config-level patch.
|
||||
|
||||
|
||||
@@ -34,9 +34,9 @@ Footer 将会话报告的用量汇总为 `↑<uncached input> ↓<output>`;任
|
||||
|
||||
`/resume` 会打开全 viewport 键盘选择器,而非居中对话框。选择器在命令执行时立即打开并接管输入焦点,会话扫描仍在进行时显示加载占位符,直到行数据就绪;Escape 取消进行中的扫描,方式与取消已加载列表相同。两个作用域覆盖同一候选项集合:打开时所处的当前工作区,以及按 Tab 切换到的所有工作区。搜索字段下方的作用域行会给出当前作用域的名称以及另一个作用域包含的数量,且在所有工作区作用域中每行还会报告自身所属的工作区。切换会清除搜索与选择,使高亮行始终属于可见列表。
|
||||
|
||||
获得焦点的搜索字段紧跟搜索 glyph 开始,并发出 pi-tui 的 cursor marker,使终端 IME 组合保持锚定在字段内。候选项按最近记录的活动排序,可按日志支持的标题或会话 id 搜索,在所有工作区作用域中还可按工作区标签搜索;每行报告 current/live/persisted 状态、上一轮次结果、近期提供方/模型,以及存在时的持久目标阶段。Up/Down 与 Page Up/Page Down 导航,Enter 恢复,Escape 会先清除非空搜索,再次按下才取消,Ctrl+C 则直接取消。当前会话、已在本运行时中活跃的会话、不可读日志、没有可运行的已记录工作区的会话,或日志所记提供方没有当前适配器的会话仍会显示,但不可选择;不同于当前工作区的工作区属于作用域而非禁用原因,因为恢复会进入该目录。
|
||||
获得焦点的搜索字段紧跟搜索 glyph 开始,并发出 pi-tui 的 cursor marker,使终端 IME 组合保持锚定在字段内。行数据除每份日志的标题(一次有界批量读取)外不折叠任何内容:候选项按元数据活动时间排序——实时会话取内存中最后一个事件的时间,否则取持久化产物的 mtime,再回退到创建时间——可按标题或会话 id 搜索,在所有工作区作用域中还可按工作区标签搜索;每行报告该时间戳、current/live/persisted 状态和 id。Up/Down 与 Page Up/Page Down 导航,Enter 恢复,Escape 会先清除非空搜索,再次按下才取消,Ctrl+C 则直接取消。当前会话、已在本运行时中活跃的会话、不可读日志,或没有可运行的已记录工作区的会话仍会显示,但不可选择;不同于当前工作区的工作区属于作用域而非禁用原因,因为恢复会进入该目录。
|
||||
|
||||
选择时会重复这些检查,并要求当前 agent 空闲,随后 flush 当前会话。TUI 接着停止终端 UI,并以所选 id 和在预检时重新读取的工作区调用由宿主持有的可选 `TuiRuntime.handoffResume`:文件系统与 shell 工具解析所依据的是进程 cwd,而非恢复出的会话头部,因此宿主必须进入该目录。存在 `process.execve` 时,发布的 `dsh` 宿主会先 chdir 进入该目录,再对 app 执行 dispose 并替换自身进程,并在终端仍可恢复时拒绝不可达的目录。恢复操作保留相同的 `SessionId`、transcript、标题、todo 和持久目标;目标激活仍保持解除,TUI 会要求用户确认或执行 `/goal resume`。
|
||||
选择时会重复这些检查,完整读取并回放验证所选中的那一份日志,在其日志所记提供方没有当前适配器时拒绝,并要求当前 agent 空闲,随后 flush 当前会话。TUI 接着停止终端 UI,并以所选 id 和在预检时重新读取的工作区调用由宿主持有的可选 `TuiRuntime.handoffResume`:文件系统与 shell 工具解析所依据的是进程 cwd,而非恢复出的会话头部,因此宿主必须进入该目录。存在 `process.execve` 时,发布的 `dsh` 宿主会先 chdir 进入该目录,再对 app 执行 dispose 并替换自身进程,并在终端仍可恢复时拒绝不可达的目录。恢复操作保留相同的 `SessionId`、transcript、标题、todo 和持久目标;目标激活仍保持解除,TUI 会要求用户确认或执行 `/goal resume`。
|
||||
|
||||
退出时打印的行由启动器拥有,不可通过配置指定。启动器在启动上下文上提供 `TUI_GOODBYE_MESSAGE_KEY`(对于随附的 `dsh`,即恢复本会话的命令),释放终端后退出会原样打印它;未提供时退出不打印任何内容。只有启动器知道自己是如何被调用的,因此只有它能给出可用的命令。TUI 在渲染前会转义终端控制字符,且绝不执行该文本。若启动器同时提供 `MAIN_SESSION_ID_KEY`,则会固定已挂载应用绑定的会话,因此恢复功能不受配置层修补影响。
|
||||
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
/**
|
||||
* Session-resume sub-controller for the interactive chat channel: the
|
||||
* `/resume` selector, one batch summary projection that tolerates a corrupt
|
||||
* `/resume` selector, one metadata-plus-title scan that tolerates a corrupt
|
||||
* neighbor, the pre-handoff preflight, and the terminal handoff itself.
|
||||
* @module @deepseek-ai/dsh-tui/chat/resume
|
||||
*/
|
||||
|
||||
import { stat } from 'node:fs/promises'
|
||||
import type { TUI } from '@earendil-works/pi-tui'
|
||||
import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent'
|
||||
import { errorChain } from '@deepseek-ai/dsh-llm'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type {} from '@deepseek-ai/dsh-session-persistence'
|
||||
import type {
|
||||
LogicalSessionSource,
|
||||
SessionQueryService,
|
||||
SessionRecord,
|
||||
} from '@deepseek-ai/dsh-session-query'
|
||||
@@ -66,51 +67,70 @@ export function createResumeController(deps: ResumeControllerDeps): ResumeContro
|
||||
const workspaceLabel = (cwd: string | undefined): string =>
|
||||
runtime.formatCwd?.(cwd) ?? formatCwd(cwd)
|
||||
|
||||
/** Summarize one record from a borrowed source, retaining only the record and derived scalars. */
|
||||
/** Summarize one record from metadata and its batch-folded title. */
|
||||
const summarize = (
|
||||
record: SessionRecord,
|
||||
source: LogicalSessionSource,
|
||||
providers: ReadonlySet<string>,
|
||||
title: string | undefined,
|
||||
lastActivityAt: number | undefined,
|
||||
): ResumeCandidate => summarizeResumeCandidate(
|
||||
record,
|
||||
source,
|
||||
title,
|
||||
lastActivityAt,
|
||||
agent.session.id,
|
||||
agent.session.header.cwd,
|
||||
providers,
|
||||
workspaceLabel,
|
||||
)
|
||||
|
||||
/** The disabled fallback row for a session whose log cannot be summarized. */
|
||||
const unreadableCandidate = (record: SessionRecord, error: unknown): ResumeCandidate => ({
|
||||
/** The disabled fallback row for a session whose title read failed. */
|
||||
const unreadableCandidate = (
|
||||
record: SessionRecord,
|
||||
lastActivityAt: number | undefined,
|
||||
error: unknown,
|
||||
): ResumeCandidate => ({
|
||||
record,
|
||||
title: 'Unreadable session',
|
||||
lastActivityAt: record.header.createdAt,
|
||||
lastTurn: 'log unavailable',
|
||||
lastActivityAt: lastActivityAt ?? record.header.createdAt,
|
||||
currentWorkspace: record.header.cwd === agent.session.header.cwd,
|
||||
workspaceLabel: workspaceLabel(record.header.cwd),
|
||||
disabledReason: `session cannot be loaded: ${errorChain(error)}`,
|
||||
})
|
||||
|
||||
/** Build one exact candidate from a live-preferred read that replay-validates a persisted log. */
|
||||
const readResumeCandidate = async (
|
||||
record: SessionRecord,
|
||||
providers: ReadonlySet<string>,
|
||||
): Promise<ResumeCandidate> => {
|
||||
/**
|
||||
* Metadata-only activity time: a live session's last in-memory event time,
|
||||
* otherwise the persisted artifact's mtime. Never reads a log, so browsing
|
||||
* cost stays independent of log size; any append (including bookkeeping)
|
||||
* moves it.
|
||||
*/
|
||||
const lastActivityAt = async (record: SessionRecord): Promise<number | undefined> => {
|
||||
const live = ctx.sessions.get(record.header.id)
|
||||
if (live !== undefined) return live.events.at(-1)?.time
|
||||
const location = ctx.get('sessionPersistence')?.locate(record.header)
|
||||
if (location === undefined) return undefined
|
||||
try {
|
||||
const readQuery = sessionQuery()
|
||||
/* v8 ignore start -- caller proves the optional service before mapping records */
|
||||
if (readQuery === undefined) throw new Error('session query is unavailable')
|
||||
/* v8 ignore stop */
|
||||
const snapshot = await readQuery.readSession(record.header.id)
|
||||
return summarize(record, { header: snapshot.session, events: snapshot.events }, providers)
|
||||
} catch (error: unknown) {
|
||||
return unreadableCandidate(record, error)
|
||||
return (await stat(location.path)).mtimeMs
|
||||
} catch {
|
||||
// Only a just-deleted or never-materialized artifact fails stat; the row falls back to created-at.
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/** The latest logged provider/model route, for the preflight availability check. */
|
||||
const resumeRoute = (events: readonly SessionEvent[]): { provider: string; model: string } | undefined => {
|
||||
const header = 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 = events.findLast(item => item.type === 'assistant/message')
|
||||
return assistant?.type === 'assistant/message'
|
||||
? { provider: assistant.data.message.source.provider, model: assistant.data.message.source.model }
|
||||
: undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-read every mutable precondition immediately before terminal handoff and
|
||||
* resolve the exact identity and workspace the host will re-exec into.
|
||||
* resolve the exact identity and workspace the host will re-exec into. This
|
||||
* is where the one chosen log is fully read, replay-validated, and checked
|
||||
* for a currently-available route — the listing never does any of that.
|
||||
*/
|
||||
const preflightResume = async (sessionId: SessionId): Promise<{ id: SessionId; cwd: string }> => {
|
||||
const query = sessionQuery()
|
||||
@@ -121,17 +141,24 @@ export function createResumeController(deps: ResumeControllerDeps): ResumeContro
|
||||
if (initialStatus !== 'idle') throw new Error(`Resume requires an idle agent (status: ${initialStatus}).`)
|
||||
const record = (await query.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)),
|
||||
)
|
||||
const candidate = summarize(record, undefined, undefined)
|
||||
if (candidate.disabledReason !== undefined) throw new Error(candidate.disabledReason)
|
||||
const cwd = candidate.record.header.cwd
|
||||
let events: readonly SessionEvent[]
|
||||
try {
|
||||
events = (await query.readSession(record.header.id)).events
|
||||
} catch (error: unknown) {
|
||||
throw new Error(`session cannot be loaded: ${errorChain(error)}`)
|
||||
}
|
||||
const route = resumeRoute(events)
|
||||
if (route !== undefined && !ctx.llm.listProviders().some(provider => provider.id === route.provider)) {
|
||||
throw new Error(`session is complete, but route is currently unavailable (${route.provider}/${route.model})`)
|
||||
}
|
||||
const cwd = record.header.cwd
|
||||
/* v8 ignore next -- summarizeResumeCandidate disables a cwd-less record, so the check above already rejected it */
|
||||
if (cwd === undefined) throw new Error(`Session "${sessionId}" has no recorded workspace to resume in.`)
|
||||
const finalStatus = deps.agentStatus()
|
||||
if (finalStatus !== 'idle') throw new Error(`Resume requires an idle agent (status: ${finalStatus}).`)
|
||||
return { id: candidate.record.header.id, cwd }
|
||||
return { id: record.header.id, cwd }
|
||||
}
|
||||
|
||||
const handoffResume = async (candidate: ResumeCandidate, overlay: TuiOverlaySession): Promise<void> => {
|
||||
@@ -235,30 +262,25 @@ export function createResumeController(deps: ResumeControllerDeps): ResumeContro
|
||||
const scanStale = (): boolean =>
|
||||
deps.isDisposed() || scan !== resumeScan || scanAbort.signal.aborted
|
||||
const scanCandidates = async (): Promise<void> => {
|
||||
// Every workspace in the store is listed; the picker owns the
|
||||
// current-workspace/all-workspaces scope split over the whole set.
|
||||
const records = await listQuery.listSessions(scanAbort.signal)
|
||||
if (scanStale()) return
|
||||
// Every workspace in the store is summarized; the picker owns the
|
||||
// current-workspace/all-workspaces scope split over the whole set.
|
||||
const providers = new Set(ctx.llm.listProviders().map(provider => provider.id))
|
||||
// One bounded batch projection over borrowed logs: unlike a
|
||||
// per-candidate readSession, it lists persistence once and skips
|
||||
// replay validation and log cloning, bounding memory by what each
|
||||
// summary retains. A corrupt neighbor degrades to one disabled row.
|
||||
const recordById = new Map(records.map(record => [record.header.id, record]))
|
||||
const listedRecord = (id: SessionId): SessionRecord => {
|
||||
const record = recordById.get(id)
|
||||
/* v8 ignore next 2 -- projection ids come from this map; the corpus verifies each loaded header id */
|
||||
if (record === undefined) throw new Error(`resume scan returned unlisted session "${id}"`)
|
||||
return record
|
||||
}
|
||||
const results = await listQuery.projectSessions(
|
||||
records.map(record => record.header.id),
|
||||
source => summarize(listedRecord(source.header.id), source, providers),
|
||||
scanAbort.signal,
|
||||
)
|
||||
const candidates = results.map(result => result.status === 'fulfilled'
|
||||
? result.value
|
||||
: unreadableCandidate(listedRecord(result.sessionId), result.reason))
|
||||
// Rows need only metadata, an mtime, and the batch-folded title — the
|
||||
// one per-log read the selector performs. A corrupt neighbor degrades
|
||||
// to one disabled row.
|
||||
const [titles, activity] = await Promise.all([
|
||||
listQuery.readTitleSnapshots(records.map(record => record.header.id), scanAbort.signal),
|
||||
Promise.all(records.map(record => lastActivityAt(record))),
|
||||
])
|
||||
const candidates = records.map((record, index) => {
|
||||
const title = titles[index]
|
||||
/* v8 ignore next 2 -- readTitleSnapshots returns one result per unique listed id in input order */
|
||||
if (title === undefined || title.sessionId !== record.header.id) throw new Error(`resume scan misaligned at "${record.header.id}"`)
|
||||
return title.status === 'fulfilled'
|
||||
? summarize(record, title.value.title?.title, activity[index])
|
||||
: unreadableCandidate(record, activity[index], title.reason)
|
||||
})
|
||||
candidates.sort((a, b) => b.lastActivityAt - a.lastActivityAt
|
||||
|| a.record.header.id.localeCompare(b.record.header.id))
|
||||
if (scanStale()) return
|
||||
@@ -266,9 +288,10 @@ export function createResumeController(deps: ResumeControllerDeps): ResumeContro
|
||||
picker?.setCandidates(candidates)
|
||||
deps.requestRender()
|
||||
}
|
||||
// One catch covers both stages, so a projection failure cannot strand
|
||||
// the overlay on its loading placeholder; an aborted scan's rejection
|
||||
// stays silent because the user already dismissed the picker.
|
||||
// One catch covers listing, titles, and mtimes, so a scan failure
|
||||
// cannot strand the overlay on its loading placeholder; an aborted
|
||||
// scan's rejection stays silent because the user already dismissed the
|
||||
// picker.
|
||||
void scanCandidates().catch((error: unknown) => {
|
||||
if (scanStale()) return
|
||||
void session.close()
|
||||
|
||||
@@ -23,14 +23,8 @@ import {
|
||||
type AgentLlmTarget,
|
||||
} from '@deepseek-ai/dsh-agent'
|
||||
import type { LlmModelInfo, LlmModelReasoningInfo, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
|
||||
import { lastActivityTime } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { foldGoal, type GoalPhase } from '@deepseek-ai/dsh-goal'
|
||||
import { foldSessionTitle } from '@deepseek-ai/dsh-session-title'
|
||||
import type {
|
||||
LogicalSessionSource,
|
||||
SessionRecord,
|
||||
} from '@deepseek-ai/dsh-session-query'
|
||||
import type { SessionRecord } from '@deepseek-ai/dsh-session-query'
|
||||
import type { AskUserQuestionItem } from '@deepseek-ai/dsh-user-interaction'
|
||||
import { BRACKETED_PASTE_END, BRACKETED_PASTE_START, displayText, sanitizePastedText } from './text.ts'
|
||||
import { dialogSelectTheme, type Palette } from './theme.ts'
|
||||
@@ -432,97 +426,53 @@ export class ModelDialog implements Component {
|
||||
}
|
||||
}
|
||||
|
||||
/** The provider/model route recovered from a resume candidate's log. */
|
||||
export interface ResumeRoute {
|
||||
provider: string
|
||||
model: string
|
||||
}
|
||||
|
||||
/** A preflighted resume selector row summarizing one persisted session. */
|
||||
/** A resume selector row summarizing one session from metadata and its folded title. */
|
||||
export interface ResumeCandidate {
|
||||
record: SessionRecord
|
||||
title: string
|
||||
/** Last observed change: live last-event time or artifact mtime, falling back to creation. */
|
||||
lastActivityAt: number
|
||||
lastTurn: string
|
||||
/** Whether the session's workspace is the one the current session runs in, which selects the picker scope that lists it. */
|
||||
currentWorkspace: boolean
|
||||
/** The session's own workspace as a prompt-style label; the all-workspaces scope shows it per row. */
|
||||
workspaceLabel: string
|
||||
route?: ResumeRoute
|
||||
goalPhase?: GoalPhase
|
||||
disabledReason?: string
|
||||
}
|
||||
|
||||
function resumeTurnLabel(source: LogicalSessionSource): string {
|
||||
const event = source.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 'interrupted': return `turn ${event.data.turn}: interrupted`
|
||||
default: return `turn ${event.data.turn}: unknown result`
|
||||
}
|
||||
}
|
||||
|
||||
function resumeRoute(source: LogicalSessionSource): ResumeRoute | undefined {
|
||||
const header = source.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 = source.events.findLast(item => item.type === 'assistant/message')
|
||||
return assistant?.type === 'assistant/message'
|
||||
? { provider: assistant.data.message.source.provider, model: assistant.data.message.source.model }
|
||||
: undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Build one resume selector row from a record and its borrowed log source,
|
||||
* deriving the title, route, goal phase, workspace scope, and any reason the
|
||||
* session cannot be resumed here. A workspace other than the current one is a
|
||||
* scope, not a disabled reason: resuming it hands the process off into that
|
||||
* directory. The result retains only the record and derived scalars, so a
|
||||
* borrowed source stays valid for exactly this call.
|
||||
* Build one resume selector row from a record, its batch-folded title, and a
|
||||
* metadata-derived activity time, deriving the workspace scope and any reason
|
||||
* the session cannot be resumed here. A workspace other than the current one
|
||||
* is a scope, not a disabled reason: resuming it hands the process off into
|
||||
* that directory. Rows carry no per-log detail beyond the title — route and
|
||||
* replay validity are checked by the Enter-time preflight against the one
|
||||
* chosen log.
|
||||
* @param record - The session record.
|
||||
* @param source - The session's borrowed header and raw event log.
|
||||
* @param title - The session's batch-folded title, absent for an untitled log.
|
||||
* @param lastActivityAt - Metadata activity time; absent falls back to the header's creation time.
|
||||
* @param currentId - The current session id.
|
||||
* @param cwd - The CURRENT session's workspace, which decides the picker scope this row falls in.
|
||||
* @param availableProviders - Providers registered in this runtime.
|
||||
* @param formatWorkspace - Renders THIS record's own cwd as its prompt-style label.
|
||||
* @returns The summarized resume candidate.
|
||||
*/
|
||||
export function summarizeResumeCandidate(
|
||||
record: SessionRecord,
|
||||
source: LogicalSessionSource,
|
||||
title: string | undefined,
|
||||
lastActivityAt: number | undefined,
|
||||
currentId: SessionId,
|
||||
cwd: string | undefined,
|
||||
availableProviders: ReadonlySet<string>,
|
||||
formatWorkspace: (cwd: string | undefined) => string,
|
||||
): ResumeCandidate {
|
||||
const title = foldSessionTitle(source.events)?.title ?? 'Untitled session'
|
||||
const route = resumeRoute(source)
|
||||
const foldedGoal = foldGoal(source.events).goal
|
||||
let disabledReason: string | undefined
|
||||
if (record.header.id === currentId) disabledReason = 'current session'
|
||||
else if (record.live) disabledReason = 'session is already live in this runtime'
|
||||
else if (record.header.cwd === undefined) disabledReason = 'session has no recorded workspace'
|
||||
else if (route !== undefined && !availableProviders.has(route.provider)) {
|
||||
disabledReason = `session is complete, but route is currently unavailable (${route.provider}/${route.model})`
|
||||
}
|
||||
return {
|
||||
record,
|
||||
title,
|
||||
// Excludes a prior pickup's boundary, or every browsed session floats up.
|
||||
lastActivityAt: lastActivityTime(source.events) ?? source.header.createdAt,
|
||||
lastTurn: resumeTurnLabel(source),
|
||||
title: title ?? 'Untitled session',
|
||||
lastActivityAt: lastActivityAt ?? record.header.createdAt,
|
||||
currentWorkspace: record.header.cwd === cwd,
|
||||
workspaceLabel: formatWorkspace(record.header.cwd),
|
||||
...route === undefined ? {} : { route },
|
||||
/* v8 ignore next -- goal-bearing resume records are covered by the goal/session integration surface. */
|
||||
...foldedGoal === undefined ? {} : { goalPhase: foldedGoal.phase },
|
||||
...disabledReason === undefined ? {} : { disabledReason },
|
||||
}
|
||||
}
|
||||
@@ -601,7 +551,7 @@ export class ResumePicker implements Component, Focusable {
|
||||
private visibleCandidateCount(): number {
|
||||
// The all-workspaces scope adds a per-row workspace line, so a row costs
|
||||
// one more terminal row there than in the single-workspace scope.
|
||||
const rowHeight = this.scope === 'all' ? 5 : 4
|
||||
const rowHeight = this.scope === 'all' ? 4 : 3
|
||||
const candidateBudget = Math.max(1, Math.floor((Math.max(1, this.viewportRows()) - 13) / rowHeight))
|
||||
return Math.min(this.maxVisible, candidateBudget)
|
||||
}
|
||||
@@ -748,11 +698,7 @@ export class ResumePicker implements Component, Focusable {
|
||||
].filter((value): value is string => value !== undefined).join(' · ')
|
||||
const lead = `${active ? '❯' : ' '} ${displayText(candidate.title)}`
|
||||
push(active ? this.palette.bold(this.palette.accent(lead)) : lead)
|
||||
const route = candidate.route === undefined ? 'route unavailable' : `${candidate.route.provider}/${candidate.route.model}`
|
||||
/* v8 ignore next -- only goal-bearing resume records add this integration-owned suffix. */
|
||||
const goal = candidate.goalPhase === undefined ? '' : ` · goal ${candidate.goalPhase}`
|
||||
push(this.palette.dim(` ${new Date(candidate.lastActivityAt).toISOString()} · ${candidate.lastTurn} · ${route}${goal}`))
|
||||
push(this.palette.dim(` ${status} · ${displayText(candidate.record.header.id)}`))
|
||||
push(this.palette.dim(` ${new Date(candidate.lastActivityAt).toISOString()} · ${status} · ${displayText(candidate.record.header.id)}`))
|
||||
// Only the all-workspaces scope mixes directories, so the per-row
|
||||
// workspace is redundant in the scope that already names one.
|
||||
if (this.scope === 'all') {
|
||||
|
||||
@@ -69,6 +69,8 @@ export interface TuiHarnessOptions {
|
||||
sessionPersistence?: {
|
||||
list(): Promise<SessionHeader[]>
|
||||
load?(id: ReturnType<typeof SessionId>): Promise<{ meta: SessionHeader; events: Session['events'] }>
|
||||
/** Per-session artifact location for mtime-based activity; defaults to none. */
|
||||
locate?(meta: SessionHeader): { kind: string; path: string } | undefined
|
||||
}
|
||||
handoffResume?: TuiRuntime['handoffResume']
|
||||
/** Host-supplied exit line; absent exercises the no-message path. */
|
||||
@@ -156,7 +158,7 @@ export async function createTuiTestHarness<TerminalType extends Terminal, Exit e
|
||||
const persistence = options.sessionPersistence
|
||||
ctx.provide('sessionPersistence', {
|
||||
...persistence,
|
||||
locate: () => undefined,
|
||||
locate: (meta: SessionHeader) => persistence.locate?.(meta),
|
||||
create: () => Promise.resolve(),
|
||||
append: () => Promise.resolve(),
|
||||
load: persistence.load === undefined
|
||||
|
||||
@@ -22,28 +22,25 @@ buffer
|
||||
8| " "
|
||||
9| " ❯ Untitled session "
|
||||
style 2-19 fg=bright-magenta bold
|
||||
10| " 2026-07-23T08:00:00.000Z · no completed turn · route unavailable "
|
||||
style 2-67 dim
|
||||
11| " current · live · main-session "
|
||||
style 2-32 dim
|
||||
12| " workspace /workspace/project "
|
||||
10| " 2026-07-23T08:00:00.000Z · current · live · main-session "
|
||||
style 2-59 dim
|
||||
11| " workspace /workspace/project "
|
||||
style 2-31 dim
|
||||
13| " unavailable: current session "
|
||||
12| " unavailable: current session "
|
||||
style 2-31 fg=yellow
|
||||
14| " Other workspace work "
|
||||
15| " 2024-02-02T00:00:08.000Z · turn 1: completed · deepseek-official/deepseek-v4-pro "
|
||||
style 2-83 dim
|
||||
16| " persisted · elsewhere-session "
|
||||
style 2-32 dim
|
||||
17| " workspace /workspace/other "
|
||||
13| " Other workspace work "
|
||||
14| " 2024-02-02T00:00:00.000Z · persisted · elsewhere-session "
|
||||
style 2-59 dim
|
||||
15| " workspace /workspace/other "
|
||||
style 2-29 dim
|
||||
18| " Resume selector design "
|
||||
19| " 2024-01-01T00:00:08.000Z · turn 1: completed · deepseek-official/deepseek-v4-pro "
|
||||
style 2-83 dim
|
||||
20| " persisted · earlier-session "
|
||||
style 2-30 dim
|
||||
21| " workspace /workspace/project "
|
||||
16| " Resume selector design "
|
||||
17| " 2024-01-01T00:00:00.000Z · persisted · earlier-session "
|
||||
style 2-57 dim
|
||||
18| " workspace /workspace/project "
|
||||
style 2-31 dim
|
||||
19| " "
|
||||
20| " "
|
||||
21| " "
|
||||
22| " "
|
||||
23| " "
|
||||
24| " "
|
||||
|
||||
@@ -22,17 +22,15 @@ buffer
|
||||
8| " "
|
||||
9| " ❯ Untitled session "
|
||||
style 2-19 fg=bright-magenta bold
|
||||
10| " 2026-07-23T08:00:00.000Z · no completed turn · route unavailable "
|
||||
style 2-67 dim
|
||||
11| " current · live · main-session "
|
||||
style 2-32 dim
|
||||
12| " unavailable: current session "
|
||||
10| " 2026-07-23T08:00:00.000Z · current · live · main-session "
|
||||
style 2-59 dim
|
||||
11| " unavailable: current session "
|
||||
style 2-31 fg=yellow
|
||||
13| " Resume selector design "
|
||||
14| " 2024-01-01T00:00:08.000Z · turn 1: completed · deepseek-official/deepseek-v4-pro "
|
||||
style 2-83 dim
|
||||
15| " persisted · earlier-session "
|
||||
style 2-30 dim
|
||||
12| " Resume selector design "
|
||||
13| " 2024-01-01T00:00:00.000Z · persisted · earlier-session "
|
||||
style 2-57 dim
|
||||
14| " "
|
||||
15| " "
|
||||
16| " "
|
||||
17| " "
|
||||
18| " "
|
||||
|
||||
@@ -879,12 +879,12 @@ describe('TUI terminal-state snapshots', () => {
|
||||
{ type: 'step/end', seq: 5, time: Date.parse(`${day}T00:00:06Z`), data: { turn: 1, step: 1 } },
|
||||
{ type: 'turn/end', seq: 6, time: Date.parse(`${day}T00:00:07Z`), data: { turn: 1, reason: { kind: 'completed' } } },
|
||||
{ type: 'session/title', seq: 7, time: Date.parse(`${day}T00:00:08Z`), data: { title, messageSeqs: [1], source: { kind: 'fallback' } } },
|
||||
// A prior pickup, dated well after the work: the picker must still
|
||||
// show the work's date, not the pickup's.
|
||||
{ type: 'session/end-seed', seq: 8, time: Date.parse('2026-07-23T07:59:00.000Z'), data: {} },
|
||||
],
|
||||
})
|
||||
const listGate = Promise.withResolvers<undefined>()
|
||||
// Rows show metadata activity (here the created-at fallback: the fake
|
||||
// store locates no per-session artifact to stat) plus each log's one
|
||||
// batch-folded title; nothing else is read from the logs.
|
||||
const harness = await setupSnapshot({
|
||||
sessionPersistence: {
|
||||
list: async () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { mkdir, mkdtemp, rm, utimes, writeFile } from 'node:fs/promises'
|
||||
import { homedir, tmpdir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
@@ -280,16 +280,19 @@ describe('goodbye message and /resume', () => {
|
||||
{ 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' } } },
|
||||
]
|
||||
/** Derive the selector's batch projection from a fake per-session readSession. */
|
||||
const projectViaReadSession = (
|
||||
/** Derive the selector's batch title read from a fake per-session readSession. */
|
||||
const titlesViaReadSession = (
|
||||
readSession: (id: SessionId) => Promise<{ session: SessionHeader; events: SessionEvent[] }>,
|
||||
) => (
|
||||
ids: readonly SessionId[],
|
||||
project: (source: { header: SessionHeader; events: readonly SessionEvent[] }) => unknown,
|
||||
) => Promise.all(ids.map(async (sessionId) => {
|
||||
) => (ids: readonly SessionId[]) => Promise.all(ids.map(async (sessionId) => {
|
||||
try {
|
||||
const snapshot = await readSession(sessionId)
|
||||
return { sessionId, status: 'fulfilled', value: project({ header: snapshot.session, events: snapshot.events }) }
|
||||
const titleEvent = snapshot.events.findLast(event => event.type === 'session/title')
|
||||
const title = titleEvent?.type === 'session/title' ? { title: titleEvent.data.title } : undefined
|
||||
return {
|
||||
sessionId,
|
||||
status: 'fulfilled',
|
||||
value: { session: snapshot.session, ...title === undefined ? {} : { title } },
|
||||
}
|
||||
} catch (reason) {
|
||||
return { sessionId, status: 'rejected', reason }
|
||||
}
|
||||
@@ -451,7 +454,7 @@ describe('goodbye message and /resume', () => {
|
||||
result.terminal.send('\x1b[6~')
|
||||
await tick()
|
||||
const rendered = result.terminal.output.slice(result.terminal.output.lastIndexOf('Resume session'))
|
||||
expect(rendered).toContain('❯ Paged 3')
|
||||
expect(rendered).toContain('❯ Paged 5')
|
||||
result.terminal.send('\x1b[5~')
|
||||
await tick()
|
||||
expect(result.terminal.output.slice(result.terminal.output.lastIndexOf('Resume session')))
|
||||
@@ -484,26 +487,53 @@ describe('goodbye message and /resume', () => {
|
||||
await dispose(result)
|
||||
})
|
||||
|
||||
it.each([
|
||||
[{ kind: 'aborted' }, 'cancelled'],
|
||||
[{ kind: 'error', step: 1, message: 'failed' }, 'error'],
|
||||
[{ kind: 'disposed' }, 'disposed'],
|
||||
[{ kind: 'max-tokens' }, 'max tokens'],
|
||||
[{ kind: 'interrupted' }, 'interrupted'],
|
||||
[{ kind: 'future-result' } as unknown as TurnEndReason, 'unknown result'],
|
||||
] as const)('renders the last turn result %s', async (reason, label) => {
|
||||
const target = header(`turn-${label}`, 10, '/workspace')
|
||||
it('orders rows by artifact mtime without reading logs for the timestamp', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'dsh-resume-mtime-'))
|
||||
const stale = join(dir, 'stale.log')
|
||||
const fresh = join(dir, 'fresh.log')
|
||||
await writeFile(stale, 'x')
|
||||
await writeFile(fresh, 'x')
|
||||
await utimes(stale, new Date(1000), new Date(60_000))
|
||||
await utimes(fresh, new Date(1000), new Date(120_000))
|
||||
// Creation order contradicts mtime order, so the sort proves its source.
|
||||
const createdLate = header('created-late-touched-early', 50, '/workspace')
|
||||
const createdEarly = header('created-early-touched-late', 40, '/workspace')
|
||||
const gone = header('artifact-gone', 30, '/workspace')
|
||||
const goneTwin = header('artifact-gone-twin', 30, '/workspace')
|
||||
const paths = new Map([
|
||||
[createdLate.id, stale],
|
||||
[createdEarly.id, fresh],
|
||||
[gone.id, join(dir, 'missing.log')],
|
||||
[goneTwin.id, join(dir, 'missing-twin.log')],
|
||||
])
|
||||
const titles = new Map([
|
||||
[createdLate.id, 'Touched early'],
|
||||
[createdEarly.id, 'Touched late'],
|
||||
[gone.id, 'Artifact gone'],
|
||||
[goneTwin.id, 'Artifact gone twin'],
|
||||
])
|
||||
const result = await setup({
|
||||
cwd: '/workspace',
|
||||
sessionPersistence: {
|
||||
list: async () => [target],
|
||||
load: async () => ({ meta: target, events: resumeEvents(`Turn ${label}`, 'deepseek-official', 100, reason) }),
|
||||
list: async () => [createdLate, createdEarly, gone, goneTwin],
|
||||
load: async id => ({
|
||||
meta: [createdLate, createdEarly, gone, goneTwin].find(target => target.id === id)!,
|
||||
events: resumeEvents(titles.get(id)!),
|
||||
}),
|
||||
locate: meta => ({ kind: 'jsonl', path: paths.get(meta.id)! }),
|
||||
},
|
||||
})
|
||||
result.terminal.send('/resume')
|
||||
result.terminal.send('\r')
|
||||
await tick(); await tick()
|
||||
expect(result.terminal.output).toContain(`turn 1: ${label}`)
|
||||
const rendered = result.terminal.output.slice(result.terminal.output.lastIndexOf('Resume session'))
|
||||
expect(rendered).toContain(new Date(120_000).toISOString())
|
||||
expect(rendered.indexOf('Touched late')).toBeLessThan(rendered.indexOf('Touched early'))
|
||||
// A missing artifact falls back to the header's creation time; equal
|
||||
// times tie-break by id.
|
||||
expect(rendered).toContain(new Date(gone.createdAt).toISOString())
|
||||
expect(rendered.indexOf('artifact-gone')).toBeLessThan(rendered.indexOf('artifact-gone-twin'))
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
await dispose(result)
|
||||
})
|
||||
|
||||
@@ -537,7 +567,7 @@ describe('goodbye message and /resume', () => {
|
||||
queryCtx = child
|
||||
child.provide('sessionQuery', {
|
||||
listSessions: async () => { listCalls++; return [] },
|
||||
projectSessions: async () => [],
|
||||
readTitleSnapshots: async () => [],
|
||||
} as never)
|
||||
},
|
||||
})
|
||||
@@ -579,7 +609,7 @@ describe('goodbye message and /resume', () => {
|
||||
persisted: true,
|
||||
}]),
|
||||
readSession,
|
||||
projectSessions: projectViaReadSession(readSession),
|
||||
readTitleSnapshots: titlesViaReadSession(readSession),
|
||||
} as never)
|
||||
},
|
||||
})
|
||||
@@ -617,7 +647,7 @@ describe('goodbye message and /resume', () => {
|
||||
ctx.provide('tools', { get: () => undefined } as never)
|
||||
ctx.provide('sessionQuery', {
|
||||
listSessions: () => ++calls === 1 ? first.promise : Promise.resolve([]),
|
||||
projectSessions: async () => [],
|
||||
readTitleSnapshots: async () => [],
|
||||
} as never)
|
||||
},
|
||||
})
|
||||
@@ -686,7 +716,7 @@ describe('goodbye message and /resume', () => {
|
||||
ctx.provide('tools', { get: () => undefined } as never)
|
||||
ctx.provide('sessionQuery', {
|
||||
listSessions: (signal?: AbortSignal) => { scanSignal = signal; return listing.promise },
|
||||
projectSessions: async () => { projections += 1; return [] },
|
||||
readTitleSnapshots: async () => { projections += 1; return [] },
|
||||
} as never)
|
||||
},
|
||||
})
|
||||
@@ -698,7 +728,7 @@ describe('goodbye message and /resume', () => {
|
||||
await tick()
|
||||
expect(scanSignal?.aborted).toBe(true)
|
||||
// A signal-ignoring backend can still fulfill after dismissal: the stale
|
||||
// scan must neither project nor report.
|
||||
// scan must neither read titles nor report.
|
||||
listing.resolve([])
|
||||
await tick()
|
||||
expect(projections).toBe(0)
|
||||
@@ -706,14 +736,14 @@ describe('goodbye message and /resume', () => {
|
||||
await dispose(result)
|
||||
})
|
||||
|
||||
it('drops a projection that settles after the picker was dismissed', async () => {
|
||||
it('drops a title read that settles after the picker was dismissed', async () => {
|
||||
const projecting = Promise.withResolvers<never[]>()
|
||||
const result = await setup({
|
||||
async configureContext(ctx) {
|
||||
ctx.provide('tools', { get: () => undefined } as never)
|
||||
ctx.provide('sessionQuery', {
|
||||
listSessions: async () => [],
|
||||
projectSessions: () => projecting.promise,
|
||||
readTitleSnapshots: () => projecting.promise,
|
||||
} as never)
|
||||
},
|
||||
})
|
||||
@@ -730,20 +760,20 @@ describe('goodbye message and /resume', () => {
|
||||
})
|
||||
|
||||
it('closes the loading picker and reports a scan that fails after listing', async () => {
|
||||
const target = header('projection-explodes', 10, '/workspace')
|
||||
const target = header('titles-explode', 10, '/workspace')
|
||||
const result = await setup({
|
||||
async configureContext(ctx) {
|
||||
ctx.provide('tools', { get: () => undefined } as never)
|
||||
ctx.provide('sessionQuery', {
|
||||
listSessions: () => Promise.resolve([{ header: target, live: false, persisted: true }]),
|
||||
projectSessions: () => Promise.reject(new Error('projection exploded')),
|
||||
readTitleSnapshots: () => Promise.reject(new Error('titles exploded')),
|
||||
} as never)
|
||||
},
|
||||
})
|
||||
result.terminal.send('/resume')
|
||||
result.terminal.send('\r')
|
||||
await tick(); await tick()
|
||||
expect(result.terminal.output).toContain('Resume session scan failed: projection exploded')
|
||||
expect(result.terminal.output).toContain('Resume session scan failed: titles exploded')
|
||||
expect(result.terminal.stopped).toBe(0)
|
||||
await dispose(result)
|
||||
})
|
||||
@@ -762,7 +792,7 @@ describe('goodbye message and /resume', () => {
|
||||
ctx.provide('sessionQuery', {
|
||||
listSessions: () => listing.promise,
|
||||
readSession,
|
||||
projectSessions: projectViaReadSession(readSession),
|
||||
readTitleSnapshots: titlesViaReadSession(readSession),
|
||||
} as never)
|
||||
},
|
||||
})
|
||||
@@ -818,11 +848,12 @@ describe('goodbye message and /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')
|
||||
// Rows carry no route: availability surfaces only at Enter-time preflight.
|
||||
expect(result.terminal.output).not.toContain('absent-provider/model-1')
|
||||
expect(result.terminal.output).toContain('Unreadable session')
|
||||
result.terminal.send('Missing adapter')
|
||||
result.terminal.send('\r')
|
||||
await tick()
|
||||
await tick(); await tick()
|
||||
expect(result.terminal.output).toContain('route is currently unavailable')
|
||||
expect(result.terminal.stopped).toBe(0)
|
||||
await dispose(result)
|
||||
@@ -847,7 +878,7 @@ describe('goodbye message and /resume', () => {
|
||||
persisted: true,
|
||||
}]),
|
||||
readSession,
|
||||
projectSessions: projectViaReadSession(readSession),
|
||||
readTitleSnapshots: titlesViaReadSession(readSession),
|
||||
} as never)
|
||||
},
|
||||
})
|
||||
@@ -862,10 +893,45 @@ describe('goodbye message and /resume', () => {
|
||||
await dispose(result)
|
||||
})
|
||||
|
||||
it('rechecks record liveness at preflight rather than trusting the listed row', async () => {
|
||||
const target = header('turns-live', 10, '/workspace')
|
||||
const handoff = vi.fn<NonNullable<TuiRuntime['handoffResume']>>()
|
||||
let listings = 0
|
||||
const result = await setup({
|
||||
cwd: '/workspace',
|
||||
handoffResume: handoff,
|
||||
async configureContext(ctx) {
|
||||
ctx.provide('tools', { get: () => undefined } as never)
|
||||
const readSession = () => Promise.resolve({
|
||||
session: target,
|
||||
events: resumeEvents('Turns live'),
|
||||
})
|
||||
ctx.provide('sessionQuery', {
|
||||
listSessions: () => Promise.resolve([{
|
||||
header: target,
|
||||
live: ++listings > 1,
|
||||
persisted: true,
|
||||
}]),
|
||||
readSession,
|
||||
readTitleSnapshots: titlesViaReadSession(readSession),
|
||||
} as never)
|
||||
},
|
||||
})
|
||||
result.terminal.send('/resume')
|
||||
result.terminal.send('\r')
|
||||
await tick(); await tick()
|
||||
result.terminal.send('Turns live')
|
||||
result.terminal.send('\r')
|
||||
await tick(); 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-official')
|
||||
const events = resumeEvents('Assistant route', 'absent-provider')
|
||||
.filter(event => event.type !== 'request/header')
|
||||
.map((event, seq) => ({ ...event, seq })) as SessionEvent[]
|
||||
const result = await setup({
|
||||
@@ -880,8 +946,23 @@ describe('goodbye message and /resume', () => {
|
||||
result.terminal.send('/resume')
|
||||
result.terminal.send('\r')
|
||||
await tick(); await tick()
|
||||
expect(result.terminal.output).toContain('deepseek-official/model-1')
|
||||
// Without a persisted artifact to stat, listing falls back to creation time.
|
||||
expect(result.terminal.output).toContain(new Date(empty.createdAt).toISOString())
|
||||
// The preflight route fold falls back to assistant provenance when the
|
||||
// log carries no request header.
|
||||
result.terminal.send('Assistant route')
|
||||
result.terminal.send('\r')
|
||||
await tick(); await tick()
|
||||
expect(result.terminal.output).toContain('route is currently unavailable')
|
||||
// The failed preflight closed the picker; reopen and pick the routeless
|
||||
// log, which passes the route check — only the absent host stops it.
|
||||
result.terminal.send('/resume')
|
||||
result.terminal.send('\r')
|
||||
await tick(); await tick()
|
||||
result.terminal.send('empty-log')
|
||||
result.terminal.send('\r')
|
||||
await tick(); await tick()
|
||||
expect(result.terminal.output).toContain('cannot hand it off in place')
|
||||
await dispose(result)
|
||||
})
|
||||
|
||||
@@ -975,7 +1056,7 @@ describe('goodbye message and /resume', () => {
|
||||
ctx.provide('sessionQuery', {
|
||||
listSessions: () => ++listings === 1 ? Promise.resolve([record]) : secondListing.promise,
|
||||
readSession,
|
||||
projectSessions: projectViaReadSession(readSession),
|
||||
readTitleSnapshots: titlesViaReadSession(readSession),
|
||||
} as never)
|
||||
},
|
||||
})
|
||||
@@ -1013,7 +1094,7 @@ describe('goodbye message and /resume', () => {
|
||||
persisted: true,
|
||||
}]),
|
||||
readSession,
|
||||
projectSessions: projectViaReadSession(readSession),
|
||||
readTitleSnapshots: titlesViaReadSession(readSession),
|
||||
} as never)
|
||||
},
|
||||
})
|
||||
@@ -1409,10 +1490,6 @@ describe('pi-tui chat lifecycle and transcript', () => {
|
||||
})
|
||||
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)
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user