From 16c28324045f1ac702b1d2253599642804bfa490 Mon Sep 17 00:00:00 2001 From: Shawn Koh Date: Wed, 12 Aug 2026 16:16:20 +0800 Subject: [PATCH] fix(tui): validate leading width epoch blocks Amp-Thread-ID: https://ampcode.com/threads/T-019ff125-f53d-739b-8462-6027fe70f914 Co-authored-by: Amp --- .../modes/components/transcript-container.ts | 25 +++++++++- .../components/transcript-container.test.ts | 46 +++++++++++++++++++ 2 files changed, 69 insertions(+), 2 deletions(-) diff --git a/packages/coding-agent/src/modes/components/transcript-container.ts b/packages/coding-agent/src/modes/components/transcript-container.ts index 219cfff02..32738ef77 100644 --- a/packages/coding-agent/src/modes/components/transcript-container.ts +++ b/packages/coding-agent/src/modes/components/transcript-container.ts @@ -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; diff --git a/packages/coding-agent/test/modes/components/transcript-container.test.ts b/packages/coding-agent/test/modes/components/transcript-container.test.ts index 372fdf122..ed773459a 100644 --- a/packages/coding-agent/test/modes/components/transcript-container.test.ts +++ b/packages/coding-agent/test/modes/components/transcript-container.test.ts @@ -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();