test/pty-chunking.test.js
Rama main · réplica de lectura del repositorio original.
// A pty read is not a line. These tests drive the real onData handler with a
// captured AskUserQuestion frame cut at chunk boundaries, because that is the
// only thing that differs between a dialog that detects and one that does not
// -- handing detectState a tidy pre-split line (helpers.test.js) passes either
// way, which is exactly why the split-line bug shipped.
import { describe, it, expect, vi } from 'vitest';
import { setupPtyHandlers, trackSyncFrames } from '../server/pty.js';
import { detectState } from '../lib/helpers.js';
import { ASK_QUESTION_FRAME, FOOTER_AT } from './fixtures/ask-user-question-frame.js';
import { BASH_DIALOG, EDIT_DIALOG } from './fixtures/claude-permission-dialogs.js';
// Split out from feed() so a test can drive chunks one at a time and inspect
// the session between them, which is what the lastOutputAt case below needs.
function openSession() {
let onData;
const session = {
id: 's1',
pty: { onData: (cb) => { onData = cb; }, onExit: () => {} },
ringBuffer: { push: () => {} },
state: 'WORKING',
lastOutputAt: Date.now(),
lastResizeAt: 0,
lastStrippedLine: '',
recentStrippedLines: [],
pendingRaw: '',
isTUI: true,
exited: false,
};
setupPtyHandlers(session, 's1', () => {});
clearInterval(session.stateCheckInterval);
return { session, write: (chunk) => onData(chunk) };
}
function feed(chunks) {
const { session, write } = openSession();
for (const chunk of chunks) write(chunk);
// The dialog is parked: nothing more arrives, so the WORKING window lapses.
session.lastOutputAt = 0;
return session;
}
const cutAt = (s, at) => [s.slice(0, at), s.slice(at)];
describe('PTY line reassembly across chunk boundaries', () => {
// Codex draws its bottom pane inside DEC 2026 synchronized-output frames,
// with cursor moves rather than newlines. What follows is the shape of a
// real capture (codex-cli 0.153): the /permissions picker, the answer, and
// the prompt redrawn — where the picker's lines were the newest thing the
// five-line window ever saw, and the bubble stayed on.
const F = (body) => `\x1b[?2026h${body}\x1b[?2026l`;
const PICKER = F('\x1b[19;1H\x1b[K Update Model Permissions\x1b[20;1H\x1b[K › 1. Ask for approval Codex can read and edit files in the current workspace\x1b[21;1H\x1b[K 2. Approve for me Only ask for actions detected as potentially unsafe.\x1b[22;1H\x1b[K 3. Full Access\x1b[23;1H\x1b[K Press enter to confirm');
const PROMPT = F('\x1b[21;1H\x1b[K › Ask Codex to do anything\x1b[23;3H\x1b[K gpt-6-astra medium · ~/wt/Vid-GTM\x1b[0 q');
const ANSWERED = '\x1b[0 q\x1bM\x1bM• Permissions updated to Full Access\n';
it('shows the Codex picker while it is open, and drops it once it is answered', () => {
const { session, write } = openSession();
write('Everything remains on one sheet.\n');
write(PICKER);
session.lastOutputAt = 0;
expect(detectState(session)).toBe('MESSAGE');
// The user picks Full Access: Codex prints the outcome as a history line
// and repaints the pane as the bare prompt.
write(ANSWERED + PROMPT);
session.lastOutputAt = 0;
expect(session.lastFrame).toContain('Ask Codex to do anything');
expect(detectState(session)).toBe('WAITING');
});
it('keeps the picker across a frame split at every read boundary', () => {
for (let at = 1; at < PICKER.length; at++) {
const { session, write } = openSession();
write(PICKER.slice(0, at));
write(PICKER.slice(at));
session.lastOutputAt = 0;
expect(session.lastFrame, `split at ${at}`).toContain('Update Model Permissions');
expect(detectState(session), `split at ${at}`).toBe('MESSAGE');
}
});
it('ignores a frame that only blinks the cursor', () => {
const { session, write } = openSession();
write(PICKER);
write(F('\x1b[0 q \x1b[23;3H')); // what Codex sends while idle, over and over
session.lastOutputAt = 0;
expect(session.lastFrame).toContain('Update Model Permissions');
expect(detectState(session)).toBe('MESSAGE');
});
it('bounds a frame that never closes, and keeps the head and the tail of one too long to keep whole', () => {
const session = { frameOpen: null, lastFrame: '' };
trackSyncFrames(session, '\x1b[?2026h' + 'x'.repeat(100 * 1024));
expect(session.frameOpen.length).toBe(64 * 1024);
trackSyncFrames(session, 'y'.repeat(10) + '\x1b[?2026l');
expect(session.frameOpen).toBeNull();
// 2000 of head, a joiner, 2000 of tail: a dialog's question sits first and
// its answers last, and the agent controls how long the middle runs.
expect(session.lastFrame.length).toBe(2 * 2000 + 3);
expect(session.lastFrame.startsWith('x'.repeat(2000))).toBe(true);
// The cap keeps a marker's worth of bytes past it, so an end marker that
// straddles the read after an oversized body still closes the frame.
const big = { frameOpen: null, lastFrame: '' };
trackSyncFrames(big, '\x1b[?2026h' + 'z'.repeat(70 * 1024) + '\x1b[?20');
trackSyncFrames(big, '26l');
expect(big.frameOpen).toBeNull();
expect(big.lastFrame.startsWith('zzz')).toBe(true);
});
it('forgets a cancelled picker: the prompt repaint that follows carries no newline, and the carry restarts after a frame', () => {
// Esc closes the picker without a history line, so nothing ever cuts the
// raw carry — and the last line, checked before the frame, is the head of
// that carry. The picker must not be it.
const { session, write } = openSession();
write(PICKER);
session.lastOutputAt = 0;
expect(detectState(session)).toBe('MESSAGE');
write(PROMPT);
for (let i = 0; i < 5; i++) write(F('\x1b[0 q \x1b[23;3H')); // idle blinks
session.lastOutputAt = 0;
expect(session.lastStrippedLine).not.toContain('Update Model Permissions');
expect(session.lastFrame).toContain('Ask Codex to do anything');
expect(detectState(session)).toBe('WAITING');
});
it('takes the last of two frames drawn in one read', () => {
const { session, write } = openSession();
write(PICKER + PROMPT);
expect(session.lastFrame).toContain('Ask Codex to do anything');
expect(session.lastFrame).not.toContain('Update Model Permissions');
expect(session.frameOpen).toBeNull();
// And the other way round: the picker drawn after the prompt is what stands.
write(PROMPT + PICKER);
session.lastOutputAt = 0;
expect(session.lastFrame).toContain('Update Model Permissions');
expect(detectState(session)).toBe('MESSAGE');
});
it('carries a frame across a read that holds neither marker', () => {
const { session, write } = openSession();
const [head, rest] = cutAt(PICKER, 40);
const [middle, tail] = cutAt(rest, 60);
write(head);
write(middle);
expect(typeof session.frameOpen).toBe('string');
expect(session.lastFrame).toBeUndefined(); // nothing complete yet
write(tail);
session.lastOutputAt = 0;
expect(session.frameOpen).toBeNull();
expect(session.lastFrame).toContain('Update Model Permissions');
expect(session.lastFrame).toContain('Press enter to confirm');
expect(detectState(session)).toBe('MESSAGE');
});
it('leaves the last frame alone when a read draws no frame', () => {
const { session, write } = openSession();
// Before any frame: the field is never touched, so the window still rules.
write('Enter to select · Esc to cancel\n');
expect(session.lastFrame).toBeUndefined();
session.lastOutputAt = 0;
expect(detectState(session)).toBe('MESSAGE');
// After one: a plain history line neither opens nor replaces it. Both
// reads share one clock tick, so the tie goes to the frame; a line in a
// later millisecond would outrank it, which the shell-tab test below owns.
const clock = vi.spyOn(Date, 'now').mockReturnValue(Date.now());
try {
write(PROMPT);
write('• Ran git status -sb\n');
} finally {
clock.mockRestore();
}
expect(session.frameOpen).toBeNull();
expect(session.lastFrame).toContain('Ask Codex to do anything');
session.lastOutputAt = 0;
expect(detectState(session)).toBe('WAITING');
});
it('shows a Codex command approval while it is open, and drops it once answered', () => {
const APPROVAL = F('\x1b[18;1H\x1b[K Would you like to run the following command?\x1b[19;1H\x1b[K $ git push origin main\x1b[21;1H\x1b[K › 1. Yes, proceed\x1b[22;1H\x1b[K 2. Yes, and don\'t ask again for commands that start with `git push`\x1b[23;1H\x1b[K 3. No, and tell Codex what to do differently');
const { session, write } = openSession();
write('• Explored the repo and drafted the change.\n');
write(APPROVAL);
session.lastOutputAt = 0;
expect(session.lastFrame).toContain('Would you like to run the following command?');
expect(detectState(session)).toBe('MESSAGE');
// The user approves: the command's outcome becomes a history line and the
// pane is repainted as the bare prompt.
write('\x1b[0 q\x1bM• Ran git push origin main\n' + PROMPT);
session.lastOutputAt = 0;
expect(detectState(session)).toBe('WAITING');
});
it('finds a begin marker cut by a read boundary, so the answered prompt is not missed', () => {
// Codex idles by blinking the cursor in frames that say nothing, so a
// prompt repaint lost to a split marker would leave the picker standing,
// and the bubble lit, until the user typed something.
for (let at = 1; at < 8; at++) { // every cut inside "\x1b[?2026h"
const { session, write } = openSession();
write(PICKER);
const [head, tail] = cutAt(PROMPT, at);
write(ANSWERED + head);
write(tail);
write(F('\x1b[0 q \x1b[23;3H'));
session.lastOutputAt = 0;
expect(session.frameOpen, `cut at ${at}`).toBeNull();
expect(session.lastFrame, `cut at ${at}`).toContain('Ask Codex to do anything');
expect(detectState(session), `cut at ${at}`).toBe('WAITING');
}
});
it('finds an end marker cut by a read boundary, so the picker is not glued onto the prompt', () => {
for (let at = 1; at < 8; at++) { // every cut inside "\x1b[?2026l"
const { session, write } = openSession();
const [head, tail] = cutAt(PICKER, PICKER.length - at);
write(head);
write(tail + ANSWERED + PROMPT);
write(F('\x1b[0 q \x1b[23;3H'));
session.lastOutputAt = 0;
expect(session.lastFrame, `cut at ${at}`).not.toContain('Update Model Permissions');
expect(detectState(session), `cut at ${at}`).toBe('WAITING');
}
});
it('restarts a frame when a new begin marker arrives before the end', () => {
// A frame that never closed is not the pane; the repaint that follows is.
const { session, write } = openSession();
write('\x1b[?2026h\x1b[19;1H Update Model Permissions › 1. Ask for approval');
expect(typeof session.frameOpen).toBe('string');
write(ANSWERED + PROMPT);
session.lastOutputAt = 0;
expect(session.frameOpen).toBeNull();
expect(session.lastFrame).not.toContain('Update Model Permissions');
expect(session.lastFrame).toContain('Ask Codex to do anything');
expect(detectState(session)).toBe('WAITING');
// In one read as well.
write('\x1b[?2026h\x1b[19;1H Update Model Permissions' + PROMPT + '\x1b[?2026h stray');
expect(session.lastFrame).not.toContain('Update Model Permissions');
expect(session.frameOpen).toContain('stray');
});
it('keeps the head and the tail of a frame too long to keep whole', () => {
// A dialog states its question first and lists its answers last; what runs
// between them is the agent's to make as long as it likes.
const { session, write } = openSession();
write(F('Would you like to run the following command?' + ' x'.repeat(3000) + ' › 1. Yes, proceed'));
session.lastOutputAt = 0;
expect(session.lastFrame.length).toBeLessThanOrEqual(2 * 2000 + 3);
expect(session.lastFrame.startsWith('Would you like to run the following command?')).toBe(true);
expect(session.lastFrame.endsWith('› 1. Yes, proceed')).toBe(true);
expect(detectState(session)).toBe('MESSAGE');
// Under the limit, it is kept whole (a fresh pane: a frame without the
// composer or status row would merge onto the one above).
const fresh = openSession();
fresh.write(F('Would you like to run the following command?' + ' x'.repeat(100) + ' › 1. Yes, proceed'));
expect(fresh.session.lastFrame).not.toContain(' … ');
});
it('ignores a read that is not a string, and a frame that says nothing', () => {
const session = { frameOpen: null, frameTail: '', lastFrame: '' };
trackSyncFrames(session, Buffer.from('\x1b[?2026h Update Model Permissions \x1b[?2026l'));
trackSyncFrames(session, '');
trackSyncFrames(session, undefined);
expect(session).toEqual({ frameOpen: null, frameTail: '', lastFrame: '' });
// Cursor-shape remnants, whitespace, box-drawing rules and half-stripped
// escapes are not a repaint of the pane.
trackSyncFrames(session, F(' Update Model Permissions '), 1000);
for (const nothing of ['\x1b[0 q \x1b[23;3H', ' ', '────────────', '\x1b[K', '12;3H', '⠋ ⠙ ⠹']) {
trackSyncFrames(session, F(nothing), 2000);
expect(session.lastFrame, JSON.stringify(nothing)).toBe('Update Model Permissions');
expect(session.lastFrameAt, JSON.stringify(nothing)).toBe(1000);
}
});
it('reads the window again once a program stops painting in frames', () => {
// A shell tab: Codex ran and drew frames, then exited, then Claude Code
// (which draws none) asked a question in plain lines. Time is the judge.
const clock = vi.spyOn(Date, 'now');
try {
const { session, write } = openSession();
clock.mockReturnValue(10_000);
write(PROMPT);
expect(session.lastFrameAt).toBe(10_000);
clock.mockReturnValue(20_000);
write('Enter to select · Esc to cancel\r\n');
expect(detectState(session, { now: 60_000 })).toBe('MESSAGE');
// And the other way: a stale picker frame does not outrank newer plain
// output. (Codex exits; the shell's lines and the next program's banner
// then push the pane's text out of the window, as any lines would.)
clock.mockReturnValue(30_000);
write(PICKER);
clock.mockReturnValue(40_000);
write('\r\n$ claude\r\n Welcome back!\r\n cwd: ~/wt\r\n Tips for getting started\r\n Run /init\r\n Use /help\r\n$ ');
expect(session.lastFrameAt).toBe(30_000);
expect(session.lastOutputAt).toBe(40_000);
expect(detectState(session, { now: 60_000 })).toBe('WAITING');
} finally {
clock.mockRestore();
}
});
// Claude Code 2.1.273's permission dialogs, replayed read for read. Its
// answers changed ("Yes, and always allow access to …", "Yes, and switch
// to auto mode", a "Tab to amend" footer), and the question is pushed out
// of the five-line window by them; the one thing that used to catch it was
// the bare "allow" this change removed.
it('detects the Claude Code 2.1 Bash permission dialog, as captured', () => {
const { session, write } = openSession();
for (const chunk of BASH_DIALOG) write(chunk);
session.lastOutputAt = 0;
expect(session.lastFrame ?? '').toBe(''); // Claude draws no frames
expect(detectState(session)).toBe('MESSAGE');
});
it('detects the Claude Code 2.1 edit permission dialog, as captured', () => {
const { session, write } = openSession();
for (const chunk of [...BASH_DIALOG, ...EDIT_DIALOG]) write(chunk);
session.lastOutputAt = 0;
expect(detectState(session)).toBe('MESSAGE');
});
// A frame that carries the composer or the status row is the pane; any
// other is a partial repaint and merges onto it. Codex repaints just the
// option rows on arrow keys, re-inserts history boxes after a resize, and
// prints each history line as a frame of its own.
it('keeps the picker through a resize, when Codex repaints the pane and then re-inserts history', () => {
const { session, write } = openSession();
write(PICKER);
session.lastResizeAt = Date.now();
write(F('\x1b[2J' + PICKER.slice(8, -8))); // the pane again, full repaint
write(F('╭──╮ │ ✨ Update available! │ ╰──╯')); // then the history boxes, frame by frame
write(F('Tip: Try the Desktop app.'));
session.lastOutputAt = 0;
expect(session.lastFrame).toContain('Update Model Permissions');
expect(detectState(session)).toBe('MESSAGE');
// Answered straight away, inside what used to be a two-second window: the
// prompt repaint carries the composer, so it is the pane and replaces.
write(ANSWERED + PROMPT);
for (let i = 0; i < 5; i++) write(F('\x1b[0 q \x1b[23;3H'));
session.lastOutputAt = 0;
expect(session.lastFrame).not.toContain('Update Model Permissions');
expect(detectState(session)).toBe('WAITING');
});
it('keeps the picker while the user arrows through it, though Codex repaints only the option rows', () => {
const { session, write } = openSession();
write(PICKER);
write(F('\x1b[20;1H\x1b[K 1. Ask for approval (current) Codex can read and edit files\x1b[21;1H\x1b[K › 2. Approve for me Only ask for actions detected as potentially unsafe.'));
session.lastOutputAt = 0;
expect(session.lastFrame).toContain('Update Model Permissions');
expect(detectState(session)).toBe('MESSAGE');
});
it('knows the pane by its status row when the composer is not empty', () => {
const { session, write } = openSession();
session.worktreePath = '/Users/me/.agent-007/worktrees/video-2df8/Vid-GTM';
write(PICKER);
write(F('\x1b[21;1H\x1b[K › /perm\x1b[23;3H\x1b[K gpt-6-astra medium · ~/.agent-007/worktrees/video-2df8/Vid-GTM'));
session.lastOutputAt = 0;
expect(session.lastFrame).not.toContain('Update Model Permissions');
expect(detectState(session)).toBe('WAITING');
});
it('is not fooled by a window-title write split across reads while a dialog waits', () => {
// Codex writes its title outside any frame every second while an approval
// waits; whole, it strips to nothing, but a read boundary inside it leaves
// a remnant that looks like output. Only a whole line may outrank a frame.
const { session, write } = openSession();
write(F('\x1b[19;1H\x1b[K Would you like to run the following command?\x1b[20;1H\x1b[K › 1. Yes, proceed'));
const title = '\x1b]0;[ ! ] Action Required | Request approval for probe command | codex\x07';
for (let i = 0; i < 6; i++) { write(title.slice(0, 40)); write(title.slice(40)); }
session.lastOutputAt = 0;
expect(detectState(session)).toBe('MESSAGE');
});
it('abandons a frame left open for more than a second', () => {
const session = { frameOpen: null, lastFrame: 'the pane', frameTail: '' };
trackSyncFrames(session, '\x1b[?2026hhalf a repaint', 1000);
expect(session.frameOpen).toBe('half a repaint');
const { outside } = trackSyncFrames(session, 'a plain line\n', 2500);
expect(session.frameOpen).toBeNull();
expect(outside).toBe('a plain line\n');
expect(session.lastFrame).toBe('the pane');
});
it('does not read frames for a session whose CLI is not Codex', () => {
const { session, write } = openSession();
session.framesTrusted = false;
write(PICKER);
expect(session.lastFrame ?? '').toBe('');
// The bytes still reach the line stream, as they always did.
expect(session.lastStrippedLine).toContain('Update Model Permissions');
});
it('detects the question dialog when the footer is cut in half', () => {
expect(detectState(feed(cutAt(ASK_QUESTION_FRAME, FOOTER_AT + 20)))).toBe('MESSAGE');
});
it('detects it at every other split offset too', () => {
// Half-done carry-over (say, dropping the tail rather than prepending it)
// still passes the one cut above; it does not survive all of these.
for (let at = 1; at < ASK_QUESTION_FRAME.length; at += 7) {
expect(detectState(feed(cutAt(ASK_QUESTION_FRAME, at))), `split at ${at}`).toBe('MESSAGE');
}
});
it('detects it when the footer is the last row and gets no trailing newline', () => {
const noNewline = ASK_QUESTION_FRAME.slice(0, ASK_QUESTION_FRAME.lastIndexOf('\r\r\n'));
expect(detectState(feed(cutAt(noNewline, FOOTER_AT + 20)))).toBe('MESSAGE');
});
it('bounds the carried-over tail when a line never gets a newline', () => {
// An agent controls this text, so an unterminated row must not grow the
// carry forever. Trimming from the left keeps the join with the next
// chunk intact: the footer that follows still detects.
const session = feed(['x'.repeat(3000), 'x\r\nEnter to select · Esc to cancel']);
expect(session.pendingRaw.length).toBeLessThanOrEqual(2000);
expect(detectState(session)).toBe('MESSAGE');
});
it('does not count carried-over text as new output', () => {
// hasContent was read off the accumulated carry, so once an unterminated
// line sat in it, ANY later chunk -- a bare cursor move, a bell --
// re-counted that same text as fresh output and bumped lastOutputAt.
// detectState returns WORKING while that window is open, so a TUI emitting
// periodic control sequences masks the dialog indefinitely: exactly the
// failure this file exists to stop, reached through another door.
const { session, write } = openSession();
write('Enter to select \u00b7 Esc to cancel'); // footer, no trailing newline
session.lastOutputAt = 0; // let the WORKING window lapse
expect(detectState(session)).toBe('MESSAGE');
write('\u001b[18A'); // pure cursor move: nothing visible
expect(session.lastOutputAt).toBe(0);
expect(detectState(session)).toBe('MESSAGE');
});
it('does not park an unterminated fragment in the five-line window', () => {
// Otherwise a frame whose last row never gets a newline latches a fragment
// into the window, and every later state check keeps matching against it.
const session = feed(['done.\r\n', 'Allow claude to read x']);
expect(session.recentStrippedLines).toEqual(['done.']);
// Still scanned, though -- via lastStrippedLine, which is overwritten.
expect(detectState(session)).toBe('MESSAGE');
});
});