COD-47 replay full tmux scrollback on terminal reload

A full page reload (GET /api/sessions/:id/terminal with no ?tail=) now captures
the ENTIRE tmux scrollback via capture-pane -p -e -S -, so users get back history
that scrolled off Codeman's byte buffer. Tab switches (?tail=N) keep the fast
visible-frame capture.

- tmux-manager capturePaneBuffer/captureActivePaneBuffer take { fullHistory }:
  full-history returns raw linear scrollback (skips the single-screen
  formatPaneSnapshot repaint, which would clip multi-screen history).
- /terminal selects full-history on full reload, visible on tail; caps the
  payload at the configured terminalBufferMaxBytes (keeps most-recent bytes,
  line-aligned) and returns source/fullSize/truncated metadata.

Verified: tsc 0, tmux-capture-full-history 5/5, session-routes 68/68.
Caveat: lines tmux already evicted past its history-limit can't be recovered.
This commit is contained in:
Aamer Akhter
2026-07-10 11:08:57 -04:00
parent 1fa88cd187
commit 8be83cd585
5 changed files with 378 additions and 15 deletions
+261 -1
View File
@@ -481,6 +481,264 @@ describe('session-routes', () => {
expect(body.data.terminalBuffer).toBeDefined();
});
it('does not strip VPA-like shell scrollback as Ink redraw bloat', async () => {
const shellHistory = Array.from(
{ length: 3000 },
(_, index) => `SHELL_SCROLLBACK_${String(index + 1).padStart(6, '0')} payload payload payload \x1b[1d`
).join('\n');
harness.ctx._session.terminalBuffer = shellHistory;
harness.ctx._session.mode = 'shell';
(harness.ctx.mux as { captureActivePaneBuffer?: unknown }).captureActivePaneBuffer = vi.fn(() => null);
const res = await harness.app.inject({
method: 'GET',
url: `/api/sessions/${harness.ctx._sessionId}/terminal`,
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.data.terminalBuffer).toContain('SHELL_SCROLLBACK_000001');
expect(body.data.terminalBuffer).toContain('SHELL_SCROLLBACK_003000');
});
it('preserves accumulated history before the live mux pane snapshot for Codex TUI replay', async () => {
harness.ctx._session.terminalBuffer = 'hello world\nlater accumulated history';
harness.ctx._session.mode = 'codex';
(harness.ctx.mux as { captureActivePaneBuffer?: unknown }).captureActivePaneBuffer = vi.fn(
() => 'visible tmux pane only\n› current prompt'
);
const res = await harness.app.inject({
method: 'GET',
url: `/api/sessions/${harness.ctx._sessionId}/terminal`,
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.data.terminalBuffer).toContain('hello world');
expect(body.data.terminalBuffer).toContain('later accumulated history');
expect(body.data.terminalBuffer).toContain('\x1b[H\x1b[2Jvisible tmux pane only');
expect(body.data.terminalBuffer.indexOf('hello world')).toBeLessThan(
body.data.terminalBuffer.indexOf('visible tmux pane only')
);
expect(harness.ctx.mux.captureActivePaneBuffer).toHaveBeenCalledWith(harness.ctx._session.muxName, {
fullHistory: true,
});
});
// ── COD-47: full tmux scrollback replay on full page reload ──
it('full reload (no tail) requests full tmux history and replays boundary markers', async () => {
// A realistic scrollback-length capture: ~5000 lines, well past one screen.
const firstLine = 'SCROLLBACK_FIRST_LINE_0001';
const lastLine = 'SCROLLBACK_LAST_LINE_5000';
const lines: string[] = [firstLine];
for (let i = 2; i <= 4999; i++) {
lines.push(`scrollback line ${String(i).padStart(4, '0')} lorem ipsum payload`);
}
lines.push(lastLine);
const fullHistoryCapture = lines.join('\n');
harness.ctx._session.mode = 'shell';
harness.ctx._session.terminalBuffer = '';
const captureSpy = vi.fn((_name: string, opts?: { fullHistory?: boolean }) =>
opts?.fullHistory ? fullHistoryCapture : 'only the visible frame'
);
(harness.ctx.mux as { captureActivePaneBuffer?: unknown }).captureActivePaneBuffer = captureSpy;
const res = await harness.app.inject({
method: 'GET',
url: `/api/sessions/${harness.ctx._sessionId}/terminal`,
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
// Full reload asked tmux for the entire scrollback.
expect(captureSpy).toHaveBeenCalledWith(harness.ctx._session.muxName, { fullHistory: true });
// Both boundary markers survived the capture → route pipeline.
expect(body.data.terminalBuffer).toContain(firstLine);
expect(body.data.terminalBuffer).toContain(lastLine);
expect(body.data.source).toBe('mux-full-history');
expect(typeof body.data.fullSize).toBe('number');
});
it('tab switch (with tail) uses the visible frame, not full history', async () => {
harness.ctx._session.mode = 'codex';
harness.ctx._session.terminalBuffer = 'accumulated history';
const captureSpy = vi.fn((_name: string, opts?: { fullHistory?: boolean }) =>
opts?.fullHistory ? 'FULL_HISTORY_SHOULD_NOT_APPEAR' : 'visible frame only\n› prompt'
);
(harness.ctx.mux as { captureActivePaneBuffer?: unknown }).captureActivePaneBuffer = captureSpy;
const res = await harness.app.inject({
method: 'GET',
url: `/api/sessions/${harness.ctx._sessionId}/terminal?tail=65536`,
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
// Tail/tab-switch must NOT request fullHistory (undefined opts).
expect(captureSpy).toHaveBeenCalledWith(harness.ctx._session.muxName, undefined);
expect(body.data.terminalBuffer).toContain('visible frame only');
expect(body.data.terminalBuffer).not.toContain('FULL_HISTORY_SHOULD_NOT_APPEAR');
expect(body.data.source).toBe('mux-visible');
});
it('caps huge full-history at the configured terminal buffer limit and marks truncated', async () => {
// Shrink the cap so the test can exceed it without allocating 32MB.
harness.ctx.getTerminalHistoryConfig = vi.fn(async () => ({
terminalScrollbackLines: 100_000,
tmuxHistoryLimit: 100_000,
terminalBufferMaxBytes: 4096,
terminalBufferTrimBytes: 4096,
}));
harness.ctx._session.mode = 'shell';
harness.ctx._session.terminalBuffer = '';
const oldestMarker = 'OLDEST_EVICTED_MARKER';
const newestMarker = 'NEWEST_KEPT_MARKER';
const filler = Array.from({ length: 400 }, (_, i) => `line ${i} ${'x'.repeat(30)}`).join('\n');
const huge = `${oldestMarker}\n${filler}\n${newestMarker}`;
(harness.ctx.mux as { captureActivePaneBuffer?: unknown }).captureActivePaneBuffer = vi.fn(
(_name: string, opts?: { fullHistory?: boolean }) => (opts?.fullHistory ? huge : 'visible')
);
const res = await harness.app.inject({
method: 'GET',
url: `/api/sessions/${harness.ctx._sessionId}/terminal`,
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.data.truncated).toBe(true);
expect(body.data.fullSize).toBeGreaterThan(4096);
expect(body.data.terminalBuffer.length).toBeLessThanOrEqual(4096);
// Cap keeps the most RECENT bytes: newest marker survives, oldest is dropped.
expect(body.data.terminalBuffer).toContain(newestMarker);
expect(body.data.terminalBuffer).not.toContain(oldestMarker);
});
it('treats stale Codex scrollback config as TUI replay', async () => {
harness.ctx._session.terminalBuffer = 'hello world\nlater accumulated history';
harness.ctx._session.mode = 'codex';
harness.ctx._session.codexConfig = { renderMode: 'scrollback' } as any;
(harness.ctx.mux as { captureActivePaneBuffer?: unknown }).captureActivePaneBuffer = vi.fn(
() => 'visible tmux pane only\n› current prompt'
);
const res = await harness.app.inject({
method: 'GET',
url: `/api/sessions/${harness.ctx._sessionId}/terminal`,
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.data.terminalBuffer).toContain('hello world');
expect(body.data.terminalBuffer).toContain('later accumulated history');
expect(body.data.terminalBuffer).toContain('\x1b[H\x1b[2Jvisible tmux pane only');
expect(body.data.terminalBuffer.indexOf('hello world')).toBeLessThan(
body.data.terminalBuffer.indexOf('visible tmux pane only')
);
expect(harness.ctx.mux.captureActivePaneBuffer).toHaveBeenCalledWith(harness.ctx._session.muxName, {
fullHistory: true,
});
});
it('preserves one-time OAuth authorization URLs in Codex TUI replay history', async () => {
const authUrl =
'https://auth.atlassian.com/authorize?response_type=code&client_id=abc&redirect_uri=http%3A%2F%2F127.0.0.1%3A35547%2Fcallback%2Fxyz';
harness.ctx._session.terminalBuffer =
'Authorize `atlassian` by opening this URL in your browser:\n' +
authUrl +
'\n(Browser launch failed; please copy the URL above manually.)\n';
harness.ctx._session.mode = 'codex';
(harness.ctx.mux as { captureActivePaneBuffer?: unknown }).captureActivePaneBuffer = vi.fn(
() => 'visible tmux pane only\n› current prompt'
);
const res = await harness.app.inject({
method: 'GET',
url: `/api/sessions/${harness.ctx._sessionId}/terminal`,
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.data.terminalBuffer).toContain(authUrl);
expect(body.data.terminalBuffer).toContain('visible tmux pane only');
expect(body.data.terminalBuffer.indexOf(authUrl)).toBeLessThan(
body.data.terminalBuffer.indexOf('visible tmux pane only')
);
});
it('preserves incidental OAuth URL mentions as ordinary Codex TUI history', async () => {
const authUrl =
'https://auth.atlassian.com/authorize?response_type=code&client_id=abc&redirect_uri=http%3A%2F%2F127.0.0.1%3A35547%2Fcallback%2Fxyz';
harness.ctx._session.terminalBuffer =
'Root cause: URLs like ' +
authUrl +
' could be present in history but missing from browser-rendered terminal replay.\n' +
"+ 'Authorize `atlassian` by opening this URL in your browser:\\n' +\n";
harness.ctx._session.mode = 'codex';
(harness.ctx.mux as { captureActivePaneBuffer?: unknown }).captureActivePaneBuffer = vi.fn(
() => 'visible tmux pane only\n› current prompt'
);
const res = await harness.app.inject({
method: 'GET',
url: `/api/sessions/${harness.ctx._sessionId}/terminal`,
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.data.terminalBuffer).toContain(authUrl);
expect(body.data.terminalBuffer).toContain('visible tmux pane only');
});
it('preserves accumulated history before a live mux pane snapshot for non-Codex sessions', async () => {
harness.ctx._session.terminalBuffer = 'hello world\nlater accumulated history';
harness.ctx._session.mode = 'claude';
(harness.ctx.mux as { captureActivePaneBuffer?: unknown }).captureActivePaneBuffer = vi.fn(
() => 'visible tmux pane only\n› current prompt'
);
const res = await harness.app.inject({
method: 'GET',
url: `/api/sessions/${harness.ctx._sessionId}/terminal`,
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.data.terminalBuffer).toContain('hello world');
expect(body.data.terminalBuffer).toContain('later accumulated history');
expect(body.data.terminalBuffer).toContain('visible tmux pane only');
expect(body.data.terminalBuffer).toContain('\x1b[H\x1b[2Jvisible tmux pane only');
expect(body.data.terminalBuffer.indexOf('hello world')).toBeLessThan(
body.data.terminalBuffer.indexOf('visible tmux pane only')
);
expect(harness.ctx.mux.captureActivePaneBuffer).toHaveBeenCalledWith(harness.ctx._session.muxName, {
fullHistory: true,
});
});
it('uses live mux pane capture only when the accumulated buffer is empty', async () => {
harness.ctx._session.terminalBuffer = '';
harness.ctx._session.mode = 'codex';
(harness.ctx.mux as { captureActivePaneBuffer?: unknown }).captureActivePaneBuffer = vi.fn(
() => 'visible restored tmux pane\n› current prompt'
);
const res = await harness.app.inject({
method: 'GET',
url: `/api/sessions/${harness.ctx._sessionId}/terminal`,
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.data.terminalBuffer).toContain('visible restored tmux pane');
expect(body.data.terminalBuffer).toContain('› current prompt');
expect(harness.ctx.mux.captureActivePaneBuffer).toHaveBeenCalledWith(harness.ctx._session.muxName, {
fullHistory: true,
});
});
it('returns error for unknown session', async () => {
const res = await harness.app.inject({
method: 'GET',
@@ -506,7 +764,9 @@ describe('session-routes', () => {
expect(buf).toContain('\x1b[H\x1b[2J');
expect(buf).toContain('LIVE-PANE-FRAME');
expect(buf.indexOf('history-bytes')).toBeLessThan(buf.indexOf('LIVE-PANE-FRAME'));
expect(harness.ctx.mux.captureActivePaneBuffer).toHaveBeenCalledWith(harness.ctx._session.muxName);
expect(harness.ctx.mux.captureActivePaneBuffer).toHaveBeenCalledWith(harness.ctx._session.muxName, {
fullHistory: true,
});
});
it('falls back to the byte history when no live pane buffer is available', async () => {
+51
View File
@@ -0,0 +1,51 @@
/**
* 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 -S -` 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 } from 'vitest';
describe('tmux full-history pane capture (COD-47)', () => {
const source = readFileSync(resolve(import.meta.dirname, '../src/tmux-manager.ts'), 'utf8');
it('capturePaneBuffer accepts a fullHistory option', () => {
const sig = source.indexOf('capturePaneBuffer(muxName: string');
expect(sig).toBeGreaterThan(-1);
// The method signature must carry the opts.fullHistory channel.
expect(source.slice(sig, sig + 160)).toContain('fullHistory');
});
it('full-history mode requests the entire scrollback with -S -', () => {
expect(source).toContain('capture-pane -p -e -S -');
});
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)', () => {
const sig = source.indexOf('capturePaneBuffer(muxName: string');
const body = source.slice(sig, sig + 2400);
// When fullHistory, return the raw buffer BEFORE the formatPaneSnapshot
// repaint (which is single-screen and would clip a multi-screen history).
const earlyReturn = body.indexOf('if (fullHistory)');
const snapshot = body.indexOf('formatPaneSnapshot(');
expect(earlyReturn).toBeGreaterThan(-1);
expect(snapshot).toBeGreaterThan(-1);
expect(earlyReturn).toBeLessThan(snapshot);
});
it('captureActivePaneBuffer forwards the fullHistory option', () => {
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)');
});
});