diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 8cda81731..d9e91beeb 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -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. diff --git a/packages/coding-agent/src/modes/components/transcript-container.ts b/packages/coding-agent/src/modes/components/transcript-container.ts index 85355439d..64af9ce42 100644 --- a/packages/coding-agent/src/modes/components/transcript-container.ts +++ b/packages/coding-agent/src/modes/components/transcript-container.ts @@ -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); } 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 f32afee0a..5ff34729a 100644 --- a/packages/coding-agent/test/modes/components/transcript-container.test.ts +++ b/packages/coding-agent/test/modes/components/transcript-container.test.ts @@ -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();