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:
Turtle
2026-08-03 16:40:47 +08:00
parent 776cc6889a
commit 7077d6befc
25 changed files with 295 additions and 372 deletions
+3 -1
View File
@@ -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| " "
+3 -3
View File
@@ -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 () => {
+120 -43
View File
@@ -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)
})