diff --git a/ui-tui-opentui-v2/src/entry/main.tsx b/ui-tui-opentui-v2/src/entry/main.tsx index 8e0310e6a16..1a48c796542 100644 --- a/ui-tui-opentui-v2/src/entry/main.tsx +++ b/ui-tui-opentui-v2/src/entry/main.tsx @@ -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) }) diff --git a/ui-tui-opentui-v2/src/logic/resume.ts b/ui-tui-opentui-v2/src/logic/resume.ts new file mode 100644 index 00000000000..258d526e5ee --- /dev/null +++ b/ui-tui-opentui-v2/src/logic/resume.ts @@ -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 +} diff --git a/ui-tui-opentui-v2/src/logic/store.ts b/ui-tui-opentui-v2/src/logic/store.ts index b2341153ba2..66b88364893 100644 --- a/ui-tui-opentui-v2/src/logic/store.ts +++ b/ui-tui-opentui-v2/src/logic/store.ts @@ -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 diff --git a/ui-tui-opentui-v2/src/test/resume.test.ts b/ui-tui-opentui-v2/src/test/resume.test.ts new file mode 100644 index 00000000000..df76f93b04f --- /dev/null +++ b/ui-tui-opentui-v2/src/test/resume.test.ts @@ -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([]) + }) +}) diff --git a/ui-tui-opentui-v2/src/test/store.test.ts b/ui-tui-opentui-v2/src/test/store.test.ts index 22277a12e28..0452a9ae19d 100644 --- a/ui-tui-opentui-v2/src/test/store.test.ts +++ b/ui-tui-opentui-v2/src/test/store.test.ts @@ -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' }) + }) +})