mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-09 13:21:42 +00:00
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:
parent
4f2bb7e52f
commit
c704d384f4
5 changed files with 181 additions and 20 deletions
|
|
@ -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) })
|
||||
|
|
|
|||
55
ui-tui-opentui-v2/src/logic/resume.ts
Normal file
55
ui-tui-opentui-v2/src/logic/resume.ts
Normal 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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
43
ui-tui-opentui-v2/src/test/resume.test.ts
Normal file
43
ui-tui-opentui-v2/src/test/resume.test.ts
Normal 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([])
|
||||
})
|
||||
})
|
||||
|
|
@ -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' })
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue