Files
Codeman/test/tmux-capture-full-history.test.ts
T
Aamer Akhter a29192a248 fix(agent): do not rerun a pane capture whose combined call failed
The combined capture-pane and cursor query swallowed a thrown error and the
caller then ran the same capture again, so a hung tmux blocked the event loop
for two 5 s timeouts on every visible-frame capture, which serves every
GET .../terminal load. A thrown error now reaches the capture's own catch;
only output that splitFrameAndCursor cannot split falls back to the two
separate calls. The capture runs through a captureRunner() seam, like
inputRunner(), so the tests count the tmux calls each path makes.
2026-10-26 09:04:52 -04:00

355 lines
16 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* COD-47: full tmux scrollback replay on reload.
*
* Under VITEST, TmuxManager no-ops execSync (IS_TEST_MODE), so we can't drive
* real tmux. Instead we assert the capture-arg construction directly from
* source (same approach as tmux-capture-color.test.ts): a full-history capture
* must use `capture-pane -p -e -J -S -<N>` (bounded to the configured history
* limit, with an explicit exec maxBuffer) and skip the single-screen snapshot
* repaint, while the visible capture keeps `capture-pane -p -e`.
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { describe, expect, it, vi } from 'vitest';
import {
formatCursorRestore,
formatPaneSnapshot,
hasVisibleContent,
queryPaneCursor,
splitFrameAndCursor,
TmuxManager,
} from '../src/tmux-manager.js';
describe('tmux full-history pane capture (COD-47)', () => {
const source = readFileSync(resolve(import.meta.dirname, '../src/tmux-manager.ts'), 'utf8');
const methodStart = source.indexOf('capturePaneBuffer(muxName: string');
// Bounded at the next method so `methodBody` really is one method: the
// ordering assertions below would otherwise be satisfiable by a neighbour.
const methodEnd = source.indexOf('captureActivePaneBuffer(muxName: string', methodStart);
const methodBody = source.slice(methodStart, methodEnd);
it('capturePaneBuffer accepts pane-capture options with a fullHistory flag', () => {
expect(methodStart).toBeGreaterThan(-1);
// The method signature must carry the opts channel...
expect(source.slice(methodStart, methodStart + 160)).toContain('PaneCaptureOptions');
// ...and the body must branch on opts.fullHistory.
expect(methodBody).toContain('opts?.fullHistory === true');
});
it('full-history mode captures scrollback bounded to the configured history limit (-J -S -<N>)', () => {
// `-S -<N>` (not unbounded `-S -`) keeps tmux from serializing more
// scrollback than the configured history limit retains; `-J` re-joins
// lines hard-wrapped at the capture-time pane width.
expect(source).toContain('capture-pane -p -e -J -S -${historyLines}');
});
it('a full capture with a scrollbackOverride (remote history) reads the visible frame only (-J -S 0)', () => {
// The remote tmux supplied the history; reading local history too would
// splice two histories above one frame.
expect(methodBody).toContain('scrollbackOverride !== undefined');
expect(methodBody).toContain("'capture-pane -p -e -J -S 0'");
expect(methodBody).toContain('joinScrollbackAndFrame(');
});
it('full-history exec sets an explicit maxBuffer (default 1MB would ENOBUFS multi-MB dumps)', () => {
expect(methodBody).toContain('maxBuffer');
expect(methodBody).toContain('FULL_HISTORY_CAPTURE_SLACK_BYTES');
});
it('still offers the visible single-screen capture for fast tab switches', () => {
expect(source).toContain("'capture-pane -p -e'");
});
it('returns full-history capture as raw scrollback (skips the single-screen repaint)', () => {
// The fullHistory branch returns before the formatPaneSnapshot repaint,
// which is single-screen and would clip a multi-screen history.
const branch = methodBody.indexOf('if (fullHistory) {\n // Without geometry');
const snapshot = methodBody.indexOf('formatPaneSnapshot(');
expect(branch).toBeGreaterThan(-1);
expect(snapshot).toBeGreaterThan(-1);
expect(branch).toBeLessThan(snapshot);
// …and what it returns is normalized linear scrollback, not a repaint.
expect(methodBody.slice(branch, snapshot)).toContain('normalizeScrollbackEol(');
});
it('appends the pane cursor to the full-history capture', () => {
// A linear replay leaves the caret wherever the last character landed — the
// status line, for an agent CLI — and every cursor-relative update the CLI
// sends afterwards is then measured from the wrong row.
const restore = methodBody.indexOf('formatCursorRestore(geometry)');
const snapshot = methodBody.indexOf('formatPaneSnapshot(');
expect(restore).toBeGreaterThan(-1);
expect(restore).toBeLessThan(snapshot);
});
it('keeps the trailing rows only when a cursor move will follow', () => {
// Trailing blank rows are the bottom of the screen and the cursor move counts
// up from them, so the two decisions travel together: no geometry, no move,
// and the old trim applies instead.
expect(methodBody).toContain("rawCapture.replace(/\\n$/, '')");
expect(methodBody).toContain("const trimmed = rawCapture.replace(/\\n+$/g, '')");
});
it('defers to the byte history when the pane holds nothing visible', () => {
expect(methodBody).toContain("if (!hasVisibleContent(trimmed)) return ''");
});
it('captureActivePaneBuffer forwards the capture options', () => {
const sig = source.indexOf('captureActivePaneBuffer(muxName: string');
expect(sig).toBeGreaterThan(-1);
const body = source.slice(sig, sig + 800);
expect(body).toContain('opts');
expect(body).toContain('this.capturePaneBuffer(muxName, target, opts)');
});
});
describe('full-history cursor restore', () => {
it('counts up from the last replayed row rather than down from the top', () => {
// Relative, not `CUP`: absolute row addressing is only correct while the
// browser's row count equals the pane's, and resizeWindow does not wait for
// tmux, so a capture can be taken before a requested resize has applied.
expect(formatCursorRestore({ cols: 80, rows: 24, cursorX: 2, cursorY: 20 })).toBe('\x1b[3A\r\x1b[2C');
});
it('emits no row move when the caret is already on the last row', () => {
expect(formatCursorRestore({ cols: 80, rows: 24, cursorX: 5, cursorY: 23 })).toBe('\r\x1b[5C');
});
it('emits no column move for column zero', () => {
expect(formatCursorRestore({ cols: 80, rows: 10, cursorX: 0, cursorY: 0 })).toBe('\x1b[9A\r');
});
});
describe('hasVisibleContent', () => {
it('is false for a pane of blank rows', () => {
expect(hasVisibleContent('\n'.repeat(23))).toBe(false);
});
it('is false for blank rows carrying only SGR attributes', () => {
// `capture-pane -e` styles every row, so an all-blank pane is not an empty
// string. Treating it as content would replace the byte history with a
// blank screen.
expect(hasVisibleContent('\x1b[m \x1b[0m\n\x1b[m \x1b[0m')).toBe(false);
});
it('is true as soon as one row carries a character', () => {
expect(hasVisibleContent('\x1b[m \x1b[0m\n\x1b[m x \x1b[0m')).toBe(true);
});
});
describe('the geometry a capture reports back', () => {
const source = readFileSync(resolve(import.meta.dirname, '../src/tmux-manager.ts'), 'utf8');
const methodStart = source.indexOf('capturePaneBuffer(muxName: string');
const methodEnd = source.indexOf('captureActivePaneBuffer(muxName: string', methodStart);
const methodBody = source.slice(methodStart, methodEnd);
it('writes the pane size onto the caller options before either replay path returns', () => {
// IS_TEST_MODE no-ops execSync, so assert from source (same approach as the
// capture-flag tests above). The write must precede the fullHistory branch:
// both paths return from inside it, and a caller that got no geometry
// cannot tell a mismatched frame from a matching one.
const write = methodBody.indexOf('opts.capturedGeometry = { cols: geometry.cols, rows: geometry.rows }');
// Anchor on the REPLAY branch, not the earlier `if (fullHistory)` that only
// sizes the exec buffer.
const replayBranch = methodBody.indexOf('if (!geometry) {');
const visibleReturn = methodBody.indexOf('if (geometry) return formatPaneSnapshot(');
expect(write).toBeGreaterThan(-1);
expect(replayBranch).toBeGreaterThan(-1);
expect(visibleReturn).toBeGreaterThan(-1);
expect(write).toBeLessThan(replayBranch);
expect(write).toBeLessThan(visibleReturn);
});
it('reports nothing when the cursor query gave no geometry', () => {
// `queryPaneCursor` returns null on a failed or nonsensical query, and the
// snapshot repaint is skipped in that case. Reporting a size anyway would
// describe a frame that was never positioned.
expect(methodBody).toContain('if (opts && geometry)');
});
});
describe('the capture reports how much scrollback tmux holds (#{history_size})', () => {
const source = readFileSync(resolve(import.meta.dirname, '../src/tmux-manager.ts'), 'utf8');
const methodStart = source.indexOf('capturePaneBuffer(muxName: string');
const methodBody = source.slice(methodStart, source.indexOf('captureActivePaneBuffer(muxName: string', methodStart));
it('reads it from the one cursor query the capture already makes', () => {
// No extra tmux call: every capture is a synchronous exec on the server.
expect(methodBody).toContain(
"'#{cursor_x} #{cursor_y} #{pane_width} #{pane_height} #{history_size} #{alternate_on}'"
);
expect(methodBody.match(/display-message/g)).toHaveLength(1);
expect(methodBody).toContain('opts.capturedHistoryLines = geometry.historyLines');
});
it('parses the fifth field as historyLines', () => {
expect(queryPaneCursor(() => '3 5 120 40 812\n')).toEqual({
cursorX: 3,
cursorY: 5,
cols: 120,
rows: 40,
historyLines: 812,
});
// A pane in the alternate screen (fullscreen claude) holds none.
expect(queryPaneCursor(() => '0 31 187 32 0')?.historyLines).toBe(0);
});
it('never lets a missing or bad history field void the caret', () => {
for (const raw of ['3 5 120 40', '3 5 120 40 ', '3 5 120 40 x', '3 5 120 40 -1']) {
const geometry = queryPaneCursor(() => raw);
expect(geometry).toEqual({ cursorX: 3, cursorY: 5, cols: 120, rows: 40 });
expect(geometry && 'historyLines' in geometry).toBe(false);
}
});
it('still returns null when the geometry itself is bad', () => {
expect(queryPaneCursor(() => 'x 5 120 40 812')).toBeNull();
expect(
queryPaneCursor(() => {
throw new Error('no pane');
})
).toBeNull();
});
});
describe('why a capture has to report its height', () => {
it('a snapshot addresses rows the receiving terminal may not have', () => {
// formatPaneSnapshot positions every row absolutely. A terminal shorter
// than the pane clamps each address past its own height onto its last
// line, so the overflow rows overwrite one another and the rows underneath
// are lost. Nothing in the escape sequence tells the client this happened —
// hence captureRows on the response.
const lines = Array.from({ length: 50 }, (_, i) => `row-${i + 1}`);
// cursorX 5 keeps the trailing cursor-restore move (`\x1b[50;6H`) out of the
// `;1H` row-paint match below, so the count is row paints alone.
const snapshot = formatPaneSnapshot(lines, { cols: 100, rows: 50, cursorX: 5, cursorY: 49 });
const addressed = [...snapshot.matchAll(/\x1b\[(\d+);1H/g)].map((m) => Number(m[1]));
expect(Math.max(...addressed)).toBe(50);
// A 30-row terminal cannot honour 20 of those addresses.
expect(addressed.filter((row) => row > 30)).toHaveLength(20);
});
});
describe('queryPaneCursor history depth', () => {
it('reads tmux history_size as an optional fifth field', () => {
expect(queryPaneCursor(() => '4 22 133 24 25000\n')).toEqual({
cols: 133,
rows: 24,
cursorX: 4,
cursorY: 22,
historyLines: 25000,
});
});
it('keeps the geometry and omits the depth when the fifth field is missing or invalid', () => {
for (const raw of ['4 22 133 24', '4 22 133 24 abc', '4 22 133 24 -1']) {
const geometry = queryPaneCursor(() => raw);
expect(geometry, raw).toEqual({ cols: 133, rows: 24, cursorX: 4, cursorY: 22 });
}
});
it('reads tmux alternate_on as an optional sixth field', () => {
expect(queryPaneCursor(() => '4 22 133 24 29 1\n')).toEqual({
cols: 133,
rows: 24,
cursorX: 4,
cursorY: 22,
historyLines: 29,
alternateOn: true,
});
expect(queryPaneCursor(() => '4 22 133 24 29 0')?.alternateOn).toBe(false);
for (const raw of ['4 22 133 24 29', '4 22 133 24 29 2', '4 22 133 24 29 x']) {
expect(
queryPaneCursor(() => raw),
raw
).not.toHaveProperty('alternateOn');
}
});
it('asks tmux for the history depth and alternate screen in the same display-message as the cursor', () => {
const source = readFileSync(resolve(import.meta.dirname, '../src/tmux-manager.ts'), 'utf8');
expect(source).toContain("'#{cursor_x} #{cursor_y} #{pane_width} #{pane_height} #{history_size} #{alternate_on}'");
});
});
describe('the visible frame and its caret come from one tmux invocation', () => {
const source = readFileSync(resolve(import.meta.dirname, '../src/tmux-manager.ts'), 'utf8');
const methodStart = source.indexOf('capturePaneBuffer(muxName: string');
const methodBody = source.slice(methodStart, source.indexOf('captureActivePaneBuffer(muxName: string', methodStart));
it('chains the capture and the cursor query with `\;` on the visible path only', () => {
// Two separate execs raced a CLI repaint (codex redraws its composer 30-60 ms
// after input): the frame could predate the caret, and guarded stage refused it.
// Two backslashes in the source: one escaped backslash in the shell command, which hands tmux its `;`.
const chained = '${captureCommand} ' + '\\'.repeat(2) + '; ${cursorQuery}';
expect(methodBody).toContain('exec(`' + chained + '`');
// Output it cannot split falls back to the two calls the capture always made.
expect(methodBody).toContain('combined?.frame ?? exec(captureCommand, execOpts)');
expect(methodBody).toContain('combined\n ? combined.cursor');
});
it('splits the combined output into the rows and the cursor line tmux prints last', () => {
expect(splitFrameAndCursor('› hello\n footer\n\n7 0 100 20 0 0\n')).toEqual({
frame: '› hello\n footer\n\n',
cursor: '7 0 100 20 0 0',
});
expect(splitFrameAndCursor('only\n3 0 80 24\n')).toEqual({ frame: 'only\n', cursor: '3 0 80 24' });
});
it('refuses output whose last line is not the cursor query, so a frame row is never taken for it', () => {
expect(splitFrameAndCursor('› hello\n footer\n')).toBeNull();
expect(splitFrameAndCursor('row\nworld 1 2 3 4\n')).toBeNull();
expect(splitFrameAndCursor('')).toBeNull();
});
});
describe('how many tmux calls one visible-frame capture makes', () => {
type Run = (cmd: string, opts: { encoding: 'utf-8'; timeout: number; maxBuffer?: number }) => string;
class ScriptedTmux extends TmuxManager {
calls: string[] = [];
constructor(private readonly answer: (cmd: string) => string) {
super();
}
protected override captureRunner(): Run {
return (cmd) => {
this.calls.push(cmd.includes(' \\; ') ? 'combined' : cmd.includes('display-message') ? 'cursor' : 'capture');
return this.answer(cmd);
};
}
}
const quiet = () => vi.spyOn(console, 'error').mockImplementation(() => {});
it('makes ONE call when the combined output splits', () => {
const tmux = new ScriptedTmux(() => '› hello\n footer\n7 0 100 20 0 0\n');
expect(tmux.capturePaneBuffer('codeman-12345678')).toContain('hello');
expect(tmux.calls).toEqual(['combined']);
});
it('does not rerun a combined call that threw: a hung tmux costs one timeout, not two', () => {
const errors = quiet();
try {
const tmux = new ScriptedTmux(() => {
throw Object.assign(new Error('spawnSync /bin/sh ETIMEDOUT'), { code: 'ETIMEDOUT' });
});
expect(tmux.capturePaneBuffer('codeman-12345678')).toBeNull();
expect(tmux.calls).toEqual(['combined']);
expect(errors).toHaveBeenCalledTimes(1);
} finally {
errors.mockRestore();
}
});
it('falls back to the two separate calls only for output it cannot split', () => {
const tmux = new ScriptedTmux((cmd) =>
cmd.includes(' \\; ') ? '› hello\n footer\n' : cmd.includes('display-message') ? '7 0 100 20 0 0\n' : '› hello\n'
);
expect(tmux.capturePaneBuffer('codeman-12345678')).toContain('hello');
expect(tmux.calls).toEqual(['combined', 'capture', 'cursor']);
});
it('keeps the full-history path on its two calls', () => {
const tmux = new ScriptedTmux((cmd) => (cmd.includes('display-message') ? '0 0 80 24 0 0\n' : 'row\n'));
tmux.capturePaneBuffer('codeman-12345678', undefined, { fullHistory: true });
expect(tmux.calls).toEqual(['capture', 'cursor']);
});
});