diff --git a/src/main/daemon/headless-emulator-fidelity.fuzz.test.ts b/src/main/daemon/headless-emulator-fidelity.fuzz.test.ts new file mode 100644 index 00000000000..a11b0a2e402 --- /dev/null +++ b/src/main/daemon/headless-emulator-fidelity.fuzz.test.ts @@ -0,0 +1,359 @@ +import { describe, expect, it } from 'vitest' +import { HeadlessEmulator } from './headless-emulator' +import { + buildAgentTuiStreamOps, + mulberry32, + splitIntoRandomChunks, + type AgentTuiStreamDims +} from '../../shared/agent-tui-ansi-fuzz-stream' +import { + POST_REPLAY_LIVE_SNAPSHOT_RESET_PARITY, + SNAPSHOT_REPLAY_PREAMBLE_ALT, + SNAPSHOT_REPLAY_PREAMBLE_NORMAL, + bufferHasSerializeHostileWrappedRow, + createRendererParityTerminal, + cursorPosition, + normalBufferRowsTrimmed, + visibleRowStyles, + visibleRows, + writeChunksToTerminal +} from '../../shared/terminal-restore-parity-fixture' + +// Differential garble gate for the hidden-terminal model/view contract +// (docs/reference/terminal-model-view-contract.md): with the hidden-delivery +// gate on, a hidden pane receives NOTHING — main's HeadlessEmulator is the +// source of truth and reveal repaints the renderer xterm from +// preamble + rehydrateSequences + snapshotAnsi (applyMainBufferSnapshot). +// This fuzz feeds seeded agent-TUI byte streams to the production emulator +// and to an always-visible renderer-parity terminal, then asserts the +// serialize→replay round trip reproduces the exact screen the renderer would +// have shown. Any diff = a garble bug on reveal. +// +// Runtime knobs: +// FUZZ_ITERATIONS=5000 deep/nightly mode (default 300, <60s combined with +// the reveal-reconciliation suite) +// FUZZ_SEED=1234 re-run exactly one seed (repro from a failure log) + +const DEFAULT_ITERATIONS = 300 +const FIXED_SEED = readPositiveIntEnv('FUZZ_SEED') +const ITERATIONS = + FIXED_SEED !== null ? 1 : (readPositiveIntEnv('FUZZ_ITERATIONS') ?? DEFAULT_ITERATIONS) +// Matches HIDDEN_OUTPUT_RESTORE_SCROLLBACK_ROWS in pty-connection.ts — the +// scrollback budget the reveal restore actually requests from main. +const HIDDEN_OUTPUT_RESTORE_SCROLLBACK_ROWS = 5000 + +function readPositiveIntEnv(name: string): number | null { + const raw = Number(process.env[name]) + return Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : null +} + +const DIMS: readonly AgentTuiStreamDims[] = [ + { cols: 80, rows: 24 }, + { cols: 100, rows: 30 }, + { cols: 120, rows: 40 } +] + +type FidelityCase = { + seed: number + dims: AgentTuiStreamDims + ops: string[] + chunked: boolean +} + +type FidelityDiff = { + stage: string + expected: unknown + actual: unknown + /** True when the always-visible buffer matches the known upstream + * @xterm/addon-serialize blank-leading-wrapped-row bug predicate — see + * bufferHasSerializeHostileWrappedRow and the skipped repro test below. */ + knownSerializeWrapBug?: boolean +} + +function buildCase(seed: number): FidelityCase { + const rng = mulberry32(seed) + const dims = DIMS[Math.floor(rng() * DIMS.length)]! + const opCount = 12 + Math.floor(rng() * 28) + const ops = buildAgentTuiStreamOps(rng, dims, { + includeMouseModes: true, + includeOscHyperlinks: false, + opCount + }) + return { seed, dims, ops, chunked: true } +} + +function firstDiff(stage: string, expected: unknown, actual: unknown): FidelityDiff | null { + return JSON.stringify(expected) === JSON.stringify(actual) ? null : { stage, expected, actual } +} + +async function runFidelityCase(testCase: FidelityCase): Promise { + const stream = testCase.ops.join('') + const chunks = testCase.chunked + ? splitIntoRandomChunks(mulberry32(testCase.seed ^ 0x9e3779b9), stream, { + minLen: 3, + maxLen: 120 + }) + : [stream] + const emulator = new HeadlessEmulator({ cols: testCase.dims.cols, rows: testCase.dims.rows }) + const control = createRendererParityTerminal(testCase.dims) + const restored = createRendererParityTerminal(testCase.dims) + try { + for (const chunk of chunks) { + await emulator.write(chunk) + } + await writeChunksToTerminal(control.terminal, chunks) + + // Stage 1 — model fidelity: the emulator's screen must already match the + // renderer twin before any serialization enters the picture. + const modelDiff = firstDiff( + 'model-visible (HeadlessEmulator vs renderer twin)', + visibleRows(control.terminal), + emulator.getVisibleLines() + ) + if (modelDiff) { + return modelDiff + } + + // Stage 2 — reveal round trip: serialize exactly like + // serializeHiddenOutputRecoveryBuffer, replay exactly like + // applyMainBufferSnapshot, then compare against the always-visible twin. + const alt = emulator.isAlternateScreen + const snapshot = emulator.getSnapshot({ + scrollbackRows: alt ? 0 : HIDDEN_OUTPUT_RESTORE_SCROLLBACK_ROWS + }) + await writeChunksToTerminal(restored.terminal, [ + alt ? SNAPSHOT_REPLAY_PREAMBLE_ALT : SNAPSHOT_REPLAY_PREAMBLE_NORMAL, + snapshot.rehydrateSequences + snapshot.snapshotAnsi, + POST_REPLAY_LIVE_SNAPSHOT_RESET_PARITY + ]) + + const diffs = [ + firstDiff( + 'restore-visible-text', + visibleRows(control.terminal), + visibleRows(restored.terminal) + ), + firstDiff( + 'restore-visible-styles', + visibleRowStyles(control.terminal), + visibleRowStyles(restored.terminal) + ), + firstDiff( + 'restore-cursor', + cursorPosition(control.terminal), + cursorPosition(restored.terminal) + ), + firstDiff( + 'restore-mode-bracketed-paste', + control.terminal.modes.bracketedPasteMode, + restored.terminal.modes.bracketedPasteMode + ), + // Why alt is excluded from the two comparisons below: + // - scrollback: serializeHeadlessTerminalBuffer (orca-runtime.ts) + // deliberately forces scrollbackRows=0 while an alt-screen TUI is + // active, so normal-buffer history is not part of the alt contract. + // - application cursor: HeadlessEmulator.getModes reports + // applicationCursor false on the alternate buffer, so rehydrate omits + // ?1h there by design. + alt + ? null + : firstDiff( + 'restore-scrollback-text', + normalBufferRowsTrimmed(control.terminal), + normalBufferRowsTrimmed(restored.terminal) + ), + alt + ? null + : firstDiff( + 'restore-mode-application-cursor', + control.terminal.modes.applicationCursorKeysMode, + restored.terminal.modes.applicationCursorKeysMode + ) + ] + const diff = diffs.find((candidate) => candidate !== null) ?? null + if (diff && bufferHasSerializeHostileWrappedRow(control.terminal)) { + return { ...diff, knownSerializeWrapBug: true } + } + return diff + } finally { + emulator.dispose() + control.terminal.dispose() + restored.terminal.dispose() + } +} + +/** Greedy op-drop minimizer: re-runs the full differential pipeline on + * smaller op lists so a failure report carries the smallest byte stream that + * still diverges (plus its seed for exact replay via FUZZ_SEED). */ +async function minimizeFailure(testCase: FidelityCase): Promise { + let current = { ...testCase, chunked: false } + if ((await runFidelityCase(current)) === null) { + current = { ...testCase, chunked: true } + } + let budget = 400 + let shrunk = true + while (shrunk && budget > 0) { + shrunk = false + for (let i = current.ops.length - 1; i >= 0 && budget > 0; i--) { + const candidate = { ...current, ops: current.ops.toSpliced(i, 1) } + budget -= 1 + if ((await runFidelityCase(candidate)) !== null) { + current = candidate + shrunk = true + } + } + } + return current +} + +function formatFailure(minimized: FidelityCase, diff: FidelityDiff | null): string { + return [ + `HeadlessEmulator fidelity divergence — stage: ${diff?.stage ?? 'unknown'}`, + `seed: ${minimized.seed} (re-run: FUZZ_SEED=${minimized.seed} pnpm exec vitest run --config config/vitest.config.ts src/main/daemon/headless-emulator-fidelity.fuzz.test.ts)`, + `dims: ${minimized.dims.cols}x${minimized.dims.rows} chunked: ${minimized.chunked}`, + `minimized ops (${minimized.ops.length}): ${JSON.stringify(minimized.ops)}`, + `expected (always-visible renderer twin): ${JSON.stringify(diff?.expected)}`, + `actual (snapshot restore replay): ${JSON.stringify(diff?.actual)}` + ].join('\n') +} + +describe('headless emulator snapshot fidelity fuzz', () => { + // Known-legitimate divergence, pinned so it cannot silently regress into a + // real one: xterm marks OSC 8 hyperlink cells underlined, SerializeAddon + // never re-emits OSC 8, and production compensates by shipping the ranges + // out-of-band in snapshot.oscLinks (collectHeadlessOscLinkRanges) for the + // renderer link provider to re-register. Byte-replay therefore keeps the + // TEXT but not the link underline — the metadata must carry the range. + it('drops OSC 8 underline from byte replay but preserves the range in snapshot metadata', async () => { + const emulator = new HeadlessEmulator({ cols: 60, rows: 10 }) + const restored = createRendererParityTerminal({ cols: 60, rows: 10 }) + try { + await emulator.write('\x1b]8;;https://example.com/pr/7\x07review link\x1b]8;;\x07 tail') + const snapshot = emulator.getSnapshot({ scrollbackRows: 5000 }) + await writeChunksToTerminal(restored.terminal, [ + SNAPSHOT_REPLAY_PREAMBLE_NORMAL, + snapshot.rehydrateSequences + snapshot.snapshotAnsi, + POST_REPLAY_LIVE_SNAPSHOT_RESET_PARITY + ]) + expect(visibleRows(restored.terminal)[0]).toBe('review link tail') + expect(snapshot.oscLinks).toContainEqual({ + row: 0, + startCol: 0, + endCol: 11, + uri: 'https://example.com/pr/7' + }) + } finally { + emulator.dispose() + restored.terminal.dispose() + } + }) + + it(`matches an always-visible renderer twin across ${ITERATIONS} seeded agent-TUI streams`, async () => { + let knownSerializeWrapBugHits = 0 + for (let i = 0; i < ITERATIONS; i++) { + const seed = FIXED_SEED ?? 1 + i + const testCase = buildCase(seed) + const diff = await runFidelityCase(testCase) + if (diff?.knownSerializeWrapBug) { + // Tolerated + counted: the blank-leading-wrapped-row serialize bug + // is already minimized and pinned by the skipped repro test below. + knownSerializeWrapBugHits += 1 + continue + } + if (diff) { + const minimized = await minimizeFailure(testCase) + const minimizedDiff = await runFidelityCase(minimized) + expect.fail(formatFailure(minimized, minimizedDiff ?? diff)) + } + } + // Guard the tolerance from swallowing the suite: if the corpus starts + // tripping the known predicate on most seeds, the gate is degenerate. + expect(knownSerializeWrapBugHits).toBeLessThan(Math.max(3, ITERATIONS * 0.5)) + }, 600_000) + + // ── HEADLINE FINDING (do not delete while unfixed upstream) ────────────── + // @xterm/addon-serialize 0.15.0-beta.287 does not round-trip null cells + // that touch a soft-wrap boundary. Two variants, both found by this fuzz + // and minimized below. Every Orca snapshot consumer is affected: hidden + // reveal, parked-tab reveal, sleep/wake restore, and mobile subscribe + // replay paint lost/shifted characters or stray '-' fillers whenever a TUI + // erased inside a soft-wrapped line (shell line editing, status lines wider + // than the pane, Claude Code in-place prompt redraws). + // + // V1 — cell loss (found by seed 31, minimized to 2 ops): + // Root cause: the wrap-validity ternary in SerializeAddon.ts (~L214) + // nextRowFirstChar.getChars() && isNextRowFirstCharDoubleWidth + // ? this._nullCellCount <= 1 : this._nullCellCount <= 0 + // binds as `(chars && doubleWidth) ? ...`, so a null-leading wrapped row + // passes as a "natural" wrap. The serializer then emits the previous row as + // full-width text (leaving xterm in wrap-pending) and skips the null cell + // with CUF (`ESC[1C`) — but CUF clamps at the right margin instead of + // crossing the wrap boundary, so the next character overwrites the previous + // row's last cell and the whole tail shifts left by one. + // cols=20: write 'ABCDEFGHIJKLMNOPQRSTUVWXYZ12\r\n' then '\x1b[1A\x1b[1K' + // live rows: ['ABCDEFGHIJKLMNOPQRST', ' VWXYZ12'] + // serialize(): 'ABCDEFGHIJKLMNOPQRST\x1b[1CVWXYZ12\x1b[8D' + // replayed rows: ['ABCDEFGHIJKLMNOPQRSV', 'WXYZ12'] ← 'T' eaten, tail shifted + // + // V2 — stray filler '-' (found by seed 157, minimized below): when the + // SOURCE row of a wrapped pair is entirely null (a TUI erased the whole + // first half of a wrapped line), the addon's forced-wrap "magic" writes + // nullCellCount+1 dashes and then cleans up with + // ESC[A ESC[(length-nullCellCount)C ESC[(nullCellCount)X ... + // With length === nullCellCount that cursor-forward becomes `ESC[0C`, and + // CSI param 0 means 1, so the ECH erase lands one cell right and the first + // '-' stays visible on the restored row. + // Unskip once the upstream fix (or a local serialize post-processor) lands. + it.skip('round-trips a wrapped line whose continuation row starts with an erased cell', async () => { + const emulator = new HeadlessEmulator({ cols: 20, rows: 6 }) + const control = createRendererParityTerminal({ cols: 20, rows: 6 }) + const restored = createRendererParityTerminal({ cols: 20, rows: 6 }) + try { + const bytes = ['ABCDEFGHIJKLMNOPQRSTUVWXYZ12\r\n', '\x1b[1A\x1b[1K'] + for (const chunk of bytes) { + await emulator.write(chunk) + } + await writeChunksToTerminal(control.terminal, bytes) + const snapshot = emulator.getSnapshot({ scrollbackRows: 5000 }) + await writeChunksToTerminal(restored.terminal, [ + SNAPSHOT_REPLAY_PREAMBLE_NORMAL, + snapshot.rehydrateSequences + snapshot.snapshotAnsi, + POST_REPLAY_LIVE_SNAPSHOT_RESET_PARITY + ]) + expect(visibleRows(restored.terminal)).toEqual(visibleRows(control.terminal)) + } finally { + emulator.dispose() + control.terminal.dispose() + restored.terminal.dispose() + } + }) + + // V2 repro of the headline finding above (stray '-' filler on a fully + // erased wrapped source row). Unskip alongside the V1 repro. + it.skip('round-trips a wrapped line whose source row was fully erased', async () => { + const emulator = new HeadlessEmulator({ cols: 20, rows: 6 }) + const control = createRendererParityTerminal({ cols: 20, rows: 6 }) + const restored = createRendererParityTerminal({ cols: 20, rows: 6 }) + try { + // Wrap a 28-char line, then erase the entire first (source) row of the + // wrapped pair: cursor up twice onto it, EL 2. + const bytes = ['ABCDEFGHIJKLMNOPQRSTUVWXYZ12\r\n', '\x1b[2A\x1b[2K'] + for (const chunk of bytes) { + await emulator.write(chunk) + } + await writeChunksToTerminal(control.terminal, bytes) + const snapshot = emulator.getSnapshot({ scrollbackRows: 5000 }) + await writeChunksToTerminal(restored.terminal, [ + SNAPSHOT_REPLAY_PREAMBLE_NORMAL, + snapshot.rehydrateSequences + snapshot.snapshotAnsi, + POST_REPLAY_LIVE_SNAPSHOT_RESET_PARITY + ]) + // Fails today: restored row 0 shows '-' where the live row is blank. + expect(visibleRows(restored.terminal)).toEqual(visibleRows(control.terminal)) + } finally { + emulator.dispose() + control.terminal.dispose() + restored.terminal.dispose() + } + }) +}) diff --git a/src/shared/agent-tui-ansi-fuzz-stream.ts b/src/shared/agent-tui-ansi-fuzz-stream.ts new file mode 100644 index 00000000000..afffcc0a411 --- /dev/null +++ b/src/shared/agent-tui-ansi-fuzz-stream.ts @@ -0,0 +1,279 @@ +// Seeded ANSI byte-stream generator for the terminal garble differential +// fuzz suites (headless-emulator-fidelity.fuzz.test.ts and +// hidden-reveal-reconciliation.fuzz.test.ts). The op mix models real agent +// TUI output (Claude Code / Codex): CR status-line redraws, cursor-up panel +// repaints, DEC 2026 synchronized frames, SGR color runs, wide CJK and ZWJ +// emoji, wrapped long lines, alt-screen sessions, and scroll regions. +// +// Deliberately excluded ops (they would fuzz behavior the snapshot/restore +// contract does not promise to preserve, so any diff would be noise, not a +// garble bug): +// - DECAWM (?7l) and IRM (CSI 4h): SerializeAddon does not re-emit them and +// rehydrateSequences (headless-emulator.ts buildRehydrateSequences) only +// covers alt-screen/bracketed-paste/app-cursor/mouse modes. +// - Terminal queries (DA/DSR/DECRQM): the emulator is write-only by contract +// (headless-emulator.ts onQueryReply gating); replies are a separate +// authority problem with its own pinned tests (session.test.ts). + +/** Same seeded PRNG as retained-tail-redraw-window.equivalence.test.ts. */ +export function mulberry32(seed: number): () => number { + let a = seed >>> 0 + return () => { + a = (a + 0x6d2b79f5) | 0 + let t = Math.imul(a ^ (a >>> 15), 1 | a) + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t + return ((t ^ (t >>> 14)) >>> 0) / 4294967296 + } +} + +export type AgentTuiStreamDims = { cols: number; rows: number } + +export type AgentTuiStreamProfile = { + /** Mouse-mode toggles require mirroring TerminalMouseModeMirror to build + * rehydrate parity; the renderer-side fuzz cannot import that main-only + * module (tsconfig.tc.web.json excludes src/main/daemon), so it opts out. */ + includeMouseModes: boolean + /** OSC 8 hyperlinks mark their cells underlined in the xterm buffer, but + * SerializeAddon never re-emits OSC 8 — production restores link ranges + * out-of-band via snapshot.oscLinks (collectHeadlessOscLinkRanges), so + * byte-replay fidelity legitimately drops that underline. The suites pin + * the metadata compensation in a targeted test instead of fuzzing it. */ + includeOscHyperlinks: boolean + opCount: number +} + +function int(rng: () => number, min: number, max: number): number { + return min + Math.floor(rng() * (max - min + 1)) +} + +function pick(rng: () => number, values: readonly T[]): T { + return values[Math.floor(rng() * values.length)]! +} + +const WORDS = [ + 'reading', + 'src/main/daemon/session.ts', + 'tokens 12.4k', + 'esc to interrupt', + 'Thinking…', + 'bash: pnpm typecheck', + '+142 -37', + 'PASS terminal.test.ts', + 'waiting for approval', + 'diff --git a/pty.ts' +] as const + +const WIDE_RUNS = [ + '你好世界', + '터미널 상태 확인', + '進捗を表示中', + '🟢 working', + '🤖 codex', + '✅ done ✨', + // ZWJ emoji join — the exact width divergence the Orca unicode provider + // exists for (shared/terminal-unicode-provider.ts). + '👨‍👩‍👧‍👦 team', + '🇰🇷 locale' +] as const + +const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧'] as const + +function sgr(rng: () => number): string { + const roll = rng() + if (roll < 0.2) { + return `\x1b[${pick(rng, ['0', '1', '2', '3', '4', '7', '9', '22', '24', '39', '49'])}m` + } + if (roll < 0.55) { + return `\x1b[${int(rng, 30, 37) + (rng() < 0.3 ? 60 : 0)}m` + } + if (roll < 0.8) { + return `\x1b[38;5;${int(rng, 0, 255)}m` + } + return `\x1b[38;2;${int(rng, 0, 255)};${int(rng, 0, 255)};${int(rng, 0, 255)}m` +} + +function textRun(rng: () => number): string { + const parts: string[] = [] + const count = int(rng, 1, 3) + for (let i = 0; i < count; i++) { + parts.push(rng() < 0.25 ? pick(rng, WIDE_RUNS) : pick(rng, WORDS)) + } + return parts.join(' ') +} + +function styledLine(rng: () => number): string { + return `${sgr(rng)}${textRun(rng)}\x1b[0m\r\n` +} + +function panelRedraw(rng: () => number, dims: AgentTuiStreamDims): string { + const height = int(rng, 1, Math.min(6, dims.rows - 2)) + const lines: string[] = [`\x1b[${height}A\r`] + if (rng() < 0.5) { + lines.push('\x1b[0J') + } + for (let i = 0; i < height; i++) { + lines.push(`\x1b[2K${sgr(rng)}│ ${textRun(rng)}\x1b[0m\r\n`) + } + return lines.join('') +} + +function statusLineRewrite(rng: () => number): string { + return `\r\x1b[2K${sgr(rng)}${pick(rng, SPINNER)} ${textRun(rng)}\x1b[0m` +} + +function cursorMotion(rng: () => number, dims: AgentTuiStreamDims): string { + const roll = rng() + if (roll < 0.4) { + return `\x1b[${int(rng, 1, dims.rows)};${int(rng, 1, dims.cols)}H` + } + if (roll < 0.55) { + return `\x1b[${int(rng, 1, dims.cols)}G` + } + return `\x1b[${int(rng, 1, 4)}${pick(rng, ['A', 'B', 'C', 'D'] as const)}` +} + +function eraseOp(rng: () => number): string { + return pick(rng, ['\x1b[K', '\x1b[1K', '\x1b[2K', '\x1b[0J', '\x1b[1J'] as const) +} + +function wrappedLongLine(rng: () => number, dims: AgentTuiStreamDims): string { + const unit = `${textRun(rng)} ` + const repeats = Math.ceil((dims.cols * int(rng, 1, 3)) / Math.max(unit.length, 1)) + 1 + return `${sgr(rng)}${unit.repeat(repeats)}\x1b[0m\r\n` +} + +function scrollRegionBurst(rng: () => number, dims: AgentTuiStreamDims): string { + const top = int(rng, 1, Math.max(1, dims.rows - 4)) + const bottom = int(rng, top + 1, dims.rows) + const body: string[] = [`\x1b[${top};${bottom}r`, `\x1b[${bottom};1H`] + for (let i = 0; i < int(rng, 1, 4); i++) { + body.push(`${textRun(rng)}\r\n`) + } + body.push('\x1b[r') + return body.join('') +} + +function altScreenFrame(rng: () => number, dims: AgentTuiStreamDims): string { + const rows = int(rng, 2, Math.min(8, dims.rows)) + const body: string[] = ['\x1b[?1049h', '\x1b[2J\x1b[H', '\x1b[?25l'] + for (let i = 0; i < rows; i++) { + body.push(`${sgr(rng)}│ ${textRun(rng)}\x1b[0m${i === rows - 1 ? '' : '\r\n'}`) + } + body.push(`\x1b[${int(rng, 1, dims.rows)};${int(rng, 1, dims.cols)}H\x1b[?25h`) + if (rng() < 0.5) { + body.push('\x1b[?1049l') + } + return body.join('') +} + +function synchronizedFrame(rng: () => number, dims: AgentTuiStreamDims): string { + return `\x1b[?2026h${rng() < 0.5 ? panelRedraw(rng, dims) : statusLineRewrite(rng)}\x1b[?2026l` +} + +function savedCursorDetour(rng: () => number, dims: AgentTuiStreamDims): string { + return `\x1b7${cursorMotion(rng, dims)}${sgr(rng)}${textRun(rng)}\x1b[0m\x1b8` +} + +function oscOp(rng: () => number, profile: AgentTuiStreamProfile): string { + const roll = rng() + if (roll < 0.5) { + return `\x1b]0;${textRun(rng)}\x07` + } + if (roll < 0.8) { + return profile.includeOscHyperlinks + ? `\x1b]8;;https://example.com/${int(rng, 1, 999)}\x07link\x1b]8;;\x07` + : `https://example.com/pr/${int(rng, 1, 999)}\r\n` + } + return '\x1b]133;A\x07' +} + +function modeToggle(rng: () => number, profile: AgentTuiStreamProfile): string { + const toggles = ['\x1b[?2004h', '\x1b[?2004l', '\x1b[?1h', '\x1b[?1l', '\x1b[?25l', '\x1b[?25h'] + if (profile.includeMouseModes) { + toggles.push( + '\x1b[?1000h', + '\x1b[?1002h', + '\x1b[?1003h', + '\x1b[?1006h', + '\x1b[?1000l', + '\x1b[?1006l' + ) + } + return pick(rng, toggles) +} + +/** One seeded agent-TUI-shaped op. Weights favor the redraw ops that have + * historically produced hidden-restore garble (CR rewrites, cursor-up panel + * repaints, DEC 2026 frames, alt-screen churn). */ +function nextOp( + rng: () => number, + dims: AgentTuiStreamDims, + profile: AgentTuiStreamProfile +): string { + const roll = rng() + if (roll < 0.18) { + return styledLine(rng) + } + if (roll < 0.32) { + return statusLineRewrite(rng) + } + if (roll < 0.46) { + return panelRedraw(rng, dims) + } + if (roll < 0.54) { + return synchronizedFrame(rng, dims) + } + if (roll < 0.62) { + return wrappedLongLine(rng, dims) + } + if (roll < 0.7) { + return cursorMotion(rng, dims) + eraseOp(rng) + } + if (roll < 0.76) { + return altScreenFrame(rng, dims) + } + if (roll < 0.82) { + return scrollRegionBurst(rng, dims) + } + if (roll < 0.88) { + return savedCursorDetour(rng, dims) + } + if (roll < 0.94) { + return oscOp(rng, profile) + } + return modeToggle(rng, profile) +} + +export function buildAgentTuiStreamOps( + rng: () => number, + dims: AgentTuiStreamDims, + profile: AgentTuiStreamProfile +): string[] { + const ops: string[] = [] + for (let i = 0; i < profile.opCount; i++) { + ops.push(nextOp(rng, dims, profile)) + } + return ops +} + +/** Splits a stream at random boundaries, including inside escape sequences + * (PTY chunking does that constantly). Never splits a surrogate pair: PTY + * bytes are decoded to complete code points before reaching JS strings. */ +export function splitIntoRandomChunks( + rng: () => number, + stream: string, + bounds: { minLen: number; maxLen: number } +): string[] { + const chunks: string[] = [] + let cursor = 0 + while (cursor < stream.length) { + let end = Math.min(stream.length, cursor + int(rng, bounds.minLen, bounds.maxLen)) + const boundaryCode = stream.charCodeAt(end - 1) + if (end < stream.length && boundaryCode >= 0xd800 && boundaryCode <= 0xdbff) { + end += 1 + } + chunks.push(stream.slice(cursor, end)) + cursor = end + } + return chunks +} diff --git a/src/shared/terminal-restore-parity-fixture.ts b/src/shared/terminal-restore-parity-fixture.ts new file mode 100644 index 00000000000..87fb5c26a3c --- /dev/null +++ b/src/shared/terminal-restore-parity-fixture.ts @@ -0,0 +1,260 @@ +// Renderer-parity headless terminal + snapshot/replay mirrors for the garble +// differential fuzz suites (headless-emulator-fidelity.fuzz.test.ts and +// hidden-reveal-reconciliation.fuzz.test.ts). Lives in src/shared because the +// main-side and renderer-side fuzz suites both consume it and neither +// tsconfig (tsconfig.node.json / tsconfig.tc.web.json) includes the other +// side's sources. +import { Terminal } from '@xterm/headless' +import { SerializeAddon } from '@xterm/addon-serialize' +import { Unicode11Addon } from '@xterm/addon-unicode11' +import { activateOrcaTerminalUnicodeProvider } from './terminal-unicode-provider' +import { DESKTOP_TERMINAL_SCROLLBACK_ROWS_DEFAULT } from './terminal-scrollback-policy' + +export type ParityTerminal = { + terminal: Terminal + serializeAddon: SerializeAddon +} + +/** Builds an @xterm/headless terminal configured exactly like the renderer + * pane where buffer state is concerned: scrollback + kitty vtExtensions from + * buildDefaultTerminalOptions (pane-terminal-options.ts), Unicode11Addon + * (pane-dom-creation.ts) and the Orca ZWJ provider (pane-lifecycle.ts). + * Font/cursor/render options are omitted — they never alter buffer cells. */ +export function createRendererParityTerminal(dims: { cols: number; rows: number }): ParityTerminal { + const terminal = new Terminal({ + cols: dims.cols, + rows: dims.rows, + scrollback: DESKTOP_TERMINAL_SCROLLBACK_ROWS_DEFAULT, + allowProposedApi: true, + vtExtensions: { kittyKeyboard: true } + }) + const serializeAddon = new SerializeAddon() + terminal.loadAddon(serializeAddon) + terminal.loadAddon(new Unicode11Addon()) + activateOrcaTerminalUnicodeProvider(terminal) + return { terminal, serializeAddon } +} + +export function writeToTerminal(terminal: Terminal, data: string): Promise { + return new Promise((resolve) => terminal.write(data, resolve)) +} + +export async function writeChunksToTerminal(terminal: Terminal, chunks: string[]): Promise { + for (const chunk of chunks) { + await writeToTerminal(terminal, chunk) + } +} + +/** Bottom-anchored visible screen rows (baseY, not viewportY — scroll intent + * is enforced separately by the production restore path). */ +export function visibleRows(terminal: Terminal): string[] { + const buffer = terminal.buffer.active + const rows: string[] = [] + for (let y = 0; y < terminal.rows; y++) { + rows.push(buffer.getLine(buffer.baseY + y)?.translateToString(true) ?? '') + } + return rows +} + +// xterm attribute color modes (Attributes CM_* in xterm's buffer model). +const COLOR_MODE_P16 = 16777216 +const COLOR_MODE_P256 = 33554432 + +/** Known-legitimate serializer normalization: SerializeAddon re-emits palette + * indices 0-15 written via 38;5;N / 48;5;N as classic SGR 30-37/90-97, so a + * restored cell reports CM_P16 where the live cell reported CM_P256. Both + * modes resolve through the same 16 theme slots — no visual difference. */ +function canonicalColorMode(mode: number, color: number): number { + return mode === COLOR_MODE_P256 && color >= 0 && color < 16 ? COLOR_MODE_P16 : mode +} + +/** Per-cell descriptor rows so SGR runs that shift cells are caught even + * when the text matches. Encodes only VISUALLY EFFECTIVE state: + * - glyph cells: char, width, fg, bg, all attribute flags; + * - blank cells (null cells and spaces render identically): width, bg, + * underline/strikethrough (drawn across blanks), and fg only when inverse + * swaps it into the cell background. SerializeAddon legitimately skips + * null cells with cursor motion, dropping their invisible fg/bold/italic + * state, and may materialize a skipped run as plain spaces — neither can + * be seen, so neither may fail the garble gate. + * Trailing default blanks are trimmed: the serializer does not re-emit + * pristine cells past the last written column. */ +export function visibleRowStyles(terminal: Terminal): string[] { + const buffer = terminal.buffer.active + const out: string[] = [] + for (let y = 0; y < terminal.rows; y++) { + const line = buffer.getLine(buffer.baseY + y) + const cells: string[] = [] + for (let x = 0; line && x < line.length; x++) { + const cell = line.getCell(x) + if (!cell) { + continue + } + const chars = cell.getChars() + const fgMode = canonicalColorMode(cell.getFgColorMode(), cell.getFgColor()) + const bgMode = canonicalColorMode(cell.getBgColorMode(), cell.getBgColor()) + if (chars === '' || chars === ' ') { + const blankFlags = [cell.isUnderline(), cell.isStrikethrough()] + .map((flag) => (flag ? '1' : '0')) + .join('') + const inverseFg = cell.isInverse() ? `·if${fgMode}:${cell.getFgColor()}` : '' + cells.push( + `▯·w${cell.getWidth()}·b${bgMode}:${cell.getBgColor()}·${blankFlags}${inverseFg}` + ) + continue + } + const flags = [ + cell.isBold(), + cell.isDim(), + cell.isItalic(), + cell.isUnderline(), + cell.isInverse(), + cell.isStrikethrough() + ] + .map((flag) => (flag ? '1' : '0')) + .join('') + cells.push( + `${chars}·w${cell.getWidth()}·f${fgMode}:${cell.getFgColor()}·b${bgMode}:${cell.getBgColor()}·${flags}` + ) + } + const defaultBlank = `▯·w1·b0:-1·00` + while (cells.length > 0 && cells.at(-1) === defaultBlank) { + cells.pop() + } + out.push(cells.join('|')) + } + return out +} + +export function cursorPosition(terminal: Terminal): { x: number; y: number } { + return { x: terminal.buffer.active.cursorX, y: terminal.buffer.active.cursorY } +} + +/** KNOWN UPSTREAM BUG predicate (@xterm/addon-serialize 0.15.0-beta.287): + * null cells touching a soft-wrap boundary do not round-trip. Two confirmed + * variants (see the skipped repros in headless-emulator-fidelity.fuzz.test.ts): + * - V1 (cell loss): a wrapped continuation row starting with a NULL cell + * (only erasure creates those — typed spaces have chars ' ') passes the + * addon's wrap-validity ternary (SerializeAddon.ts ~L214 binds as + * `(chars && isDoubleWidth) ? ...`), so the blank is skipped with CUF — + * which clamps at the right margin instead of crossing the wrap boundary, + * overwriting the previous row's last cell and shifting the tail left. + * - V2 (filler artifact): a wrapped pair whose SOURCE row is entirely null + * takes the forced-wrap "magic" path, whose cleanup emits `ESC[0C`; CSI + * param 0 means 1, so the erase lands one cell right and the first filler + * '-' stays visible on the restored screen. + * The fuzz suites use this predicate to tolerate (and count) exactly these + * divergences without masking unknown ones. */ +export function bufferHasSerializeHostileWrappedRow(terminal: Terminal): boolean { + const buffer = terminal.buffer.active + for (let y = 1; y < buffer.length; y++) { + const line = buffer.getLine(y) + if (!line?.isWrapped) { + continue + } + if (line.getCell(0)?.getChars() === '') { + return true + } + const previous = buffer.getLine(y - 1) + let previousIsAllNull = previous !== undefined + for (let x = 0; previous && x < previous.length; x++) { + if (previous.getCell(x)?.getChars() !== '') { + previousIsAllNull = false + break + } + } + if (previousIsAllNull) { + return true + } + } + return false +} + +/** Full normal-buffer text with trailing blank rows trimmed (SerializeAddon + * restores content rows; both sides may differ only in trailing blanks). */ +export function normalBufferRowsTrimmed(terminal: Terminal): string[] { + const buffer = terminal.buffer.normal + const rows: string[] = [] + for (let y = 0; y < buffer.length; y++) { + rows.push(buffer.getLine(y)?.translateToString(true) ?? '') + } + while (rows.length > 0 && rows.at(-1) === '') { + rows.pop() + } + return rows +} + +// Mirror of applyMainBufferSnapshot's clear preamble (pty-connection.ts): +// normal-buffer restores wipe screen+scrollback+home; alt-screen restores +// clear only the alt screen so the normal buffer's scrollback survives. +export const SNAPSHOT_REPLAY_PREAMBLE_NORMAL = '\x1b[2J\x1b[3J\x1b[H' +export const SNAPSHOT_REPLAY_PREAMBLE_ALT = '\x1b[?1049h\x1b[2J\x1b[H' + +// Twin of POST_REPLAY_LIVE_SNAPSHOT_RESET (layout-serialization.ts) — the +// renderer suite pins equality against the real constant so drift fails fast. +export const POST_REPLAY_LIVE_SNAPSHOT_RESET_PARITY = '\x1b[0 q\x1b[?25h\x1b[?1004l' + +export type ParityMainSnapshot = { + data: string + cols: number + rows: number + seq: number + alternateScreen: boolean + pendingDeliveryStartSeq?: number +} + +/** Mirror of the production main-buffer snapshot the renderer restore path + * consumes: HeadlessEmulator.getSnapshot (snapshotAnsi normalization + + * rehydrateSequences) composed exactly like + * OrcaRuntime.serializeHeadlessTerminalBuffer (alt forces scrollback 0, + * data = rehydrateSequences + snapshotAnsi). The renderer fuzz cannot import + * HeadlessEmulator itself — tsconfig.tc.web.json excludes src/main/daemon. */ +export function buildParityMainBufferSnapshot( + parity: ParityTerminal, + seq: number, + opts: { pendingDeliveryStartSeq?: number; scrollbackRows?: number } = {} +): ParityMainSnapshot { + const { terminal } = parity + const alternateScreen = terminal.buffer.active.type === 'alternate' + // Why scrollback 0 on alt: serializeHeadlessTerminalBuffer forces it so + // normal-buffer scrollback cannot replay underneath an alt-screen TUI frame. + const scrollback = alternateScreen + ? 0 + : (opts.scrollbackRows ?? DESKTOP_TERMINAL_SCROLLBACK_ROWS_DEFAULT) + let snapshotAnsi = parity.serializeAddon.serialize({ scrollback }) + if (alternateScreen) { + // Why: HeadlessEmulator.normalizeSnapshotAnsiForModes drops the + // serializer's duplicate ?1049h; rehydrateSequences re-enters alt. + const marker = '\x1b[?1049h' + const start = snapshotAnsi.lastIndexOf(marker) + if (start !== -1) { + snapshotAnsi = snapshotAnsi.slice(start + marker.length) + } + } + const seqs: string[] = [] + if (alternateScreen) { + seqs.push('\x1b[?1049h') + } + if (terminal.modes.bracketedPasteMode) { + seqs.push('\x1b[?2004h') + } + // Why normal-buffer-only: HeadlessEmulator.getModes reports + // applicationCursor false while the alternate buffer is active, so the + // production rehydrate omits ?1h for alt-screen snapshots. + if (!alternateScreen && terminal.modes.applicationCursorKeysMode) { + seqs.push('\x1b[?1h') + } + // Mouse-mode rehydrate omitted: TerminalMouseModeMirror is main-only and + // mouse reporting is input encoding — it cannot alter rendered output. + const snapshot: ParityMainSnapshot = { + data: seqs.join('') + snapshotAnsi, + cols: terminal.cols, + rows: terminal.rows, + seq, + alternateScreen + } + if (opts.pendingDeliveryStartSeq !== undefined) { + snapshot.pendingDeliveryStartSeq = opts.pendingDeliveryStartSeq + } + return snapshot +}