test(tui): consolidated streaming scrollback logic and testing infrastructure

- Exposed `Markdown.getLastRenderSettledRows` to track streaming progress.
- Simplified scrollback architecture by migrating logic to a unified `SeamLineList` boundary.
- Removed legacy safe-end methods and redundant `findCommittedPrefixResync` test suites.
- Fixed live tool and evaluation preview duplication issues during re-layouts.
This commit is contained in:
can1357
2026-07-04 11:22:05 +02:00
parent 42fc4e6b0a
commit 81097c4ffa
7 changed files with 350 additions and 518 deletions
+1 -1
View File
@@ -2659,7 +2659,7 @@ mod tests {
for index in 0..300 {
let path =
root
.join(format!("dir_{:02}/nested_{:02}/file_{index:03}.txt", index % 12, index % 5,));
.join(format!("dir_{:02}/nested_{:02}/file_{index:03}.txt", index % 12, index % 5));
let content = if index % 3 == 0 {
format!("before {index}\nneedle {index}\nafter {index}\n")
} else {
+10 -2
View File
@@ -2,10 +2,18 @@
## [Unreleased]
### Added
- Added `Markdown.getLastRenderSettledRows()`: the rendered frozen-token-prefix row count of the most recent streaming render, exposed (hard-monotone per text lineage) for native-scrollback commit gating. Frozen-prefix code blocks now syntax-highlight during streaming renders so settled rows stay byte-stable across finalize.
### Changed
- Simplified native scrollback logic to use a single finality boundary
- Optimized committed prefix auditing by removing exempt-zone tracking
- Simplified native scrollback logic to use a single finality boundary: `NativeScrollbackLiveRegion` is now one method (`getNativeScrollbackLiveRegionStart`), and the engine commits a row only when the seam declares it final. Live rows that scroll above the viewport are deferred (never force-committed) and backfill history in order once they settle — a still-mutating block can no longer strand a stale copy in immutable scrollback. `getNativeScrollbackCommitSafeEnd`/`getNativeScrollbackSnapshotSafeEnd` are removed.
- Optimized committed prefix auditing by removing exempt-zone tracking: the audit is single-zone (tail sample + first-divergence re-anchor) and runs only when the composed frame's stable prefix does not cover the committed rows.
### Fixed
- Fixed live tool/eval preview boxes duplicating into native scrollback mid-run (a collapsed streaming preview's head was force-committed when it scrolled above the viewport, and the next re-layout re-anchored and recommitted the whole box below the stale copy).
## [16.3.5] - 2026-07-04
+9 -15
View File
@@ -9,8 +9,8 @@ import { VirtualTerminal } from "./virtual-terminal";
class Transcript implements Component, NativeScrollbackLiveRegion {
lines: string[] = [];
/** Declared-final boundary: rows above it may enter native scrollback. */
seam = 0;
safeEnd: number | undefined;
invalidate(): void {}
@@ -21,10 +21,6 @@ class Transcript implements Component, NativeScrollbackLiveRegion {
getNativeScrollbackLiveRegionStart(): number | undefined {
return this.seam;
}
getNativeScrollbackCommitSafeEnd(): number | undefined {
return this.safeEnd;
}
}
class Editor implements Component, Focusable {
@@ -58,29 +54,27 @@ describe("abort-collapse gap regression", () => {
// 20 finalized rows + live block streaming.
transcript.lines = [...rows("hist-", 20), ...rows("live-", 4)];
transcript.seam = 20;
transcript.safeEnd = 20; // nothing sealed yet
transcript.seam = 20; // nothing of the live block declared final yet
try {
tui.start();
await scheduler.drain(term);
// Stream: live block grows, sealed prefix advances behind the tail.
// Stream: live block grows, its declared-final prefix advances behind
// the tail (a streaming reply's settled markdown prefix).
for (let grow = 8; grow <= 24; grow += 4) {
transcript.lines = [...rows("hist-", 20), ...rows("live-", grow)];
transcript.seam = 20;
transcript.safeEnd = 20 + grow - 2; // sealed prefix trails by 2
transcript.seam = 20 + grow - 2; // declared-final prefix trails by 2
tui.requestRender();
await scheduler.drain(term);
}
// ABORT: the live block collapses after some of its append-only prefix
// reached native scrollback. The viewport should pull the short tail
// back down instead of pinning the editor near the top with blank rows
// underneath.
// ABORT: the live block collapses after some of its declared-final
// prefix reached native scrollback. The viewport should pull the short
// tail back down instead of pinning the editor near the top with blank
// rows underneath.
transcript.lines = [...rows("hist-", 20), "aborted!", "interrupted"];
transcript.seam = 22;
transcript.safeEnd = 22;
tui.requestRender();
await scheduler.drain(term);
@@ -2,24 +2,18 @@ import { describe, expect, it } from "bun:test";
import { findCommittedPrefixResync } from "@oh-my-pi/pi-tui";
// Regression coverage for the committed-prefix resync seam that decides where the
// engine re-anchors #committedRows after a live block re-lays-out at settle.
// engine re-anchors #committedRows after a component violates the declared-final
// contract (a budget-demoted image, a TTSR rewind, a post-finalize mutation).
//
// Contract, condensed from the tui.ts doc:
// findCommittedPrefixResync(frame, prefix, auditTo, exemptFrom, exemptTo, permanentEnd)
// findCommittedPrefixResync(frame, prefix, auditTo)
// ► returns -1 when frame is aligned with prefix
// ► returns i the earliest AUDITED row index where they diverge
// ► rows in [exemptFrom, exemptTo) are exempt — durable in-place drift
// ► rows in [exemptTo, permanentEnd) are hard-scanned in FULL — a settle-time
// edit there re-anchors even when the tail sample would tolerate it
// ► one non-hard mismatch in the tail sample (last 24 audited rows / 8
// non-blank samples) is tolerated (offscreen spinner, single in-place edit)
// ► returns i the earliest row index where they diverge
// ► one mismatch in the tail sample (last 24 rows / 8 non-blank samples)
// is tolerated (a no-seam root's offscreen animated row, a single
// in-place edit) — the stale copy in history is the accepted artifact
// ► frame.length < prefix.length always re-anchors at frame.length so the
// shrunk tail is dropped from history (duplication, never loss)
//
// Issue #4124: a formerly forced-overflow row that later becomes permanent AND
// changes MUST re-anchor at the EARLIEST audited mismatch, not somewhere later
// and not never — otherwise a settle transition strands stale pending chrome
// (e.g. `⏳ SSH: [host]`) above the final settled block.
function rows(prefix: string, count: number): string[] {
return Array.from({ length: count }, (_, i) => `${prefix}${i}`);
@@ -67,88 +61,6 @@ describe("findCommittedPrefixResync", () => {
expect(findCommittedPrefixResync(frame, prefix)).toBe(14);
});
it("re-anchors at the earliest audited mismatch even when a later hard mismatch triggers the audit", () => {
// Issue #4124 core: a formerly forced-overflow row later becomes
// permanent and its content changes. The hard scan detects the
// permanent-zone change and forces the audit; the re-anchor loop must
// walk from row 0 and return the FIRST audited mismatch, not the hard
// scan's stop position — otherwise an earlier stranded pending row
// (e.g. `⏳ SSH: [host]`) is left uncommitted-behind above the settled
// block.
//
// Geometry: auditRows=5 (byte-stable zone [0,5)), durableRows=10 (exempt
// window [5,10)), durableBoundary=15 rose past three previously
// forced-overflow rows (hard-scanned suffix [10,15)). Row 3 changed
// (byte-stable) AND row 12 changed (in the hard-scanned newly-permanent
// zone). The hard scan trips on row 12, but the re-anchor MUST return
// row 3 — the earliest audited divergence.
const prefix = rows("r", 20);
const frame = [...prefix];
frame[3] = "r3-changed";
frame[12] = "r12-permanent-now-changed";
const auditTo = 20;
const exemptFrom = 5;
const exemptTo = 10;
const permanentEnd = 15;
expect(findCommittedPrefixResync(frame, prefix, auditTo, exemptFrom, exemptTo, permanentEnd)).toBe(3);
});
it("hard-scans the newly-permanent forced suffix in full, escaping tail-sample tolerance", () => {
// A single mismatch inside the forced suffix that just became permanent
// (durableBoundary rose past it this frame) MUST re-anchor, even though
// one non-hard mismatch would otherwise be tolerated. This is what
// prevents a pending SSH header from silently swallowing its settled
// replacement — the header row was forced-overflow while streaming, now
// the block finalized, and the tolerance would otherwise eat it.
//
// Geometry: byte-stable zone [0,3), exempt window [3,7), hard-scan zone
// [7,12) — the barrier just finalized past row 11 so those rows are
// declared permanent this frame. Row 10 flips content: the hard scan
// catches it and re-anchors at row 10.
const prefix = rows("r", 20);
const frame = [...prefix];
frame[10] = "r10-settled";
const auditTo = 20;
const exemptFrom = 3;
const exemptTo = 7;
const permanentEnd = 12;
expect(findCommittedPrefixResync(frame, prefix, auditTo, exemptFrom, exemptTo, permanentEnd)).toBe(10);
});
it("keeps drift inside the exempt window silent (durable in-place snapshot)", () => {
// Rows in [exemptFrom, exemptTo) are durable snapshots that legitimately
// drift in place (a streaming table re-aligning columns). Their
// mismatch must NOT re-anchor — otherwise every column-realign frame
// sprays duplicate snapshots.
const prefix = rows("r", 20);
const frame = [...prefix];
frame[6] = "r6-realigned";
frame[7] = "r7-realigned";
const auditTo = 20;
const exemptFrom = 5;
const exemptTo = 15;
const permanentEnd = 5;
expect(findCommittedPrefixResync(frame, prefix, auditTo, exemptFrom, exemptTo, permanentEnd)).toBe(-1);
});
it("re-anchors at the earliest audited mismatch outside the exempt window", () => {
// The exempt window is scoped drift: a mismatch OUTSIDE it (byte-stable
// audited zone [0, exemptFrom) or the forced suffix [exemptTo,
// committed)) still re-anchors, and it does so at the earliest audited
// row — even when the earliest is in the byte-stable zone above the
// exempt window.
const prefix = rows("r", 20);
const frame = [...prefix];
frame[2] = "r2-shift";
frame[16] = "r16-shift";
frame[17] = "r17-shift";
const auditTo = 20;
const exemptFrom = 5;
const exemptTo = 15;
const permanentEnd = 5;
expect(findCommittedPrefixResync(frame, prefix, auditTo, exemptFrom, exemptTo, permanentEnd)).toBe(2);
});
it("re-anchors at frame.length when the frame shrinks into the committed prefix", () => {
// A shrink drops rows the prefix still holds. The engine has no way to
// keep those rows painted — history keeps whatever scrolled off, and
@@ -178,20 +90,4 @@ describe("findCommittedPrefixResync", () => {
// auditTo=10 means rows 10..19 are outside the audit
expect(findCommittedPrefixResync(frame, prefix, 10)).toBe(-1);
});
it("still re-anchors when a mismatch straddles the byte-stable/forced-suffix boundary", () => {
// exempt=[5,15); mismatches at row 4 (byte-stable) and row 16 (forced
// suffix). The re-anchor MUST return 4 (earliest audited), never 16 —
// this guards against a bug where the re-anchor loop started at exTo
// instead of 0.
const prefix = rows("r", 20);
const frame = [...prefix];
frame[4] = "r4-changed";
frame[16] = "r16-changed";
const auditTo = 20;
const exemptFrom = 5;
const exemptTo = 15;
const permanentEnd = 5;
expect(findCommittedPrefixResync(frame, prefix, auditTo, exemptFrom, exemptTo, permanentEnd)).toBe(4);
});
});
+3 -6
View File
@@ -41,8 +41,9 @@ class LineList implements Component {
/**
* Minimal append-only live region. Models the real omp setup where
* `TranscriptContainer` wraps an `AssistantMessageComponent` that reports
* itself as `isTranscriptBlockAppendOnly() === true`.
* `TranscriptContainer` wraps a streaming `AssistantMessageComponent` whose
* rendered rows are all declared final (settled) as they stream, so its
* scrolled-off head commits to pane history mid-stream.
*/
class StreamingLiveRegion implements Component, NativeScrollbackLiveRegion {
#lines: string[];
@@ -62,10 +63,6 @@ class StreamingLiveRegion implements Component, NativeScrollbackLiveRegion {
}
getNativeScrollbackLiveRegionStart(): number | undefined {
return 0;
}
getNativeScrollbackCommitSafeEnd(): number | undefined {
return this.#lines.length;
}
}
+1 -4
View File
@@ -41,10 +41,7 @@ class StreamingLiveRegion implements Component, NativeScrollbackLiveRegion {
}
getNativeScrollbackLiveRegionStart(): number | undefined {
return 0;
}
getNativeScrollbackCommitSafeEnd(): number | undefined {
// Append-only stream: every rendered row is declared final.
return this.#lines.length;
}
}
File diff suppressed because it is too large Load Diff