From 6fa896384a3dbf0f8e336fef4e780946e36ce20b Mon Sep 17 00:00:00 2001 From: garrytan Date: Sat, 17 Oct 2026 19:30:47 +0000 Subject: [PATCH] test(pty): keep the last non-blank viewport for failure dumps (W3-11) autoplan-guard-pty (PR gate run 38976253774) stalled at 489 s and its screen dump, the tail of visibleText(), was only spinner redraws. Live PTY sessions now remember the last non-blank viewport a screen read returned (session.lastScreen()) and the harness wait-timeout dumps append it. Diagnostic only: visibleText(), matching and verdicts are unchanged. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- test/helpers/pty/launch.ts | 26 +++++++++++++++++++----- test/pty-screen-session.test.ts | 36 +++++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 5 deletions(-) diff --git a/test/helpers/pty/launch.ts b/test/helpers/pty/launch.ts index ca874247a..884d1008b 100644 --- a/test/helpers/pty/launch.ts +++ b/test/helpers/pty/launch.ts @@ -82,6 +82,13 @@ export interface ClaudePtySession { visibleText(): string; /** Flush the opted-in terminal parser and return only its current viewport. */ currentScreen(deadlineAt?: number): Promise; + /** + * The last non-blank viewport a screen read returned, kept for failure dumps: the tail of visibleText() + * can be nothing but spinner redraws after a stall (autoplan-guard-pty, PR gate run 38976253774). + * '' before any non-blank read, or when screen observation is off. Never used for matching; optional + * because scripted sessions have no viewport history. + */ + lastScreen?(): string; /** Same decoded viewport with styles and input epoch for acknowledged paste. */ currentScreenFrame(deadlineAt?: number): Promise<{ text: string; rawEnd: number; styledText: Array<{ row: number; start: number; text: string; dim: boolean; inverse: boolean }> }>; @@ -187,7 +194,7 @@ export async function launchClaudePty( deadlineAt: Math.min(opts.screenDeadlineAt ?? wallDeadline, wallDeadline), signal: screenAbort.signal, }) : undefined; const pty: PtyProcess = { buffer: '', exited: false, closing: false, exitCode: null, outputWaiters: new Set(), - screen, screenAbort, screenClosing: undefined, screenFailure: undefined, proc: undefined, + screen, screenAbort, screenClosing: undefined, screenFailure: undefined, lastScreen: '', proc: undefined, exitedPromise: Promise.resolve(), wallDeadline, recorders: { pendingFiles: [] } }; try { @@ -369,6 +376,7 @@ interface PtyProcess { screenAbort: AbortController; screenClosing: Promise | undefined; screenFailure: unknown; + lastScreen: string; // eslint-disable-next-line @typescript-eslint/no-explicit-any proc: any; exitedPromise: Promise; @@ -378,6 +386,13 @@ interface PtyProcess { function notifyOutput(pty: PtyProcess): void { for (const done of pty.outputWaiters) done(); } +function keepScreen(pty: PtyProcess, text: string): string { + if (text.trim()) pty.lastScreen = text; + return text; +} + +const lastScreenDump = (pty: PtyProcess) => pty.lastScreen ? `\nLast non-blank screen:\n${pty.lastScreen}` : ''; + function disposeScreen(pty: PtyProcess): Promise { return pty.screenClosing ??= (pty.screen?.dispose() ?? Promise.resolve()).catch(error => { pty.screenFailure = error; }); } @@ -437,7 +452,7 @@ async function waitForAnyOutput( if (pty.exited) { throw new Error( `claude exited (code=${pty.exitCode}) before any pattern matched. ` + - `Last visible:\n${stripAnsi(pty.buffer).slice(-2000)}`, + `Last visible:\n${stripAnsi(pty.buffer).slice(-2000)}${lastScreenDump(pty)}`, ); } const visible = since !== undefined ? stripAnsi(pty.buffer.slice(since)) : stripAnsi(pty.buffer); @@ -455,7 +470,7 @@ async function waitForAnyOutput( .map((p) => (typeof p === 'string' ? JSON.stringify(p) : p.source)) .join(', ')}\nLast visible (since=${since ?? 'all'}):\n${ since !== undefined ? stripAnsi(pty.buffer.slice(since)).slice(-2000) : stripAnsi(pty.buffer).slice(-2000) - }`, + }${lastScreenDump(pty)}`, ); } @@ -527,11 +542,12 @@ function sessionHandle(pty: PtyProcess, launch: LaunchSetup, wallTimer: ReturnTy }, rawOutput: () => pty.buffer, visibleText: () => stripAnsi(pty.buffer), - currentScreen: async (deadlineAt?: number) => readable().read(deadlineAt), + currentScreen: async (deadlineAt?: number) => keepScreen(pty, await readable().read(deadlineAt)), currentScreenFrame: async (deadlineAt?: number) => { const frame = await readable().readFrame(deadlineAt); - return {text: frame.text, rawEnd: frame.inputOffset, styledText: frame.styledText}; + return {text: keepScreen(pty, frame.text), rawEnd: frame.inputOffset, styledText: frame.styledText}; }, + lastScreen: () => pty.lastScreen, mark: () => (lastMark = pty.buffer.length), waitForOutput: async (since: number, timeoutMs: number) => { if (pty.buffer.length > since || pty.exited || pty.closing) return; diff --git a/test/pty-screen-session.test.ts b/test/pty-screen-session.test.ts index 1b01e4845..36ba0b474 100644 --- a/test/pty-screen-session.test.ts +++ b/test/pty-screen-session.test.ts @@ -148,4 +148,40 @@ catch(error){if(!String(error).includes('PTY screen unavailable') || !String(err expect(fs.existsSync(f.dispatched)).toBe(false); } finally { fs.rmSync(f.dir, { recursive: true, force: true }); } }, 15_000); + + // PR gate run 38976253774 (autoplan-guard-pty): after a stall the tail of visibleText() was only spinner redraws, + // so the failure dump showed no screen. Diagnostic only: visibleText() and matching are unchanged. + test('a blank redraw keeps the last non-blank viewport for the failure dump without changing visibleText', () => { + const f = fixture(); + try { + fs.writeFileSync(f.fake, `#!${process.execPath} +process.stdin.setRawMode?.(true); +process.stdout.write('\\x1b[?1049h\\x1b[2J\\x1b[HWaiting for next invocation to continue with step 8.\\r\\n'); +process.stdin.on('data', data => { + if (data.toString().includes('blank')) process.stdout.write('\\x1b[2J\\x1b[H' + '\\x1b[?25l\\r \\x1b[?25h'.repeat(40)); + if (data.toString().includes('exit')) process.exit(0); +}); +setInterval(() => {}, 1000); +`, { mode: 0o755 }); + const result = run(f.dir, f.fake, ` +import { launchClaudePty } from ${JSON.stringify(runner)}; +const session = await launchClaudePty({ cwd: ${JSON.stringify(f.dir)}, observeScreen: true, cols: 60, rows: 6, timeoutMs: 8000 }); +const until = async predicate => { for (let i = 0; i < 200; i++) { const screen = await session.currentScreen(); if (predicate(screen)) return screen; await Bun.sleep(20); } throw new Error('fixture screen timeout'); }; +try { + if (session.lastScreen() !== '') throw new Error('lastScreen before any read'); + const first = await until(s => s.includes('step 8')); + session.send('blank'); + await until(s => !s.trim()); + const report = { kept: session.lastScreen() === first, visible: session.visibleText().includes('step 8') }; + let dump = ''; + try { await session.waitFor('never printed', { timeoutMs: 200, pollMs: 20 }); } catch (error) { dump = String(error); } + report.dump = dump.includes('Last non-blank screen:') && dump.includes('Waiting for next invocation to continue with step 8.'); + session.send('exit'); + process.stdout.write(JSON.stringify(report)); +} finally { await session.close(); } +`); + expect(result.status, result.stderr).toBe(0); + expect(JSON.parse(result.stdout)).toEqual({ kept: true, visible: true, dump: true }); + } finally { fs.rmSync(f.dir, { recursive: true, force: true }); } + }, 15_000); });