diff --git a/browse/src/terminal-agent.ts b/browse/src/terminal-agent.ts index 1ed4c8a1c..2e39d99e4 100644 --- a/browse/src/terminal-agent.ts +++ b/browse/src/terminal-agent.ts @@ -77,7 +77,7 @@ process.on('unhandledRejection', (reason) => { console.error('[terminal-agent] unhandledRejection:', reason); }); -interface PtySession { +export interface PtySession { proc: any | null; // Bun.Subprocess once spawned cols: number; rows: number; @@ -185,7 +185,7 @@ const DETACH_WINDOW_MS = parseInt( * sequences (CSI ?1049h / CSI ?1049l) and updates session.altScreenActive * so the re-attach prelude knows whether to re-enter alt-screen. */ -function appendToRingBuffer(session: PtySession, frame: Buffer): void { +export function appendToRingBuffer(session: PtySession, frame: Buffer): void { session.ringBuffer.push(frame); session.ringBufferBytes += frame.length; while (session.ringBufferBytes > RING_BUFFER_MAX_BYTES && session.ringBuffer.length > 1) { @@ -219,7 +219,7 @@ function appendToRingBuffer(session: PtySession, frame: Buffer): void { * is what lets us prepend reset codes without clobbering the live stream * that resumes immediately after. */ -function buildReplayPayload(session: PtySession): Buffer { +export function buildReplayPayload(session: PtySession): Buffer { const parts: Buffer[] = []; parts.push(Buffer.from('\x1b[!p')); if (session.altScreenActive) parts.push(Buffer.from('\x1b[?1049h')); diff --git a/browse/test/terminal-agent-ring-buffer-runtime.test.ts b/browse/test/terminal-agent-ring-buffer-runtime.test.ts new file mode 100644 index 000000000..5cea4f9b8 --- /dev/null +++ b/browse/test/terminal-agent-ring-buffer-runtime.test.ts @@ -0,0 +1,154 @@ +import { describe, test, expect, beforeEach } from 'bun:test'; +import { + appendToRingBuffer, + buildReplayPayload, + type PtySession, +} from '../src/terminal-agent'; + +// Runtime exercises for the v1.44 Commit 3 ring buffer + replay prelude. +// Companion to browse/test/terminal-agent-detach-reattach.test.ts which +// covers the structural invariants; this file calls the helpers directly +// to prove behavioral correctness without spinning up a real Bun.serve +// listener. + +function fresh(): PtySession { + return { + proc: null, + cols: 80, + rows: 24, + cookie: 'test-cookie', + liveWs: null, + sessionId: 'test-session', + spawned: false, + pingInterval: null, + ringBuffer: [], + ringBufferBytes: 0, + altScreenActive: false, + detached: false, + detachTimer: null, + }; +} + +describe('appendToRingBuffer runtime', () => { + test('appends frames in order and tracks byte count', () => { + const s = fresh(); + appendToRingBuffer(s, Buffer.from('hello ')); + appendToRingBuffer(s, Buffer.from('world')); + expect(s.ringBuffer).toHaveLength(2); + expect(s.ringBufferBytes).toBe(11); + expect(Buffer.concat(s.ringBuffer).toString()).toBe('hello world'); + }); + + test('evicts oldest frames when cap exceeded', () => { + // Default cap is 1 MB. Override via env wouldn't help inside this + // running process (constant was read at module load), so use frames + // big enough to exceed it deterministically. + const s = fresh(); + const big = Buffer.alloc(400_000, 0x41); // 400 KB of 'A' + appendToRingBuffer(s, big); + appendToRingBuffer(s, big); + appendToRingBuffer(s, big); // total 1.2 MB — exceeds default cap + // Eviction must drop frames until under cap; first 400 KB chunk goes. + expect(s.ringBuffer.length).toBeLessThan(3); + expect(s.ringBufferBytes).toBeLessThanOrEqual(1024 * 1024); + }); + + test('keeps at least one frame even when a single frame exceeds the cap', () => { + const s = fresh(); + // 2 MB single frame — bigger than the 1 MB cap. The eviction loop + // guards on `ringBuffer.length > 1`, so the single oversized frame + // stays. Without that guard, the buffer would empty itself, defeating + // the whole point of replay on re-attach. + const huge = Buffer.alloc(2 * 1024 * 1024, 0x42); + appendToRingBuffer(s, huge); + expect(s.ringBuffer.length).toBe(1); + expect(s.ringBufferBytes).toBe(huge.length); + }); + + test('tracks alt-screen enter (CSI ?1049h)', () => { + const s = fresh(); + expect(s.altScreenActive).toBe(false); + appendToRingBuffer(s, Buffer.from('plain text')); + expect(s.altScreenActive).toBe(false); + appendToRingBuffer(s, Buffer.from('\x1b[?1049h')); + expect(s.altScreenActive).toBe(true); + }); + + test('tracks alt-screen exit (CSI ?1049l)', () => { + const s = fresh(); + appendToRingBuffer(s, Buffer.from('\x1b[?1049h')); + expect(s.altScreenActive).toBe(true); + appendToRingBuffer(s, Buffer.from('\x1b[?1049l')); + expect(s.altScreenActive).toBe(false); + }); + + test('trailing state wins when enter + exit appear in one frame', () => { + const s = fresh(); + // Tool call opened alt-screen then closed it inside one render — net + // state is back to main screen. lastIndexOf comparison handles this. + appendToRingBuffer(s, Buffer.from('start\x1b[?1049hmiddle\x1b[?1049lend')); + expect(s.altScreenActive).toBe(false); + + const s2 = fresh(); + // Reverse order: exited then re-entered — net state alt-screen. + appendToRingBuffer(s2, Buffer.from('\x1b[?1049l\x1b[?1049h')); + expect(s2.altScreenActive).toBe(true); + }); +}); + +describe('buildReplayPayload runtime', () => { + test('prepends DECSTR soft reset before ring buffer contents', () => { + const s = fresh(); + appendToRingBuffer(s, Buffer.from('prompt> ')); + const payload = buildReplayPayload(s).toString('latin1'); + expect(payload.startsWith('\x1b[!p')).toBe(true); + expect(payload.endsWith('prompt> ')).toBe(true); + }); + + test('re-enters alt-screen when session was in alt-screen at detach', () => { + const s = fresh(); + appendToRingBuffer(s, Buffer.from('\x1b[?1049h tool output ')); + const payload = buildReplayPayload(s).toString('latin1'); + // Order: soft reset, alt-screen re-enter, ring buffer. + expect(payload.indexOf('\x1b[!p')).toBeLessThan(payload.indexOf('\x1b[?1049h')); + expect(payload.indexOf('\x1b[?1049h')).toBeLessThan(payload.indexOf('tool output')); + }); + + test('omits alt-screen re-enter when session was on main screen', () => { + const s = fresh(); + appendToRingBuffer(s, Buffer.from('regular prompt')); + const payload = buildReplayPayload(s).toString('latin1'); + // Soft reset is present, but alt-screen enter is NOT. Both substrings + // are otherwise identical 8 bytes apart in the alphabet, so equal- + // substring checks need to be strict. + expect(payload).toContain('\x1b[!p'); + expect(payload).not.toContain('\x1b[?1049h'); + }); + + test('replay buffer length = soft-reset + (optional alt-screen) + ring bytes', () => { + const s = fresh(); + appendToRingBuffer(s, Buffer.from('abc')); + appendToRingBuffer(s, Buffer.from('def')); + const payload = buildReplayPayload(s); + // 4 bytes (DECSTR) + 6 bytes (abc/def) = 10 bytes. No alt-screen. + expect(payload.length).toBe(4 + 6); + }); +}); + +describe('lease lifecycle interplay (via pty-session-lease)', () => { + // Cross-module behavior: lease + ring buffer are both per-session. + // This catches the case where a refactor accidentally couples them. + test('lease registry is independent of ring buffer state', async () => { + const { mintLease, validateLease, __resetLeases } = await import('../src/pty-session-lease'); + __resetLeases(); + const a = mintLease(); + const b = mintLease(); + expect(a.sessionId).not.toBe(b.sessionId); + const va = validateLease(a.sessionId); + const vb = validateLease(b.sessionId); + expect(va.ok && vb.ok).toBe(true); + if (va.ok && vb.ok) { + expect(va.expiresAt).toBe(vb.expiresAt); + } + }); +});