Files
orca/src/main/daemon/serialize-grid-transcript-replay.test.ts
T
Neil fba7b753ec Cache mobile typechecks and overlap CI setup (#28192)
* ci: run mobile runtime test groups on separate runners

* ci: use a lightweight runner for the mobile result gate

* ci: distribute Bun tests across both mobile runners

* ci: reuse mobile compiler state without adding runner queues

* test: verify eviction of source variants in one cache bucket

* test: baseline existing serializer limits in the capacity capture
2026-10-13 03:35:51 -07:00

287 lines
12 KiB
TypeScript
Raw 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.
import { createHash } from 'node:crypto'
import { existsSync, readdirSync, readFileSync } from 'node:fs'
import { basename, join } from 'node:path'
import { describe, expect, it } from 'vitest'
import { mulberry32 } from '../../shared/agent-tui-ansi-fuzz-stream'
import type { SerializeFuzzCase, SerializeFuzzStep } from './serialize-grid-fuzz-stream'
import {
loadOldSerializer,
NEW_SERIALIZER,
runSerializeFuzzCase,
verdicts,
type Verdict
} from './serialize-grid-roundtrip'
// Replays captured agent/TUI PTY transcripts through the serialize round trip
// under resize schedules the app never saw (a pane resized while hidden), and
// serializes at random chunk boundaries. With ORCA_OLD_SERIALIZE_ADDON set it
// also checks I1/I3 against the previous build; see serialize-grid.differential.fuzz.test.ts.
// SERIALIZE_TRANSCRIPT_DIR=<dir> also replay local, uncommitted captures (<name>.txt + .meta.json)
// SERIALIZE_TRANSCRIPT_SEEDS=20 seeds per transcript × schedule (default 2)
const FIXTURE_DIRS = [
join(__dirname, '../runtime/__fixtures__'),
join(__dirname, '__fixtures__/pty-transcripts')
]
const EXTRA_DIR = process.env.SERIALIZE_TRANSCRIPT_DIR
const OLD_ADDON_PATH = process.env.ORCA_OLD_SERIALIZE_ADDON
const SEEDS = Math.max(1, Number(process.env.SERIALIZE_TRANSCRIPT_SEEDS) || 2)
// Checkpoints (default seeds) whose new replay diverges exactly as the previous
// build's did — pre-existing serializer limitations, not regressions (verified
// with ORCA_OLD_SERIALIZE_ADDON): the live SGR pen leaks into the alt buffer, and
// an alt buffer first entered after a shrink keeps hidden scrollback. Shrink when one is fixed.
const KNOWN_PREEXISTING_I2_FAILURES: Record<string, number> = {
less: 6,
nano: 2,
opencode: 5,
// Captured boots expose the existing background/cursor restore loss; serializer code is unchanged.
'opencode-1-18-32-timed-boot-hidden-pane': 16,
'opencode-1-18-32-timed-boot-slow': 16,
'opencode-1-18-32-timed-first-launch': 16,
'opencode-2-0-18-timed-boot-hidden-pane': 14,
// STA-9359 OpenCode 2 captures, serializer untouched: every background diff is the live pen's
// panel colour on restored cells, plus the rows kept below the source after a shrink.
'opencode-2-0-14-timed-cold-standalone': 4,
'opencode-2-0-21-timed-cold-standalone': 36,
'opencode-2-0-21-timed-cold-standalone-hidden-pane': 6,
'opencode-2-0-21-timed-natural-load-enter-dropped': 12,
'opencode-cmd-2-0-21-timed-warm-server': 21,
// The same, plus the cursor one column short after a shrink and the pen's bold bit on a glyph.
'opencode-2-0-21-timed-narrow-pane': 8,
// Shrink leaves the cursor one column short; also present in the pre-Qoder serializer.
'qoder-no-account': 2,
'qoder-ready': 2,
// Codex 0.157 header border restores with an extra attribute bit (STA-8628 fixtures).
'codex-0157-config-override-embedded-warning': 22,
'codex-0157-effort-override-embedded-warning': 4,
'codex-0157-no-daemon-effort-override': 16,
'codex-0157-plain-ready': 18,
// Fresh-home 0.157/0.158 captures: the live pen's true-colour fg/bg leaks onto restored cells.
'codex-0157-fresh-home-daemon-install': 48,
'codex-0158-fresh-home-greeting': 9,
'codex-0158-model-announcement-dialog': 8,
// Codex 0.157/0.158 startup-dialog captures: the same live-pen true-colour leak onto restored cells.
'codex-0157-update-available-dialog': 26,
'codex-0158-update-available-dialog': 8,
'codex-0157-hooks-review-dialog': 24,
'codex-0158-hooks-review-dialog': 8,
'codex-0157-model-retired-dialog': 22,
'codex-0158-model-retired-dialog': 6,
// Same extra dim bit on the 0.157/0.158 header row (STA-8834 fixtures).
'codex-0-157-1-update-dialog': 16,
'codex-0-157-1-timed-sleep-turn': 6,
'codex-0-158-0-approval': 12,
'codex-0-158-0-timed-turn': 20,
'codex-0-158-0-trustprompt': 36,
// Fullscreen startup captures diverge identically with the base b58f8197dc36 serializer.
'codex-fullscreen-custom-footer': 18,
'codex-fullscreen-early-input': 4,
'codex-fullscreen-multiline-early-input': 10,
'codex-fullscreen-startup': 14,
// Capacity capture: identical attribute leaks with the pre-capture 4559c250b3e serializer.
'codex-model-capacity-fullscreen': 12,
'claude-dialog-trust-workspace-answered': 13,
// DSH-TUI's whale intro paints whole rows of 24-bit background, and every one of this
// transcript's divergences is the same shape: `visible-grid row=0`, a true-colour
// background that the round trip does not restore to default. Verified as upstream, not a
// regression, by replaying it against the previous build
// (`build-serialize-addon-at-ref.mjs --ref origin/main`): I1 and I3 both hold.
'dsh-tui-ready-no-key': 10,
// Hermes banner cells restore with an extra bold bit under the jitter schedule.
'hermes-tui-ready': 2,
// STA-8741 agy/Cline/Prime captures, serializer untouched: the same true-colour background
// left on restored cells as DSH, plus Prime's cursor row after its alternate-screen repaints.
'antigravity-1-2-14-busy-thinking': 2,
'antigravity-1-2-14-command-palette': 4,
'antigravity-1-2-14-draft': 4,
'antigravity-1-2-14-model-picker': 2,
'antigravity-1-2-14-picker-dismissed': 2,
'antigravity-1-2-14-ready': 12,
'antigravity-1-2-14-ready-80x24': 2,
'antigravity-1-2-14-ready-accept-edits': 4,
'antigravity-1-2-14-ready-plan': 4,
'antigravity-1-2-14-trust-dialog': 26,
'cline-3-0-66-busy-streaming': 6,
'cline-3-0-66-draft': 6,
'cline-3-0-66-permission': 6,
'cline-3-0-66-promo': 15,
'cline-3-0-66-ready': 11,
'cline-3-0-66-ready-80x24': 2,
'cline-3-0-66-slash-menu': 6,
'cline-3-0-66-turn-ended': 10,
'prime-agent-0-9-5-ready': 18,
'prime-agent-0-9-5-turn': 4,
'prime-agent-0-9-8-busy-streaming': 16,
'prime-agent-0-9-8-draft': 10,
'prime-agent-0-9-8-ready': 22,
'prime-agent-0-9-8-ready-80x24': 16,
'prime-agent-0-9-8-ready-after-question': 2,
'prime-agent-0-9-8-slash-menu': 34,
'prime-agent-0-9-8-tool-turn': 40,
'prime-agent-0-9-8-trace-question': 4,
'prime-agent-0-9-8-turn-ended': 24,
// OMP 18.4.5 captures, serializer untouched: after a shrink the restored cursor sits one column
// short, as for Qoder. The unscrubbed captures diverge identically, so the scrub is not the cause.
'omp-18-composer': 2,
'omp-18-composer-narrow': 7,
'omp-18-setup': 6
}
// Exact resize checkpoints and full GridDiff hashes from base 6835b9b4e3ea, not this branch.
const FREEBUFF_BASELINE: Record<string, readonly string[]> = JSON.parse(
readFileSync(join(__dirname, '__fixtures__/freebuff-serialize-baseline.json'), 'utf8')
)
type Transcript = { name: string; data: string; cols: number; rows: number }
type Schedule = 'none' | 'shrink' | 'shrink-grow' | 'jitter'
const SCHEDULES: readonly Schedule[] = ['none', 'shrink', 'shrink-grow', 'jitter']
function readTranscripts(dir: string): Transcript[] {
return readdirSync(dir)
.filter((file) => file.endsWith('.txt'))
.map((file) => {
const metaPath = join(dir, file.replace(/\.txt$/, '.meta.json'))
const meta: { cols?: number; rows?: number } = existsSync(metaPath)
? JSON.parse(readFileSync(metaPath, 'utf8'))
: {}
return {
name: basename(file, '.txt'),
data: readFileSync(join(dir, file), 'utf8'),
cols: meta.cols ?? 120,
rows: meta.rows ?? 40
}
})
}
// Never splits a surrogate pair: PTY bytes reach JS as whole code points.
function chunk(data: string, rng: () => number, count: number): string[] {
const chunks: string[] = []
let start = 0
while (start < data.length) {
let end = Math.min(data.length, start + 1 + Math.floor(rng() * ((2 * data.length) / count)))
const code = data.charCodeAt(end - 1)
end += end < data.length && code >= 0xd800 && code <= 0xdbff ? 1 : 0
chunks.push(data.slice(start, end))
start = end
}
return chunks
}
function buildReplayCase(
transcript: Transcript,
schedule: Schedule,
conpty: boolean,
seed: number
): SerializeFuzzCase {
const rng = mulberry32(seed)
const chunks = chunk(transcript.data, rng, 24)
const { cols, rows } = transcript
const narrow = Math.max(8, Math.floor(cols * (0.4 + rng() * 0.4)))
const steps: SerializeFuzzStep[] = []
chunks.forEach((data, i) => {
const at = i / chunks.length
const next = (i + 1) / chunks.length
if (schedule === 'shrink' && at <= 0.5 && next > 0.5) {
steps.push({ kind: 'resize', cols: narrow, rows })
}
if (schedule === 'shrink-grow' && at <= 0.3 && next > 0.3) {
steps.push({ kind: 'resize', cols: narrow, rows: Math.max(4, rows - 6) })
}
if (schedule === 'shrink-grow' && at <= 0.7 && next > 0.7) {
steps.push({ kind: 'resize', cols, rows })
}
if (schedule === 'jitter' && rng() < 0.25) {
steps.push({ kind: 'resize', cols: Math.max(8, cols + Math.floor(rng() * 21) - 14), rows })
}
steps.push({ kind: 'write', data })
if (rng() < 0.15) {
steps.push({ kind: 'check', scrollback: rng() < 0.5 ? 5000 : 0 })
}
})
steps.push({ kind: 'check', scrollback: 5000 })
return {
seed,
category: conpty ? 'conpty' : 'normal',
cols,
rows,
sourceScrollback: 1000,
steps
}
}
describe('serialize round trip over captured PTY transcripts', () => {
const transcripts = [
...FIXTURE_DIRS.flatMap(readTranscripts),
...(EXTRA_DIR ? readTranscripts(EXTRA_DIR) : [])
]
const serializers = OLD_ADDON_PATH
? [loadOldSerializer(OLD_ADDON_PATH), NEW_SERIALIZER]
: [NEW_SERIALIZER]
it.each(transcripts.map((t) => [t.name, t] as const))(
'%s: no I1 byte diff and no I3 regression under resize schedules',
async (_name, transcript) => {
const counts: Partial<Record<Verdict | 'checks', number>> = {}
const blocking: string[] = []
const failureSignatures: string[] = []
for (const schedule of SCHEDULES) {
for (const conpty of [false, true]) {
for (let seed = 1; seed <= SEEDS; seed++) {
const testCase = buildReplayCase(transcript, schedule, conpty, seed)
const run = await runSerializeFuzzCase(testCase, serializers)
expect(run.sourceCrash).toBeNull()
for (const check of run.checks) {
counts.checks = (counts.checks ?? 0) + 1
if (process.env.SERIALIZE_FUZZ_REPORT === '1' && check.gridDiff.new) {
const d = check.gridDiff.new
console.log(
` ${transcript.name} ${schedule} conpty=${conpty} seed=${seed}: ${d.stage} row=${d.row} ${JSON.stringify(d.expected)?.slice(0, 160)} -> ${JSON.stringify(d.actual)?.slice(0, 160)}`
)
}
if (check.gridDiff.new) {
const signature = createHash('sha256')
.update(JSON.stringify(check.gridDiff.new))
.digest('hex')
failureSignatures.push(
`${schedule}/${conpty}/${seed}/${check.stepIndex}:${signature}`
)
}
for (const verdict of verdicts(check, serializers.length > 1)) {
counts[verdict] = (counts[verdict] ?? 0) + 1
if (verdict === 'i1-bytes-differ' || verdict === 'regression') {
blocking.push(
`${verdict} schedule=${schedule} conpty=${conpty} seed=${seed} step=${check.stepIndex} new=${JSON.stringify(check.gridDiff.new)?.slice(0, 400)}`
)
}
}
}
}
}
}
if (process.env.SERIALIZE_FUZZ_REPORT === '1') {
console.log(`${transcript.name} ${JSON.stringify(counts)}`)
}
expect(blocking).toEqual([])
if (SEEDS === 2 && transcript.name === 'hermes-tui-ready') {
expect(failureSignatures).toEqual([
'jitter/false/2/34:0db2561506ff28d2e83276b07bc8684541957f61e488b6f8081627e8cad80c63',
'jitter/true/2/34:0db2561506ff28d2e83276b07bc8684541957f61e488b6f8081627e8cad80c63'
])
}
// The pre-#22586 serializer has these same row-1 bold diffs on the untouched capture.
if (SEEDS === 2 && transcript.name === 'dsb-6-9-0-folder') {
expect(failureSignatures).toEqual([
'jitter/false/2/19:2050835176269320209c56be5ae9e525424635dfca511d44d3911d3a22ba9d27',
'jitter/true/2/19:2050835176269320209c56be5ae9e525424635dfca511d44d3911d3a22ba9d27'
])
} else if (SEEDS === 2 && transcript.name.startsWith('freebuff-')) {
expect(failureSignatures).toEqual(FREEBUFF_BASELINE[transcript.name] ?? [])
} else if (!OLD_ADDON_PATH && SEEDS === 2) {
expect(counts['new-fail'] ?? 0).toBe(KNOWN_PREEXISTING_I2_FAILURES[transcript.name] ?? 0)
}
},
600_000
)
})