qwen-code/scripts/tui-parity
ChiGao a2ed9928bb
fix(cli): Align OpenTUI mid-turn submit and exit with ink (#10883)
* docs: Design the OpenTUI submit and exit parity batch

Six gaps where the ink renderer has a mechanism the OpenTUI port carried
only the shadow of: bare quit tokens, the missing client shutdown signal
on exit, mid-turn steering that skips the vision bridge and @-expansion,
an e2e leg that cannot hold a stream open, and the noflicker scenario's
outcome being judged globally.

* fix(cli): Let bare quit tokens reach the OpenTUI dispatcher

Typing `exit`, `quit`, `:q` or `:wq` in the OpenTUI renderer sent them to
the model as prose, and mid-turn they queued behind the stream instead of
stopping it. ink rewrites that family to `/quit` ahead of its message
queue; the port had the exemption but not the rewrite, so the gate let a
quit through only in the one spelling that already looked like a command.

* fix(cli): Stop background work before an OpenTUI session exits

Every OpenTUI exit drained the cleanup chain without first telling the
client that shutdown was imminent, so memory extract, dream and skill
review could spawn new agent work during the exit window — the one thing
that keeps a process from finishing. ink makes that signal on its quit
path; here all three exits share one drain, so the signal belongs to the
drain.

* fix(cli): Resolve OpenTUI mid-turn steering like an idle prompt

Text the composer queues while a turn runs reached the model raw: no
`@path` expansion, no read deadline, and no prompt-side vision bridge, so
an image could ride as `inlineData` to a model that cannot read it while
ink converts it and discloses the egress. Both hops now resolve a steered
message the way an idle submission does — expansion under ink's 10 s read
timeout, then the bridge — and a turn that dies mid-resolution hands its
texts back to the queue instead of losing them.

The bridge can also pick a full-turn vision model mid-turn; the rest of
the turn stays on it, including the model named in the mapper's own
notices, so the override is read per boundary rather than once per turn.

Part of U-21 and U-25 in #8662.

* test(cli): Pin the exit check between queued command dispatches

Round four of #10831 noted that the deferred-command drain checks the
shared exit latch twice but only the pre-loop check had a test: deleting
the per-iteration one left the suite green. This adds the crossing that
check exists for — the exit begins while an earlier command is still
awaiting its outcome — by flipping the latch inside the first dispatch and
requiring the second never to run.

Part of U-23 in #8662.

* fix(scripts): Require the offline parity gate's base to fail

The offline no-flicker scenario runs a fixture emitter that injects clears
and unbalanced DEC 2026 as its base side, yet the runner accepted both-pass
as a passing outcome. A base fixture that stopped emitting its defect would
have kept the gate green while proving nothing at all.

Scenarios can now declare their base a defect fixture with
`expectBaseFailure`, which turns both-pass into a failure — set on the
offline scenario only, since the credentials scenario compares against ink
and the self-test override path asserts both-pass deliberately. A gate
failure names its own cause in the report and on the console line.

Part of U-15 in #8662.

* test(integration): Hold the fake model stream open mid-turn

Every mid-turn behaviour in the submit path is invisible to a test that
cannot say "the CLI is streaming right now": the fake server wrote each SSE
chunk synchronously, and awaiting its handler holds a turn only before the
first byte. FakeOpenAIResponse gains holdAfterChunks + holdUntil, so a test
can stop the stream after a delta it can see on screen and release it on
purpose. close() tears held connections down, which is pinned because
"the run cannot hang on my instrument" is exactly the claim an instrument
like this must not make untested.

Four cases use it, one file for both renderers: /quit and a bare `exit`
typed into a live stream, a slash command held back mid-turn, and a steered
@file mention. The last two assert on captured request bodies rather than
screen text, because OpenTUI repaints by diffing cells and renders no slash
command output at all.

Closes U-24 in #8662.

* docs: Record the batch-8 measurement boundary and adjacent gaps

The design doc said each new case was checked red-then-green before its own
fix commit. What was done instead is mutation probes, and two of the four
leave the OpenTUI leg green — so replace the claim with the measured table
and its cause: a submission that skips the mid-turn gate is steered into the
same turn and still reaches the model only after the held turn ends, and
process teardown closes the socket however the turn ends. Both halves are
pinned at unit level, which is a different claim.

Register the three gaps this batch found and deliberately did not fix:
steered text has no transcript echo (U-26), neither hop warns about an
unsupported image format (U-27), and OpenTUI keeps slash command output in a
history no view renders (U-28) — the last is why the first version of the
deferral case failed for a reason unrelated to the gate under test.

* docs: Correct the mid-turn requeue rule in the batch-8 design

The design doc said a timed-out mid-turn read hands its text back to the
queue. The code requeues on an abort only — a timeout or a declined read is
dropped with a warning, which is what ink does and what the steering test
pins — so the sentence described a behaviour the port deliberately does not
have.

* docs: Measure both of the batch-8 drain's exit-latch checks

Deleting the in-loop check reddens exactly the test written for it, and deleting the
pre-loop check reddens nothing at unit level. The pair is what makes the documented
redundancy a measurement instead of an argument, so both now sit in the coverage
boundary alongside the two mutations the E2E leg cannot see.

---------

Co-authored-by: Shaojin Wen <shaojin.wensj@alibaba-inc.com>
2026-09-03 09:31:05 +00:00
..
fixtures fix(cli): Align OpenTUI mid-turn submit and exit with ink (#10883) 2026-09-03 09:31:05 +00:00
lib fix(cli): Align OpenTUI mid-turn submit and exit with ink (#10883) 2026-09-03 09:31:05 +00:00
test fix(cli): Align OpenTUI mid-turn submit and exit with ink (#10883) 2026-09-03 09:31:05 +00:00
accept-noflicker.sh feat(opentui): bundle assets, CI matrix and parity tooling (Batch 7) (#10770) 2026-09-02 08:36:20 +00:00
component-parity.sh feat(opentui): bundle assets, CI matrix and parity tooling (Batch 7) (#10770) 2026-09-02 08:36:20 +00:00
README.md fix(cli): Align OpenTUI mid-turn submit and exit with ink (#10883) 2026-09-03 09:31:05 +00:00
runner.mjs fix(cli): Align OpenTUI mid-turn submit and exit with ink (#10883) 2026-09-03 09:31:05 +00:00
self-test.mjs feat(opentui): bundle assets, CI matrix and parity tooling (Batch 7) (#10770) 2026-09-02 08:36:20 +00:00

TUI parity harness

Deterministic, dependency-light harness that compares a base CLI capture (current Ink behavior) against a fixed CLI capture (OpenTUI behavior) with machine-checkable metrics and reviewable artifacts. Node built-ins plus the repository's existing @lydell/node-pty capability for PTY allocation.

Runner contract

node scripts/tui-parity/runner.mjs \
  --scenario <file|dir> ... \
  --out <dir> \
  [--base '<command>'] [--fixed '<command>']
  • --scenario is a *.scenario.json file or a directory of them. --out is the artifact directory. Exit codes: 0 pass, 1 threshold/capture failure, 2 usage or validation error.
  • --base / --fixed override the scenario commands (single scenario only). This is the real-CLI contract: point base at the Ink build and fixed at the OpenTUI build with the same scenario parameters. Overrides always capture through a native PTY. Relative .js/.mjs/.cjs script paths after node resolve against the repository root. The final resolved argv (after any override) is re-validated before anything is captured: declared compareParams must be present and equal on both sides, and terminal-size flags must match the capture geometry. A divergent override aborts the run with a parameter-binding error instead of executing under a stale "validated" claim.
  • Base and fixed always share the scenario's terminal size, timeout, stdin input, environment, and thresholds. Scenarios that attach per-side parameters are rejected. compareParams names argv flags whose values are extracted, normalised (--flag value and --flag=value are equivalent), and required to be present and equal on both sides; terminal-size flags (--rows/--lines/--columns/--cols) must additionally match terminal.rows/terminal.columns. Any mismatch fails validation before anything runs.

Native TTY capture is enforced

Plain argv commands are captured through a native PTY allocated by the harness (@lydell/node-pty, rows/columns and TERM from the scenario). The child therefore runs with a real TTY, and each side's summary.json records the evidence (capture.tty: mode, backend, allocated, rows, columns, handshake). If no PTY backend is available, direct capture is refused with native TTY capture refused: ...; the harness never silently falls back to non-TTY pipes for a real CLI. Two explicit waivers exist, both recorded in artifacts and reports:

  • "pty": "fixture" — the command is a deterministic fixture that needs no TTY (the harness's own emitters). Captured over pipes and clearly marked as waived; real-CLI captures may not use this mode.
  • "pty": "wrapped" — the command already runs inside a caller-supplied PTY wrapper (expect, tmux, script, or lib/tty-handshake.mjs). The harness generates a per-run nonce in TUI_PARITY_PTY_NONCE and verifies that the captured stream contains the matching ESC ] 697 ; tty-handshake ; <nonce> BEL marker. lib/tty-handshake.mjs emits that marker only when its stdout really is a TTY, and refuses otherwise. A missing handshake fails the side.

TUI_PARITY_NO_PTY=1 forces the no-backend path in tests.

Scenario schema

{
  "id": "kebab-case-id",
  "description": "what is being compared",
  "terminal": { "rows": 12, "columns": 40 },
  "timeoutMs": 10000,
  "input": [{ "data": "\r", "delayMs": 0 }],
  "env": { "KEY": "value" },
  "compareParams": ["--frames", "--rows"],
  "commands": {
    "base": ["argv", "captured through a native PTY"],
    "fixed": { "argv": ["argv..."], "pty": "fixture" }
  },
  "thresholds": { "maxFullScreenClears": 0 },
  "expectBaseFailure": false,
  "proves": "what a passing run proves",
  "doesNotProve": "what it cannot prove"
}

commands.<side> is either a plain argv array (native PTY capture) or an object with argv and optional pty ("fixture" or "wrapped"). Any other per-side key is rejected.

expectBaseFailure (optional boolean, default false) declares that the base side is a defect fixture rather than a clean reference. Such a scenario passes only when the base side fails: a both-pass there means the fixture emitted no defect, so the comparison would report success while proving nothing.

Thresholds (all optional, evaluated per side): maxFullScreenClears, maxPartialScreenErases, maxLineErases, maxDuplicateEvents, maxDec2026Unbalanced (integers >= 0), and requireSync, requireEventMarkers, requireExitCodeZero (booleans; the exit-code requirement defaults to true; spawn failures, timeouts, PTY refusals, and unverified handshakes always fail a side).

Metrics

Counted from each side's raw stdout:

  • Full-screen clear opcodes: ESC[2J, ESC[3J, RIS (ESC c).
  • Line erases: ESC[K in modes 0/1/2; partial screen erases ESC[J modes 0/1 tracked separately.
  • DEC 2026 synchronised-output begin/end (ESC[?2026h / ESC[?2026l) plus an unbalanced count for stray ends and unclosed begins. requireSync accepts a stream only if at least one begin exists, pairs balance, and — when live-output event markers are measured — every marker occurrence was emitted inside an active DEC 2026 interval. Coverage is measured per event, not inferred from counts: empty begin/end pairs that wrap no events and markers emitted outside every interval fail requireSync even when the begin count equals the unique event count.
  • Live-output event markers using OSC 697;<id>;<seq> (BEL or ST terminated). A marker whose (id, seq) was already seen is a duplicate. Each marker occurrence is additionally classified as covered (inside an active DEC 2026 interval) or unwrapped (outside every interval), and both counts are reported. Without markers the duplicate count is reported as not measurable.
  • Process exit code, signal, timeout, and duration.

Timeout handling tracks and clears every pending input timer, and kills the capture process tree where supported (SIGTERM to the process group, SIGKILL after a 1s grace). The kill escalation survives the main child closing: the SIGKILL step is not cancelled when the captured command exits, and after it fires the harness re-probes the process group until it is confirmed gone (bounded). A descendant that ignores SIGTERM and detaches stdio therefore cannot outlive the capture, and a capture cannot linger on delayed input or orphaned descendants.

proves and doesNotProve are mandatory scenario fields and are copied verbatim into every report, so each scenario states what its result does and does not demonstrate.

Artifacts

Per run under --out:

run-summary.json
<scenario-id>/
  base|fixed/
    raw.ansi        exact captured stdout
    stderr.txt      captured stderr (empty for PTY captures: a real
                    terminal merges stderr into the PTY stream)
    screen.txt      final screen from the built-in terminal model
    summary.json    capture metadata (incl. tty evidence), metrics,
                    verdict, reasons
  comparison.json   side-by-side metrics, deltas, outcome
  report.md         reviewer-facing report

Outcomes: base-fails-fixed-passes (evidence that the fix removes the defect), both-pass (no base-side defect exhibited), fixed-fails (threshold violation), capture-error (spawn failure, refusal, or timeout). The run passes only on the first two — and for a scenario with expectBaseFailure, only on base-fails-fixed-passes.

Fixtures and tests

  • fixtures/emitters/tui-emitter.mjs is a deterministic emitter: identical flags produce identical bytes, and its flags inject the exact defects the metrics count (--clears-per-frame, --scrollback-clears, --dups, --sync, --hang-ms, --exit-code). --tree-hang spawns an idle grandchild and hangs, to exercise process-tree kill on timeout. --stubborn-hang spawns a grandchild that ignores SIGTERM and detaches its stdio, then hangs; it exercises kill escalation that must survive the main child closing.
  • fixtures/wrappers/pty-launcher.mjs wraps a command in a PTY, standing in for external wrappers in handshake tests.
  • fixtures/scenarios/stream-redraw.scenario.json reproduces a failing base and passing fixed side without launching the real CLI.
node --test scripts/tui-parity/test   # unit + end-to-end tests
node scripts/tui-parity/self-test.mjs # full pipeline self-check

Tests cover the PTY refusal and the native/wrapped happy paths, divergent compareParams (including --rows/--columns), per-side parameter rejection, and a bounded regression test where input.delayMs exceeds timeoutMs. They also lock in the three adversarial counterexamples as regressions: empty DEC 2026 pairs plus unwrapped event markers can never satisfy requireSync; a descendant that ignores SIGTERM and detaches stdio is still reaped after the main child closes (fixture and native PTY); and a --base/--fixed override that diverges from compareParams or the capture geometry aborts before anything runs. Fixture results are evidence about the harness and its metrics, not about the products. Real-CLI parity claims require running the same harness with real base/fixed commands through the native PTY contract.

Normalization limits

The built-in terminal model supports printable text, CR/LF/BS/TAB, cursor movement, ED/EL/IL/DL/ICH/DCH/ECH, SGR (ignored), alternate screen (47/1047/1049), and RIS. Scroll regions, double-width characters, and wide-grapheme measurement are not modeled; screen.txt is a review aid, and the machine-checkable part of the harness is the metric counts. PTY captures apply the terminal line discipline (e.g. LF echoed as CRLF); metrics count opcodes, not whitespace, so this does not change threshold semantics.