diff --git a/src/main/daemon/daemon-pty-adapter.test.ts b/src/main/daemon/daemon-pty-adapter.test.ts index 323918cd9..539f66b55 100644 --- a/src/main/daemon/daemon-pty-adapter.test.ts +++ b/src/main/daemon/daemon-pty-adapter.test.ts @@ -932,6 +932,103 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { expect(result.coldRestore?.oscLinks).toEqual(oscLinks) }) + it('cold-restores an alt-screen agent snapshot as scrollback on wake (hibernation)', async () => { + // Why: agent hibernation force-kills Claude/Codex while still in their + // alt-screen TUI, so scrollbackAnsi is empty. The fix falls back to the + // saved snapshot so the pane repaints the agent's last frame instead of + // coming back blank. (The payload is snapshotAnsi alone — no + // rehydrateSequences — so it never re-enters alt-screen.) + const sessionId = 'cold-restore-alt-screen' + const sessionDir = join(historyDir, getHistorySessionDirName(sessionId)) + mkdirSync(sessionDir, { recursive: true }) + writeFileSync( + join(sessionDir, 'meta.json'), + JSON.stringify({ + cwd: '/projects/myapp', + cols: 80, + rows: 24, + startedAt: '2026-04-15T10:00:00Z', + endedAt: null, + exitCode: null + }) + ) + writeFileSync( + join(sessionDir, 'checkpoint.json'), + JSON.stringify({ + snapshotAnsi: '\x1b[H Claude Code — Opus 4.8\r\n > ', + scrollbackAnsi: '', + oscLinks: [], + rehydrateSequences: '\x1b[?1049h', + cwd: '/projects/myapp', + cols: 80, + rows: 24, + modes: { + bracketedPaste: false, + mouseTracking: false, + applicationCursor: false, + alternateScreen: true + }, + scrollbackLines: 0, + generation: 0, + checkpointedAt: '2026-04-15T11:00:00Z' + }) + ) + + historyAdapter = new DaemonPtyAdapter({ socketPath, tokenPath, historyPath: historyDir }) + + const result = await historyAdapter.spawn({ cols: 80, rows: 24, sessionId }) + expect(result.coldRestore).toBeDefined() + expect(result.coldRestore!.scrollback).toContain('Claude Code') + // The payload must NOT re-enter alt-screen — that would fight the + // relaunched agent's repaint and the renderer's POST_REPLAY_MODE_RESET. + expect(result.coldRestore!.scrollback).not.toContain('\x1b[?1049h') + }) + + it('skips cold restore for an alt-screen session with an empty snapshot', async () => { + // Why: alt-screen entered before any content → nothing to show. Keep the + // no-op (blank) rather than fabricate a payload. + const sessionId = 'cold-restore-alt-screen-empty' + const sessionDir = join(historyDir, getHistorySessionDirName(sessionId)) + mkdirSync(sessionDir, { recursive: true }) + writeFileSync( + join(sessionDir, 'meta.json'), + JSON.stringify({ + cwd: '/projects/myapp', + cols: 80, + rows: 24, + startedAt: '2026-04-15T10:00:00Z', + endedAt: null, + exitCode: null + }) + ) + writeFileSync( + join(sessionDir, 'checkpoint.json'), + JSON.stringify({ + snapshotAnsi: '', + scrollbackAnsi: '', + oscLinks: [], + rehydrateSequences: '\x1b[?1049h', + cwd: '/projects/myapp', + cols: 80, + rows: 24, + modes: { + bracketedPaste: false, + mouseTracking: false, + applicationCursor: false, + alternateScreen: true + }, + scrollbackLines: 0, + generation: 0, + checkpointedAt: '2026-04-15T11:00:00Z' + }) + ) + + historyAdapter = new DaemonPtyAdapter({ socketPath, tokenPath, historyPath: historyDir }) + + const result = await historyAdapter.spawn({ cols: 80, rows: 24, sessionId }) + expect(result.coldRestore).toBeUndefined() + }) + it('re-anchors a cold-restored session with a full checkpoint on the first tick', async () => { const adapterClass = DaemonPtyAdapter as unknown as { CHECKPOINT_INTERVAL_MS: number } const previousInterval = adapterClass.CHECKPOINT_INTERVAL_MS diff --git a/src/main/daemon/daemon-pty-adapter.ts b/src/main/daemon/daemon-pty-adapter.ts index 8e8f4dbdf..b3252aae3 100644 --- a/src/main/daemon/daemon-pty-adapter.ts +++ b/src/main/daemon/daemon-pty-adapter.ts @@ -206,19 +206,16 @@ export class DaemonPtyAdapter implements IPtyProvider { // an unclean shutdown → return saved scrollback so the renderer can // display the previous terminal content. if (result.isNew && restoreInfo) { - // Why: if the checkpoint was captured while an alternate-screen app - // (vim, less, htop) was active, snapshotAnsi is the alt buffer content. - // Replaying that into a fresh shell would show stale TUI content. Use - // scrollbackAnsi (rows above the viewport only) which excludes the alt - // buffer. For normal sessions, use the full snapshot with rehydrate - // sequences to restore terminal modes (colors, cursor position, etc). - // Why: scrollbackAnsi may be empty if the emulator hadn't accumulated - // scrollback before the alt-screen app launched. In that case, skip - // cold restore entirely rather than showing a blank terminal — no - // content is better than confusing the user with an empty restore. + // Why prefer scrollbackAnsi for alt-screen: snapshotAnsi is the alt buffer + // (vim/less/htop); normal sessions use the full snapshot + rehydrate. + // Why the snapshotAnsi fallback: a hibernated TUI agent (empty scrollback) + // would otherwise get `|| null` → blank pane on wake. snapshotAnsi *alone* + // (no rehydrateSequences — they start with \x1b[?1049h, which the + // renderer's POST_REPLAY_MODE_RESET does NOT undo) lands the last frame as + // normal scrollback. An empty snapshot still yields null → no-op. const isAltScreen = restoreInfo.modes.alternateScreen const scrollback = isAltScreen - ? restoreInfo.scrollbackAnsi || null + ? restoreInfo.scrollbackAnsi || restoreInfo.snapshotAnsi || null : restoreInfo.rehydrateSequences + restoreInfo.snapshotAnsi // Why: use registerWriter (not openSession) to avoid deleting the // existing checkpoint.json. If the revived daemon crashes again before diff --git a/src/main/daemon/hibernation-cold-restore-repro.test.ts b/src/main/daemon/hibernation-cold-restore-repro.test.ts new file mode 100644 index 000000000..d3b5f4ee0 --- /dev/null +++ b/src/main/daemon/hibernation-cold-restore-repro.test.ts @@ -0,0 +1,173 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { mkdtempSync, rmSync } from 'node:fs' +import { HistoryManager } from './history-manager' +import { HistoryReader } from './history-reader' +import { HeadlessEmulator } from './headless-emulator' + +// Reproduction for the "blank pane after agent hibernation" bug. +// +// Verified root cause (NOT the meta.endedAt gate — see below): +// Agent hibernation force-kills the agent PTY via the immediate path +// (pty:kill -> shutdown({ immediate: true, keepHistory: true }) -> +// TerminalHost.kill(immediate) -> forceKillAndDisposeSubprocess), which reaps +// synchronously WITHOUT firing onExit. So closeSession never runs and +// meta.endedAt stays null — detectColdRestore does NOT reject the session. +// +// The actual blank comes from cold-restore CONTENT, not eligibility: +// Claude/Codex TUIs run in terminal alternate-screen mode. For an alt-screen +// snapshot, HistoryReader.coldRestoreInfoFromSnapshot returns scrollbackAnsi='' +// (history-reader.ts:190-191), and DaemonPtyAdapter then skips the cold-restore +// payload entirely on `if (scrollback)` (daemon-pty-adapter.ts:230) — "no +// content is better than a confusing empty restore." Result: the daemon sends +// nothing back on wake and the preserved pane repaints blank, even though a +// full snapshotAnsi of the agent's last screen is intact on disk. +// +// This test drives a real HeadlessEmulator into alt-screen mode, checkpoints it +// through the real HistoryManager, and asserts the empty-scrollback outcome the +// adapter treats as "no cold restore". +// +// Scope note: this file documents the bug MECHANISM at the emulator/reader layer +// and replicates the adapter's payload decision inline, so its post-fix +// assertions would still pass if the production line were reverted. The actual +// regression guard that exercises DaemonPtyAdapter.spawn() end-to-end lives in +// daemon-pty-adapter.test.ts ("cold-restores an alt-screen agent snapshot…"). + +const ALT_SCREEN_ON = '\x1b[?1049h' + +// Note: HistoryManager.checkpoint() takes the emulator's TerminalSnapshot directly +// and stamps generation / checkpointedAt itself, so em.getSnapshot() is passed as-is. + +describe('agent hibernation cold-restore (alt-screen TUI)', () => { + let dir: string + const sessionId = 'repo-1::/Users/dev/pr-review-6321' + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'hibernation-repro-')) + }) + + afterEach(() => { + rmSync(dir, { recursive: true, force: true }) + }) + + it('a NORMAL-screen shell cold-restores its snapshot as scrollback (control)', async () => { + const manager = new HistoryManager(dir) + const reader = new HistoryReader(dir) + const em = new HeadlessEmulator({ cols: 80, rows: 24 }) + em.writeSync('thebr@host:~/project$ echo hello\r\nhello\r\n') + + await manager.openSession(sessionId, { cwd: '/home/user/project', cols: 80, rows: 24 }) + await manager.checkpoint(sessionId, em.getSnapshot()) + em.dispose() + + const info = reader.detectColdRestore(sessionId) + expect(info).not.toBeNull() + // Adapter uses rehydrateSequences + snapshotAnsi for non-alt-screen → non-empty. + expect(info!.modes.alternateScreen).toBe(false) + // Normal-screen restores carry their buffer as scrollback; assert both so a + // regression that empties scrollbackAnsi can't slip past this control. + expect(info!.scrollbackAnsi).toContain('hello') + expect(info!.snapshotAnsi).toContain('hello') + }) + + it('post-fix: an ALT-SCREEN TUI agent with empty scrollback now cold-restores its snapshot', async () => { + const manager = new HistoryManager(dir) + const reader = new HistoryReader(dir) + const em = new HeadlessEmulator({ cols: 80, rows: 24 }) + // Why: Claude/Codex enter the alternate screen. Once in alt-screen, the + // serialized snapshot is the TUI buffer and scrollbackAnsi is empty. + em.writeSync(ALT_SCREEN_ON) + em.writeSync('\x1b[2J\x1b[H Claude Code — Opus 4.8\r\n > ') + expect(em.isAlternateScreen).toBe(true) + + await manager.openSession(sessionId, { cwd: '/home/user/project', cols: 80, rows: 24 }) + await manager.checkpoint(sessionId, em.getSnapshot()) + em.dispose() + + const info = reader.detectColdRestore(sessionId) + // The session IS eligible (endedAt is null — hibernation's immediate kill + // never stamps it), and the snapshot of the agent's screen is intact... + expect(info).not.toBeNull() + expect(info!.modes.alternateScreen).toBe(true) + expect(info!.snapshotAnsi.length).toBeGreaterThan(0) + + // scrollbackAnsi is empty for alt-screen (the bug's trigger). Pre-fix the + // adapter's `isAltScreen ? scrollbackAnsi || null : ...` dropped the + // payload here, leaving the pane blank. + expect(info!.scrollbackAnsi).toBe('') + + // Replicate the adapter's POST-FIX payload decision: alt-screen falls + // back to snapshotAnsi (the agent's last frame) when scrollbackAnsi is + // empty, so the pane is no longer blank on wake. + const isAltScreen = info!.modes.alternateScreen + const adapterScrollback = isAltScreen + ? info!.scrollbackAnsi || info!.snapshotAnsi || null + : info!.rehydrateSequences + info!.snapshotAnsi + expect(adapterScrollback).not.toBeNull() // → adapter sends a coldRestore payload + expect(adapterScrollback).toContain('Claude Code') + }) + + it('post-fix: the restored alt-screen snapshot lands at a NORMAL screen, not alt-screen', async () => { + const manager = new HistoryManager(dir) + const reader = new HistoryReader(dir) + const em = new HeadlessEmulator({ cols: 80, rows: 24 }) + em.writeSync(ALT_SCREEN_ON) + em.writeSync('\x1b[2J\x1b[H Claude Code — Opus 4.8\r\n > do something\r\n') + + await manager.openSession(sessionId, { cwd: '/home/user/project', cols: 80, rows: 24 }) + await manager.checkpoint(sessionId, em.getSnapshot()) + em.dispose() + + const info = reader.detectColdRestore(sessionId) + const adapterScrollback = info!.modes.alternateScreen + ? info!.scrollbackAnsi || info!.snapshotAnsi || null + : info!.rehydrateSequences + info!.snapshotAnsi + expect(adapterScrollback).not.toBeNull() + + // Drive the renderer's cold-restore branch into a FRESH shell emulator: + // clear, write the payload, then POST_REPLAY_MODE_RESET. The pane must end + // in the normal buffer (no \x1b[?1049h fed) so it won't fight the agent's + // own repaint when the resume command relaunches it. + // Keep in sync with POST_REPLAY_MODE_RESET in + // src/renderer/src/components/terminal-pane/layout-serialization.ts (copied + // as a literal because a main-process test must not import a renderer module). + const POST_REPLAY_MODE_RESET = + '\x1b[0 q\x1b[<99u\x1b[=0u\x1b[?25h\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1004l\x1b[?1006l\x1b[?2004l' + const fresh = new HeadlessEmulator({ cols: 80, rows: 24 }) + fresh.writeSync('\x1b[2J\x1b[3J\x1b[H') + fresh.writeSync(adapterScrollback as string) + fresh.writeSync(POST_REPLAY_MODE_RESET) + expect(fresh.isAlternateScreen).toBe(false) + expect(fresh.getVisibleLines().some((l) => l.includes('Claude Code'))).toBe(true) + fresh.dispose() + }) + + it('an alt-screen snapshot with no drawn content restores harmlessly (no alt-screen re-entry)', async () => { + const manager = new HistoryManager(dir) + const reader = new HistoryReader(dir) + const em = new HeadlessEmulator({ cols: 80, rows: 24 }) + // Alt-screen entered but nothing drawn. SerializeAddon still emits a bare + // cursor-home (\x1b[H), so the payload is non-null but visually empty — + // safe to write into the fresh shell and crucially never re-enters + // alt-screen (rehydrateSequences is omitted). + em.writeSync(ALT_SCREEN_ON) + + await manager.openSession(sessionId, { cwd: '/home/user/project', cols: 80, rows: 24 }) + await manager.checkpoint(sessionId, em.getSnapshot()) + em.dispose() + + const info = reader.detectColdRestore(sessionId) + expect(info!.modes.alternateScreen).toBe(true) + const adapterScrollback = info!.scrollbackAnsi || info!.snapshotAnsi || null + expect(adapterScrollback).not.toContain(ALT_SCREEN_ON) + + const fresh = new HeadlessEmulator({ cols: 80, rows: 24 }) + fresh.writeSync('\x1b[2J\x1b[3J\x1b[H') + if (adapterScrollback) { + fresh.writeSync(adapterScrollback) + } + expect(fresh.isAlternateScreen).toBe(false) + fresh.dispose() + }) +})