feat(opentui-v2): Phase 4b — session resume with tool/transcript hydration

HERMES_TUI_RESUME=<id|recent> resumes a session instead of creating one:
session.most_recent (for "recent") → session.resume {cols, session_id} →
commitSnapshot(mapResumeHistory(messages)), buffering live events across the RPC.

- logic/resume.ts: maps the session.resume history into Message[]. Resumed tool
  rows arrive as {role:'tool', name, context} (NO text — gotcha §8 #5); they're
  FOLDED into the preceding assistant turn's ordered parts (state:'complete',
  summary=context) so a resumed transcript renders the tools INLINE like a live
  one. Assistant text gets a text part (renders via native markdown). User/system
  stay flat. Unknown roles / non-arrays are ignored.
- logic/store.ts: hydrate split into beginBuffer() + commitSnapshot() so the live
  event buffer spans the async resume RPC (events that arrive during resume are
  replayed after the snapshot, in order).
- entry/main.tsx: bootstrap branches create vs resume; the resume path is timed
  (rpc_ms / hydrate_ms) for profiling.

Verified: bun run check green (40 tests / 7 files) — resume mapper (fold tool
rows, standalone holder, ignore junk) + beginBuffer/commitSnapshot replay. LIVE
tmux: Launch A created a session with a terminal tool call; Launch B
(HERMES_TUI_RESUME=recent) hydrated user + assistant + the tool row inline.
STRESS+PROFILE on a real 103-message session (~/.hermes/sessions): client hydrate
= 76ms, bun RSS = 214MB STABLE (no leak), tool rows hydrated, PageUp scroll works;
the 1.6s cost is the server-side session.resume RPC, not the TUI. Smoke P4 +
matrix updated. Note: rows instantiate for the full history (scrollbox culls
render only) → RSS ~linear in turns; list virtualization is the lever if
multi-thousand-turn sessions become a target.
This commit is contained in:
alt-glitch 2026-06-08 15:31:46 +00:00
parent 4f2bb7e52f
commit c704d384f4
5 changed files with 181 additions and 20 deletions

View file

@ -27,6 +27,7 @@ import { liveGatewayLayer } from '../boundary/gateway/liveGateway.ts'
import { getLog } from '../boundary/log.ts'
import { acquireRenderer } from '../boundary/renderer.ts'
import { makeAppLayer } from '../boundary/runtime.ts'
import { mapResumeHistory } from '../logic/resume.ts'
import { dispatchSlash, type SlashContext } from '../logic/slash.ts'
import { createSessionStore, type SessionStore } from '../logic/store.ts'
import { App } from '../view/App.tsx'
@ -42,6 +43,8 @@ export interface TuiInput {
readonly cols: number
/** Optional initial prompt submitted once the session is ready — the Phase-1 stand-in for the composer. */
readonly initialPrompt?: string
/** Resume a session instead of creating one: a session id, or 'recent'/'last' (→ session.most_recent). */
readonly resumeId?: string
}
const READY_POLL = Duration.millis(100)
@ -49,10 +52,11 @@ const READY_TIMEOUT_MS = 20_000
/**
* Live session bootstrap: wait for the unsolicited `gateway.ready` handshake,
* `session.create`, then (if given) submit the initial prompt. Forked into the
* entry scope so it runs concurrently with the render + the quit-await. Any
* failure is logged (file/ring sink) and swallowed a bootstrap hiccup must
* never tear down the rendered UI.
* then either RESUME a session (hydrate its transcript incl. tool rows via
* the snapshot, buffering live events across the RPC) or CREATE a fresh one, and
* (if given) submit the initial prompt. Forked into the entry scope so it runs
* concurrently with the render + the quit-await. Any failure is logged and
* swallowed a bootstrap hiccup must never tear down the rendered UI.
*/
const bootstrapSession = (gateway: GatewayServiceShape, store: SessionStore, input: TuiInput) =>
Effect.gen(function* () {
@ -66,13 +70,45 @@ const bootstrapSession = (gateway: GatewayServiceShape, store: SessionStore, inp
log.warn('bootstrap', 'no gateway.ready within timeout', { waited })
return
}
const created = yield* gateway.request<{ session_id?: string }>('session.create', { cols: input.cols })
const sid = created?.session_id ?? gateway.sessionId()
if (!sid) {
log.warn('bootstrap', 'session.create returned no session_id')
return
let sid: string | undefined
if (input.resumeId) {
sid = input.resumeId
if (sid === 'recent' || sid === 'last') {
const recent = yield* gateway.request<{ session_id?: string }>('session.most_recent', {})
sid = recent?.session_id
}
if (!sid) {
log.warn('bootstrap', 'no session to resume', { resumeId: input.resumeId })
return
}
// Buffer live events across the resume RPC, then replace history + replay.
store.beginBuffer()
const t0 = Date.now()
const resumed = yield* gateway.request<{ messages?: unknown }>('session.resume', {
cols: input.cols,
session_id: sid
})
const t1 = Date.now()
const snapshot = mapResumeHistory(resumed?.messages)
store.commitSnapshot(snapshot)
// Hydration profile: rpc_ms = server load + transport; hydrate_ms = map + store write.
log.info('bootstrap', 'session resumed', {
count: snapshot.length,
hydrate_ms: Date.now() - t1,
rpc_ms: t1 - t0,
sid
})
} else {
const created = yield* gateway.request<{ session_id?: string }>('session.create', { cols: input.cols })
sid = created?.session_id ?? gateway.sessionId()
if (!sid) {
log.warn('bootstrap', 'session.create returned no session_id')
return
}
log.info('bootstrap', 'session created', { sid })
}
log.info('bootstrap', 'session created', { sid })
const prompt = input.initialPrompt?.trim()
if (prompt) {
store.pushUser(prompt)
@ -191,8 +227,10 @@ if (import.meta.main) {
const fake = TRUE_RE.test(process.env.HERMES_TUI_FAKE?.trim() ?? '')
const cols = process.stdout.columns || 80
const initialPrompt = process.env.HERMES_TUI_PROMPT?.trim() || process.argv.slice(2).join(' ').trim()
const resumeId = process.env.HERMES_TUI_RESUME?.trim()
const base = { mouse: false, fake, cols }
const input: TuiInput = initialPrompt ? { ...base, initialPrompt } : base
const withPrompt = initialPrompt ? { ...base, initialPrompt } : base
const input: TuiInput = resumeId ? { ...withPrompt, resumeId } : withPrompt
const onFatal = (error: unknown) => {
getLog().error('entry', 'fatal', { error: String(error) })

View file

@ -0,0 +1,55 @@
/**
* Resume snapshot mapper (spec §1 lifecycle; gotcha §8 #5). Maps the
* `session.resume` response `messages` (tui_gateway `_history_to_messages`) into
* the store's `Message[]`. Each history entry is either `{role, text}` (user/
* assistant/system) or `{role:'tool', name, context}` (NO text render it).
*
* Tool rows are folded into the PRECEDING assistant turn's ordered `parts[]`
* (state:'complete', summary=context) so a resumed transcript renders inline like
* a live one. Resumed assistant text is given a single text part so it renders
* through the native markdown path. IDs are `r*` (distinct from live `p*`).
*/
import type { Message, Part } from './store.ts'
function readStr(value: unknown, key: string): string | undefined {
if (!value || typeof value !== 'object') return undefined
const v = (value as { [k: string]: unknown })[key]
return typeof v === 'string' ? v : undefined
}
export function mapResumeHistory(history: unknown): Message[] {
if (!Array.isArray(history)) return []
const out: Message[] = []
let seq = 0
const id = () => `r${++seq}`
let currentAssistant: Message | undefined
for (const raw of history) {
const role = readStr(raw, 'role')
if (role === 'tool') {
const name = readStr(raw, 'name') ?? 'tool'
const context = readStr(raw, 'context')
const tool: Part = { type: 'tool', id: id(), name, state: 'complete' }
if (context) tool.summary = context
if (!currentAssistant) {
currentAssistant = { role: 'assistant', text: '', parts: [] }
out.push(currentAssistant)
}
;(currentAssistant.parts ??= []).push(tool)
continue
}
const text = readStr(raw, 'text') ?? ''
if (role === 'assistant') {
const parts: Part[] = text ? [{ type: 'text', id: id(), text }] : []
currentAssistant = { role: 'assistant', text, parts }
out.push(currentAssistant)
} else if (role === 'user' || role === 'system') {
out.push({ role, text })
currentAssistant = undefined
}
}
return out
}

View file

@ -306,20 +306,28 @@ export function createSessionStore() {
setState('prompt', undefined)
}
/**
* Begin a resume hydrate: buffer live events, replace history with the
* snapshot, then replay buffered events. `loadSnapshot` maps the gateway's
* historical messages into the store's Message[] (Phase 4 fills the mapping).
*/
function hydrate(loadSnapshot: () => Message[]): void {
buffering = []
const snapshot = loadSnapshot()
// ── resume hydrate (opencode sync-v2): buffer live events while the snapshot
// loads, then replace history + replay the buffer in order. Split into begin/
// commit so the buffer can span an async `session.resume` RPC.
/** Start buffering live events (call BEFORE the async resume RPC). Idempotent. */
function beginBuffer(): void {
if (!buffering) buffering = []
}
/** Replace history with the resume snapshot, then replay events buffered meanwhile. */
function commitSnapshot(snapshot: Message[]): void {
setState('messages', snapshot)
const pending = buffering
const pending = buffering ?? []
buffering = null
for (const event of pending) applyNow(event)
}
/** Synchronous convenience: buffer → load → commit (used by tests). */
function hydrate(loadSnapshot: () => Message[]): void {
beginBuffer()
commitSnapshot(loadSnapshot())
}
return {
state,
apply,
@ -328,6 +336,8 @@ export function createSessionStore() {
clearTranscript,
setConfirm,
hydrate,
beginBuffer,
commitSnapshot,
duplicate,
clearPrompt
} as const

View file

@ -0,0 +1,43 @@
/**
* Resume mapper test (spec §1 lifecycle; gotcha §8 #5). The `session.resume`
* history maps into the store's Message[], folding tool rows ({name,context},
* NO text) into the preceding assistant turn's ordered parts so they render.
*/
import { describe, expect, test } from 'bun:test'
import { mapResumeHistory } from '../logic/resume.ts'
describe('mapResumeHistory (Phase 4b)', () => {
test('maps user/assistant text + folds tool rows into the preceding assistant parts', () => {
const msgs = mapResumeHistory([
{ role: 'user', text: 'list files' },
{ role: 'assistant', text: 'Listing.' },
{ role: 'tool', name: 'terminal', context: 'ls -la' },
{ role: 'assistant', text: 'Done.' }
])
expect(msgs.map(m => m.role)).toEqual(['user', 'assistant', 'assistant'])
expect(msgs[0]).toMatchObject({ role: 'user', text: 'list files' })
const a1 = msgs[1]!
expect(a1.parts?.map(p => p.type)).toEqual(['text', 'tool']) // text + folded tool, inline
const tool = a1.parts![1]!
if (tool.type === 'tool') {
expect(tool).toMatchObject({ name: 'terminal', state: 'complete', summary: 'ls -la' })
} else {
throw new Error('expected a folded tool part')
}
expect(msgs[2]).toMatchObject({ role: 'assistant', text: 'Done.' })
})
test('a tool row with no preceding assistant gets a standalone assistant holder', () => {
const msgs = mapResumeHistory([{ role: 'tool', name: 'read_file', context: 'foo.ts' }])
expect(msgs).toHaveLength(1)
expect(msgs[0]!.role).toBe('assistant')
expect(msgs[0]!.parts?.[0]).toMatchObject({ type: 'tool', name: 'read_file', summary: 'foo.ts' })
})
test('ignores non-arrays and unknown roles', () => {
expect(mapResumeHistory(null)).toEqual([])
expect(mapResumeHistory([{ role: 'weird', text: 'x' }])).toEqual([])
})
})

View file

@ -153,3 +153,18 @@ describe('session store — blocking prompts (Phase 3)', () => {
expect(store.state.prompt).toMatchObject({ kind: 'secret', envVar: 'API_KEY', requestId: 's2' })
})
})
describe('session store — resume hydrate (Phase 4b)', () => {
test('beginBuffer + commitSnapshot replaces history then replays events buffered across the resume', () => {
const store = createSessionStore()
store.beginBuffer()
// a live event arrives DURING the (async) session.resume RPC
store.apply({ type: 'message.start' })
store.apply({ type: 'message.delta', payload: { text: 'live during resume' } })
// the snapshot commits afterwards
store.commitSnapshot([{ role: 'user', text: 'old question' }])
expect(store.state.messages).toHaveLength(2) // snapshot(1) + the replayed assistant turn(1)
expect(store.state.messages[0]).toMatchObject({ role: 'user', text: 'old question' })
expect(store.state.messages[1]!.parts?.[0]).toMatchObject({ type: 'text', text: 'live during resume' })
})
})