Ctrl K
ComunidadGuías…

test/pty-chunking.test.js

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
// 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');
  });
});