mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-31 22:14:25 +01:00
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:
1 parent
20d77403e4
commit
6fa896384a
2 files changed
+57
-5
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user