refactor(agent): unified transcript block finalization logic

- Replaced commit-based stability checks with a unified `isTranscriptBlockFinalized` tracking mechanism.
- Removed deprecated provisional rendering configuration and flags across tool and renderer interfaces.
- Standardized native scrollback boundary logic to pin at the first unfinalized block using settled row verification.
- Updated and refactored test suites to validate block finalization and settled row boundaries instead of deprecated commit stability methods.
This commit is contained in:
can1357
2026-07-04 11:22:05 +02:00
parent 6e2bba871e
commit 42fc4e6b0a
14 changed files with 193 additions and 452 deletions
@@ -53,12 +53,22 @@ class StreamingBlock implements Component {
}
}
// A still-live block whose render is provisional (a tool call's tail-window
// streaming preview): the result render replaces it wholesale, so its settled
// rows must never be offered for native-scrollback commit.
class ProvisionalStreamingBlock extends StreamingBlock {
isTranscriptBlockCommitStable(): boolean {
return false;
// A still-live block that can declare a byte-stable rendered prefix. The
// transcript container may commit only those declared rows before finalization.
class DeclaredSettledStreamingBlock extends StreamingBlock {
#settledRows: number;
constructor(lines: string[], settledRows: number) {
super(lines);
this.#settledRows = settledRows;
}
setSettledRows(rows: number): void {
this.#settledRows = rows;
}
getTranscriptBlockSettledRows(): number {
return this.#settledRows;
}
}
@@ -181,7 +191,7 @@ describe("TranscriptContainer", () => {
expect(container.render(80)).toEqual(["a-reflowed", "", "b2"]);
});
it("reports the live block start that gates native scrollback commits", () => {
it("reports undefined as the native scrollback boundary when every block is finalized", () => {
const container = new TranscriptContainer();
const a = new MutableBlock(["a1", "a2"]);
const b = new MutableBlock(["b1"]);
@@ -189,11 +199,11 @@ describe("TranscriptContainer", () => {
container.addChild(b);
expect(container.render(40)).toEqual(["a1", "a2", "", "b1"]);
expect(container.getNativeScrollbackLiveRegionStart()).toBe(3);
expect(container.getNativeScrollbackLiveRegionStart()).toBeUndefined();
b.set(["b1", "b2"]);
expect(container.render(40)).toEqual(["a1", "a2", "", "b1", "b2"]);
expect(container.getNativeScrollbackLiveRegionStart()).toBe(3);
expect(container.getNativeScrollbackLiveRegionStart()).toBeUndefined();
});
it("keeps an unfinalized block below the seam when a finalized block is appended below it", () => {
@@ -214,8 +224,8 @@ describe("TranscriptContainer", () => {
// The tool's result lands after the card is already below it.
tool.finalize(["✔ write: 4 lines"]);
expect(container.render(40)).toEqual(["✔ write: 4 lines", "", "rule card"]);
// The seam moves past the now-finalized tool.
expect(container.getNativeScrollbackLiveRegionStart()).toBe(2);
// All blocks are finalized; the whole rendered frame is committable.
expect(container.getNativeScrollbackLiveRegionStart()).toBeUndefined();
// Even after finalizing, a late re-layout still repaints in the window.
tool.set(["collapsed"]);
@@ -251,9 +261,8 @@ describe("TranscriptContainer", () => {
const rendered = plain(container.render(80));
expect(rendered).toContain("The config file write went through despite the interruption.");
expect(rendered).not.toContain(USER_INTERRUPT_LABEL);
expect(rendered).toContain("Copied raw SSE stream");
expect(container.getNativeScrollbackLiveRegionStart()).not.toBe(0);
expect(container.getNativeScrollbackLiveRegionStart()).toBeUndefined();
});
it("starts the live region at the earliest of several unfinalized blocks", () => {
@@ -278,113 +287,66 @@ describe("TranscriptContainer", () => {
// The pending block updates freely while live.
pending.finalize(["pending-final"]);
expect(container.render(40)).toEqual(["done-collapsed", "", "pending-final", "", "card"]);
expect(container.getNativeScrollbackLiveRegionStart()).toBeUndefined();
});
it("stops the boundary at the first unfinalized block's first content row when no rows are settled", () => {
const container = new TranscriptContainer();
container.addChild(new MutableBlock(["history"]));
const live = new StreamingBlock(["live-0", "live-1"]);
container.addChild(live);
container.addChild(new MutableBlock(["below"]));
expect(container.render(40)).toEqual(["history", "", "live-0", "live-1", "", "below"]);
expect(container.getNativeScrollbackLiveRegionStart()).toBe(2);
live.set(["live-0 updated", "live-1"]);
expect(container.render(40)).toEqual(["history", "", "live-0 updated", "live-1", "", "below"]);
expect(container.getNativeScrollbackLiveRegionStart()).toBe(2);
});
it("extends the boundary through declared settled rows after stripping leading blank padding", () => {
const container = new TranscriptContainer();
container.addChild(new MutableBlock(["history"]));
const live = new DeclaredSettledStreamingBlock(["", "settled-a", "settled-b", "live-tail", ""], 3);
container.addChild(live);
expect(container.render(40)).toEqual(["history", "", "settled-a", "settled-b", "live-tail"]);
expect(container.getNativeScrollbackLiveRegionStart()).toBe(4);
});
it("never offers a commit-unstable live block's settled rows for native scrollback", () => {
it("returns undefined after the first unfinalized block finalizes", () => {
const container = new TranscriptContainer();
container.addChild(new MutableBlock(["history"]));
// A pending collapsed tool preview: byte-static while the tool executes
// (the spinner stops once args complete), but replaced wholesale by the
// result render — committing any of it would strand a stale call-box
// fragment in terminal history above the final block.
const preview = new ProvisionalStreamingBlock([
"┌ Edit: foo.ts",
"… (2 more hunks above)",
"-old-a",
"+new-a",
"-old-b",
"+new-b",
"└ (streaming)",
]);
container.addChild(preview);
const live = new StreamingBlock(["live"]);
container.addChild(live);
// Far past STABLE_PREFIX_COMMIT_FRAMES: a durable block's settled head
// would have been promoted long ago.
for (let frame = 0; frame < 40; frame++) container.render(40);
expect(container.render(40)).toEqual(["history", "", "live"]);
expect(container.getNativeScrollbackLiveRegionStart()).toBe(2);
expect(container.getNativeScrollbackCommitSafeEnd()).toBeUndefined();
// The result render re-anchors the block top-first; nothing of the stale
// preview was committed, so nothing can be duplicated. Finalizing makes
// the full body commit-safe like any settled block.
preview.finalize(["✔ Edit: foo.ts (+2/-2)", "-old-a", "+new-a", "context"]);
container.render(40);
expect(container.getNativeScrollbackLiveRegionStart()).toBe(2);
expect(container.getNativeScrollbackCommitSafeEnd()).toBe(6);
live.finalize(["done"]);
expect(container.render(40)).toEqual(["history", "", "done"]);
expect(container.getNativeScrollbackLiveRegionStart()).toBeUndefined();
});
it("still promotes a durable live block's settled head after the stability window", () => {
it("pins the boundary at an empty unfinalized block's row position", () => {
const container = new TranscriptContainer();
container.addChild(new MutableBlock(["history"]));
// Default contract (no isTranscriptBlockCommitStable): settled leading
// rows are durable — a streaming assistant message, a top-anchored
// expanded tool stream — and promote once they sit visibly unchanged for
// the whole stability window, holding back only the volatile tail.
const streaming = new StreamingBlock(["head-0", "head-1", "head-2", "head-3", "head-4", "head-5", "tail"]);
container.addChild(streaming);
container.addChild(new StreamingBlock([]));
container.addChild(new MutableBlock(["below"]));
for (let frame = 0; frame < 40; frame++) container.render(40);
expect(container.render(40)).toEqual(["history", "", "below"]);
expect(container.getNativeScrollbackLiveRegionStart()).toBe(1);
});
it("does not let finalized blocks below the first unfinalized block extend the boundary", () => {
const container = new TranscriptContainer();
container.addChild(new MutableBlock(["history"]));
container.addChild(new StreamingBlock(["live"]));
container.addChild(new StreamingBlock(["finalized-below-0", "finalized-below-1"], true));
expect(container.render(40)).toEqual(["history", "", "live", "", "finalized-below-0", "finalized-below-1"]);
expect(container.getNativeScrollbackLiveRegionStart()).toBe(2);
// blockStart 2 + (7 rows - TAIL_VOLATILITY_ROWS holdback of 4) = 5.
expect(container.getNativeScrollbackCommitSafeEnd()).toBe(5);
});
it("withholds the durable snapshot commit for a streaming mermaid reply, then promotes it on finalize", () => {
// A fenced mermaid diagram re-lays-out its whole body every frame as the
// reply streams. If its scrolled-off rows were committed to immutable
// native scrollback (the durable-snapshot path) the later re-layout would
// strand a stale diagram fragment in history that only Ctrl+L clears, so
// the live mermaid reply must advertise no durable snapshot end.
const container = new TranscriptContainer();
container.addChild(new StreamingBlock(["earlier turn"], true));
const assistant = new AssistantMessageComponent();
assistant.updateContent(
makeAssistantMessage({ content: [{ type: "text", text: "```mermaid\nflowchart TD\n A-->B\n```" }] }),
);
container.addChild(assistant);
for (let frame = 0; frame < 4; frame++) container.render(60);
expect(container.getNativeScrollbackSnapshotSafeEnd()).toBeUndefined();
// Finalized: the final layout is permanent and commits like any block.
assistant.markTranscriptBlockFinalized();
container.render(60);
expect(container.getNativeScrollbackSnapshotSafeEnd()).toBeGreaterThan(0);
});
it("withholds the durable snapshot commit for a streaming GFM table, then promotes it on finalize", () => {
// A streaming table re-aligns its columns as rows arrive; its scrolled-off
// rows must not commit an intermediate alignment to immutable scrollback.
const container = new TranscriptContainer();
container.addChild(new StreamingBlock(["earlier turn"], true));
const assistant = new AssistantMessageComponent();
assistant.updateContent(
makeAssistantMessage({ content: [{ type: "text", text: "| Name | Score |\n| --- | --- |\n| a | 1 |" }] }),
);
container.addChild(assistant);
for (let frame = 0; frame < 4; frame++) container.render(60);
expect(container.getNativeScrollbackSnapshotSafeEnd()).toBeUndefined();
assistant.markTranscriptBlockFinalized();
container.render(60);
expect(container.getNativeScrollbackSnapshotSafeEnd()).toBeGreaterThan(0);
});
it("still commits the durable snapshot of a streaming reply without a mermaid fence", () => {
// Guard the narrow scope: an ordinary streaming reply stays commit-stable,
// so its settled rows still reach native scrollback while it streams.
const container = new TranscriptContainer();
container.addChild(new StreamingBlock(["earlier turn"], true));
const assistant = new AssistantMessageComponent();
assistant.updateContent(
makeAssistantMessage({ content: [{ type: "text", text: "A normal streamed answer with **bold** text." }] }),
);
container.addChild(assistant);
for (let frame = 0; frame < 4; frame++) container.render(60);
expect(container.getNativeScrollbackSnapshotSafeEnd()).toBeGreaterThan(0);
});
it("does not re-render finalized rows already committed to native scrollback", () => {
const container = new TranscriptContainer();