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>
This commit is contained in:
garrytanandcapy-ai[bot] committed 2026-10-17 19:36:43 +00:00
1 parent 20d77403e4
commit 6fa896384a
2 files changed
+57 -5

No files matched your search

+21 -5
View File
@@ -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<string>;
/**
* 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<void> | undefined;
screenFailure: unknown;
lastScreen: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
proc: any;
exitedPromise: Promise<void>;
@@ -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<void> {
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;
+36
View File
@@ -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);
});