fix(coding-agent): fixed append-only detection for in-place streaming line growth

- Updated live transcript state derivation to treat a trailing-line extension with unchanged rows as append-only.
- Replaced the prior append-grew heuristic with preserved-row checks so non-rewritten history is not marked volatile.
- Added a regression test covering in-place streaming line growth and scrollback commit safety in transcript containers.
This commit is contained in:
can1357
2026-06-08 16:12:26 +02:00
parent aee59fc2c7
commit 09a81f4c59
2 changed files with 69 additions and 10 deletions
@@ -71,9 +71,7 @@ function commonPrefixLength(prev: string[], cur: string[]): number {
}
function commonSuffixLength(prev: string[], cur: string[], prefixLength: number): number {
const prevLimit = prev.length - prefixLength;
const curLimit = cur.length - prefixLength;
const limit = Math.min(prevLimit, curLimit);
const limit = Math.min(prev.length - prefixLength, cur.length - prefixLength);
let i = 0;
while (i < limit && prev[prev.length - 1 - i] === cur[cur.length - 1 - i]) i++;
return i;
@@ -95,14 +93,23 @@ function deriveLiveCommitState(
const staticRender = prefixLength === previous.lines.length && prefixLength === current.length;
if (!staticRender) {
const suffixLength = commonSuffixLength(previous.lines, current, prefixLength);
const stablePreviousLength = prefixLength + suffixLength;
const appendGrew =
previous.lines.length > 0 &&
current.length > previous.lines.length &&
stablePreviousLength >= previous.lines.length;
if (appendGrew && !volatile) {
// Append-only growth never rewrites a row that may already have scrolled
// into native scrollback; it only grows the block at/near its tail. Three
// shapes qualify: a pure bottom append, an insertion above stable trailing
// chrome (a streaming tool's footer/border), and an in-place extension of
// the current line by one streamed token (line count unchanged). The first
// two preserve every previous row across a matching prefix + suffix; the
// last leaves a single divergent previous row that the current row merely
// lengthens. A divergent interior row that is genuinely rewritten means the
// block re-laid-out committed content — volatile, and never committed.
const preservedEveryRow = prefixLength + suffixLength >= previous.lines.length;
const tailExtendedInPlace =
prefixLength + suffixLength === previous.lines.length - 1 &&
prefixLength < current.length &&
current[prefixLength]!.startsWith(previous.lines[prefixLength]!);
if ((preservedEveryRow || tailExtendedInPlace) && current.length >= previous.lines.length && !volatile) {
appendOnly = true;
} else if (stablePreviousLength < previous.lines.length) {
} else if (!preservedEveryRow && !tailExtendedInPlace) {
volatile = true;
appendOnly = false;
}
@@ -0,0 +1,52 @@
import { describe, expect, it } from "bun:test";
import { type Component, TERMINAL } from "@oh-my-pi/pi-tui";
import { TranscriptContainer } from "../src/modes/components/transcript-container";
type MutableTerminalInfo = { eagerEraseScrollbackRisk: boolean };
const mutableTerminalInfo = TERMINAL as unknown as MutableTerminalInfo;
async function withTerminalRisk<T>(risk: boolean, run: () => T | Promise<T>): Promise<T> {
const saved = TERMINAL.eagerEraseScrollbackRisk;
mutableTerminalInfo.eagerEraseScrollbackRisk = risk;
try {
return await run();
} finally {
mutableTerminalInfo.eagerEraseScrollbackRisk = saved;
}
}
class MutableLiveBlock implements Component {
#lines: string[];
constructor(lines: string[]) {
this.#lines = [...lines];
}
render(width: number): string[] {
return this.#lines.map(line => line.slice(0, width));
}
setLines(lines: string[]): void {
this.#lines = [...lines];
}
isTranscriptBlockFinalized(): boolean {
return false;
}
}
describe("transcript streaming commit (assistant text)", () => {
it("treats in-place growth of the trailing line as append-only", async () => {
await withTerminalRisk(true, () => {
const chat = new TranscriptContainer();
// Models a streaming assistant reply: stable head rows plus a current
// line that grows token-by-token without adding a new row.
const block = new MutableLiveBlock(["para one", "para two", "the quick brown"]);
chat.addChild(block);
chat.render(80);
block.setLines(["para one", "para two", "the quick brown fox"]);
chat.render(80);
// The head rows never changed; only the trailing line grew. Its scrolled-
// off head must be committable to native scrollback (tmux pane history).
expect(chat.getNativeScrollbackCommitSafeEnd()).toBe(3);
});
});
});