Starting point: prior agent's garble differential fuzz harness

Three files recovered (were untracked) from a prior agent killed by API
outages, plus a trivial curly-brace lint fix in the op dispatcher so the
pre-commit hook passes:
- src/shared/agent-tui-ansi-fuzz-stream.ts (seeded agent-TUI byte-stream gen)
- src/shared/terminal-restore-parity-fixture.ts (renderer-parity fixture)
- src/main/daemon/headless-emulator-fidelity.fuzz.test.ts (suite 1: differential
  HeadlessEmulator vs @xterm/headless reference on identical bytes)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo-H
2026-07-03 19:52:44 -04:00
co-authored by Orca
parent 44624b5736
commit 623ec8680a
3 changed files with 898 additions and 0 deletions
@@ -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<FidelityDiff | null> {
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<FidelityCase> {
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()
}
})
})
+279
View File
@@ -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<T>(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
}
@@ -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<void> {
return new Promise((resolve) => terminal.write(data, resolve))
}
export async function writeChunksToTerminal(terminal: Terminal, chunks: string[]): Promise<void> {
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
}