fix(tui): validate leading width epoch blocks

Amp-Thread-ID: https://ampcode.com/threads/T-019ff125-f53d-739b-8462-6027fe70f914
Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
Shawn Koh
2026-08-12 16:16:20 +08:00
parent c948d7a289
commit 16c2832404
2 changed files with 69 additions and 2 deletions
@@ -191,6 +191,7 @@ export class TranscriptContainer
segment: BlockSegment;
childBoundary: unknown;
childHasBoundary: boolean;
precedingSegments: BlockSegment[];
trailingSegments: BlockSegment[];
}
>();
@@ -265,12 +266,14 @@ export class TranscriptContainer
typeof child.captureNativeScrollbackWidthEpoch === "function" &&
typeof child.resolveNativeScrollbackWidthEpoch === "function" &&
typeof child.getNativeScrollbackWidthEpochRows === "function";
const segmentIndex = this.#segments.indexOf(segment);
const marker = {};
this.#widthEpochBoundaries.set(marker, {
segment,
childBoundary: childHasBoundary ? child.captureNativeScrollbackWidthEpoch?.() : undefined,
childHasBoundary,
trailingSegments: this.#segments.slice(this.#segments.indexOf(segment) + 1),
precedingSegments: this.#segments.slice(0, segmentIndex),
trailingSegments: this.#segments.slice(segmentIndex + 1),
});
return marker;
}
@@ -279,8 +282,26 @@ export class TranscriptContainer
if (typeof boundary !== "object" || boundary === null) return undefined;
const marker = this.#widthEpochBoundaries.get(boundary);
if (!marker) return undefined;
const current = this.#segments.find(segment => segment.component === marker.segment.component);
const currentIndex = this.#segments.findIndex(segment => segment.component === marker.segment.component);
const current = this.#segments[currentIndex];
if (!current) return undefined;
if (currentIndex !== marker.precedingSegments.length) return undefined;
for (let i = 0; i < marker.precedingSegments.length; i++) {
const captured = marker.precedingSegments[i]!;
const preceding = this.#segments[i]!;
// A width-dependent physical row count cannot distinguish ordinary
// reflow from logical growth. Without a mutation version the leading
// boundary is unverifiable, so replay the epoch conservatively.
if (
preceding.component !== captured.component ||
!captured.finalized ||
!preceding.finalized ||
captured.version === undefined ||
preceding.version !== captured.version
) {
return undefined;
}
}
if (!marker.childHasBoundary) {
if (marker.segment.rowCount === 0) return current.startRow;
if (!marker.segment.finalized) return undefined;
@@ -78,6 +78,20 @@ class DeclaredSettledStreamingBlock extends StreamingBlock {
}
}
class WidthEpochStreamingBlock extends DeclaredSettledStreamingBlock {
captureNativeScrollbackWidthEpoch(): unknown {
return {};
}
resolveNativeScrollbackWidthEpoch(_boundary: unknown): number | undefined {
return 1;
}
getNativeScrollbackWidthEpochRows(): number | undefined {
return 1;
}
}
class CountingFinalizedBlock implements Component {
renderCount = 0;
#lines: string[];
@@ -369,6 +383,38 @@ describe("TranscriptContainer", () => {
expect(rendered.at(-1)).toContain("Finalized notice");
});
it("replays conservatively when finalized history before a live source has no mutation version", () => {
const container = new TranscriptContainer();
const history = new CountingFinalizedBlock(["history"]);
const live = new WidthEpochStreamingBlock(["stable", "pending"], 1);
container.addChild(history);
container.addChild(live);
container.render(40);
const boundary = container.captureNativeScrollbackWidthEpoch();
history.set(["history", "late image"]);
container.render(17);
expect(container.resolveNativeScrollbackWidthEpoch(boundary)).toBeUndefined();
});
it("rejects a width epoch when versioned finalized history before its live source grows", () => {
const container = new TranscriptContainer();
const history = new VersionedFinalizedBlock(["history"]);
const live = new WidthEpochStreamingBlock(["stable", "pending"], 1);
container.addChild(history);
container.addChild(live);
container.render(40);
const boundary = container.captureNativeScrollbackWidthEpoch();
container.render(17);
expect(container.resolveNativeScrollbackWidthEpoch(boundary)).toBeGreaterThan(0);
history.mutate(["history", "late image"]);
container.render(17);
expect(container.resolveNativeScrollbackWidthEpoch(boundary)).toBeUndefined();
});
it("rejects a captured live tail that mutates while finalizing", () => {
const container = new TranscriptContainer();
const assistant = new AssistantMessageComponent();