fix(coding-agent/modes): recomputed prior live block on live-to-frozen transition

- Tracked the previous live child in TranscriptContainer and forced a one-off recompute when a block leaves live status.
- Skipped snapshot reuse for that transitioning block so coalesced finalize-and-append frames seal transcript blocks at their final content.
- Added a regression test covering the stale-prefix freeze case on ED3-risk terminals and confirming the frozen block stays unchanged until thaw.
This commit is contained in:
can1357
2026-06-04 18:46:16 +02:00
parent 52b3d90400
commit bfbb2464bb
3 changed files with 47 additions and 4 deletions
+2
View File
@@ -17,6 +17,8 @@
### Fixed
- Fixed a streamed assistant message freezing at a partial prefix (e.g. only "Nat" of "Natives built, now…") on ED3-risk terminals (Ghostty/kitty/iTerm2/Alacritty), with the final text appearing only after a resize. `TranscriptContainer` freezes each non-live block by replaying its last live render, but render coalescing can finalize a block's content and append the next block within the same throttled frame — so the block was sealed at its stale mid-stream snapshot and never repainted until the next `thaw`. The block that was live on the previous render is now recomputed once on the live→frozen transition, sealing it at its final content.
- Fixed ACP/RPC stdio startup so protocol frames are no longer consumed as one-shot piped prompt input before the JSON-RPC transport starts.
- Fixed `omp completions` to await the completion script write before exiting.
- Fixed `AssistantMessageComponent` exposing its stable-prefix completion API again so streamed assistant messages remain unstable until explicitly completed.
@@ -38,6 +38,11 @@ export class TranscriptContainer extends Container {
// Bumped to invalidate every block's snapshot at once; a snapshot is only
// honored when its stored generation still matches.
#generation = 0;
// The block that was bottom-most (live) on the previous render. When the live
// position moves past it, its snapshot was last refreshed mid-stream and may
// predate content that finalized in the same coalesced frame that appended the
// block now below it — so it must recompute once on the live→frozen transition.
#prevLiveChild: Component | undefined;
override invalidate(): void {
// A theme/global invalidation forces a full recompute on the rebuild that
@@ -67,22 +72,35 @@ export class TranscriptContainer extends Container {
const lines: string[] = [];
const liveIndex = this.children.length - 1;
const liveChild = this.children[liveIndex];
const prevLiveChild = this.#prevLiveChild;
this.#prevLiveChild = liveChild;
for (let i = 0; i < this.children.length; i++) {
const child = this.children[i]! as Component & SnapshotCarrier;
if (i !== liveIndex) {
if (child !== liveChild) {
const snapshot = child[kSnapshot];
// Replay the block's last render from while it was live. A stale
// generation (post-thaw) or width mismatch (resize in flight, an
// explicit rebuild that reconciles history anyway) recomputes instead.
if (snapshot && snapshot.generation === this.#generation && snapshot.width === width) {
// The block that was live on the previous render is also recomputed
// here: TUI render coalescing can advance its content (final streamed
// tokens) in the very frame that appends the block now below it, so its
// cached snapshot predates that final content. Recomputing on the
// transition seals the block at its true final state, not a mid-stream one.
if (
child !== prevLiveChild &&
snapshot &&
snapshot.generation === this.#generation &&
snapshot.width === width
) {
lines.push(...snapshot.lines);
continue;
}
}
const rendered = child.render(width);
// Cache every block's latest render. While a block is live this keeps its
// snapshot current; the frame it stops being live the cache already holds
// its final live render, so nothing recomputes underneath it.
// snapshot current; on the frame it stops being live the recompute above
// refreshes it to the final state before it freezes.
child[kSnapshot] = { width, lines: rendered, generation: this.#generation };
lines.push(...rendered);
}
@@ -53,6 +53,29 @@ describe("TranscriptContainer", () => {
expect(container.render(40)).toEqual(["a2", "b2"]);
});
it("seals the prior block at its final content when finalize+append coalesce (ED3-risk)", () => {
riskFlag.eagerEraseScrollbackRisk = true;
const container = new TranscriptContainer();
const a = new MutableBlock(["Nat"]);
container.addChild(a);
// `a` streamed a partial chunk and rendered while live.
expect(container.render(40)).toEqual(["Nat"]);
// TUI render coalescing: `a` finalizes AND a newer block is appended within
// one throttled frame, so no render happens between the two mutations.
a.set(["Natives built, now..."]);
const b = new MutableBlock(["b1"]);
container.addChild(b);
// The transition frame must seal `a` at its final content, not the stale
// mid-stream snapshot ("Nat") it last rendered while live.
expect(container.render(40)).toEqual(["Natives built, now...", "b1"]);
// Once sealed, a later re-layout of `a` stays frozen until the next thaw.
a.set(["a-collapsed"]);
expect(container.render(40)).toEqual(["Natives built, now...", "b1"]);
});
it("thaw() reconciles frozen blocks to their current state", () => {
riskFlag.eagerEraseScrollbackRisk = true;
const container = new TranscriptContainer();