fix(coding-agent): fixed streaming tool output commit boundaries to preserve settled context

- Introduced a stable-prefix ratchet in `deriveLiveCommitState` for 30-frame row stability.
- Computed `safeLength` from the stable prefix when `appendOnly` is false so static heads reach scrollback.
- Persisted stable-prefix/candidate state in `LiveDiffSnapshot` and `LiveCommitState` for boundary retreat on rewrites.
This commit is contained in:
can1357
2026-06-09 23:16:22 +02:00
parent 3e990c5bea
commit 647eb1e47e
4 changed files with 202 additions and 9 deletions
+18 -4
View File
@@ -131,10 +131,24 @@ of history:
freeze (their render is snapshotted, so their content can never drift after
the engine may have committed it), still-mutating blocks
(`isTranscriptBlockFinalized?.() === false`) anchor the live region, and
`deriveLiveCommitState` detects the append-only stable prefix of a streaming
block (a rewrite of an interior row suspends commits for
`VOLATILE_REARM_FRAMES` clean frames). Freezing is unconditional — it is the
engine's required guarantee, not a per-terminal optimization.
`deriveLiveCommitState` derives the commit-safe end of the first live block
from two independent signals:
- **append-only detection** — a block observed growing without visibly
rewriting an interior row commits its full body; a rewrite suspends this
for `VOLATILE_REARM_FRAMES` clean frames.
- **stable-prefix ratchet** — rows that stayed visibly identical for a full
`STABLE_PREFIX_COMMIT_FRAMES` window commit even while the block's tail
keeps rewriting (a task tool's static prompt above a ticking progress
tree). Without it, one perpetually animating row holds the whole block out
of history, so a block taller than the window reads as cut off (head
neither committed nor on screen) for the entire run. The ratchet tracks the
window-minimum common prefix; a rewrite above the promoted run retreats it
to the divergence, and rows that already committed are the engine audit's
problem (recommit → duplication, never loss).
Freezing is unconditional — it is the engine's required guarantee, not a
per-terminal optimization.
---
+2 -4
View File
@@ -17,16 +17,14 @@
- Transcript block freezing is now unconditional instead of gated on ED3-risk terminal detection: every finalized block replays its frozen snapshot once it crosses out of the live region, on all terminals including Windows, because the rewritten renderer's committed scrollback is immutable everywhere. Still-mutating blocks (pending tools, streaming messages, async thinking renderers) anchor the live region and keep repainting until they finalize, which structurally fixes stale/duplicated output from late async expansions ([#1823](https://github.com/can1357/oh-my-pi/issues/1823)).
- Fixed the edit tool's post-edit diff preview occasionally echoing a context line twice with out-of-order numbering. Block-boundary context injection classified space-prefixed diff rows as old-file-only, so an unchanged line sitting in a net-offset region (old N / new N+k) was missing from the new file's visibility window; `findBlockContextLines` then re-surfaced it under its post-edit number and the row was spliced in after the adjacent change run. New-file boundary lines are now translated back to pre-edit numbers (the compact-preview renumbering contract) and merged into a single old-numbered insertion pass — also fixing closers below a net-offset edit being dropped or renumbered incorrectly.
- Fixed the Anthropic web-search provider claiming the Claude Code identity on API-key requests: the CC billing header + system instruction were injected whenever the model wasn't Haiku 3.5, regardless of auth mode. Injection is now OAuth-gated like the streaming path, and OAuth search requests patch the billing header's `cch` attestation (via `wrapFetchForCch`) instead of shipping the `cch=00000` placeholder.
- Fixed long streamed content appearing cut off mid-run: scrolled-off rows were erased from the viewport without ever being appended to terminal history. The transcript's commit boundary (`deriveLiveCommitState`) was all-or-nothing per block — one perpetually rewriting row (a task tool's ticking progress tree, per-agent cost/tool counters, spinner stats) suspended scrollback commits for the entire block, so once the block outgrew the viewport its static head (e.g. a task's prompt/context markdown) was neither committed nor on screen until the tool sealed, and was lost outright if the session ended mid-run. A stable-prefix ratchet now promotes leading rows that stayed visibly identical for a full 30-frame window as commit-safe, so the settled head reaches native scrollback while only the genuinely volatile tail stays deferred; a rewrite above the promoted run retreats the boundary and the engine audit recommits (duplication, never loss).
- Fixed local tiny-title worker stdout/stderr leaking raw native model output such as `</title>` and cache/status lines into the interactive TUI scrollback ([#2206](https://github.com/can1357/oh-my-pi/issues/2206)).
### Removed
- Removed the `clearOnShrink` setting and its `PI_CLEAR_ON_SHRINK` environment variable: the rewritten renderer always clears shrunken rows exactly, so the flicker/perf tradeoff the setting controlled no longer exists. Existing config entries are ignored.
- Removed the prompt-submit native-scrollback reconciliation checkpoint and the eager streaming render mode from the interactive controllers — the renderer's append-only contract made both obsolete.
### Fixed
- Fixed local tiny-title worker stdout/stderr leaking raw native model output such as `</title>` and cache/status lines into the interactive TUI scrollback ([#2206](https://github.com/can1357/oh-my-pi/issues/2206)).
## [15.10.9] - 2026-06-09
### Fixed
@@ -18,6 +18,15 @@ interface LiveDiffSnapshot {
* append-only status. `0` means the block is not under rewrite suspicion.
*/
volatileCooldown: number;
/**
* Stable-prefix ratchet (see {@link deriveLiveCommitState}): leading rows
* promoted as commit-safe because they stayed visibly identical for
* {@link STABLE_PREFIX_COMMIT_FRAMES} consecutive frames, plus the in-flight
* candidate run and its age.
*/
stablePrefixLength: number;
candidatePrefixLength: number;
candidatePrefixAge: number;
}
interface SnapshotCarrier {
@@ -62,6 +71,9 @@ function stripPlainBlankEdges(lines: string[]): string[] {
interface LiveCommitState {
appendOnly: boolean;
volatileCooldown: number;
stablePrefixLength: number;
candidatePrefixLength: number;
candidatePrefixAge: number;
safeLength: number;
}
@@ -78,6 +90,22 @@ interface LiveCommitState {
*/
const VOLATILE_REARM_FRAMES = 30;
/**
* Consecutive frames a leading row run must stay visibly identical before it
* is promoted as commit-safe even though the block's tail keeps rewriting.
* Append-only detection alone is all-or-nothing per block: one perpetually
* ticking row (a task tool's progress tree, per-agent cost/tool counters, a
* log line spinner) suspends commits for the WHOLE block forever, so once the
* block outgrows the viewport its static head — e.g. a task's prompt/context
* markdown — is neither committed to native scrollback nor on screen: the
* transcript reads as cut off for the entire (possibly minutes-long) run.
* The ratchet commits the settled head while only the genuinely volatile tail
* stays deferred. If a promoted row is later rewritten (a collapsing
* preview), the engine's committed-prefix audit re-anchors and recommits —
* duplication, never loss — and the ratchet retreats to the divergence.
*/
const STABLE_PREFIX_COMMIT_FRAMES = 30;
/**
* Visible-content form of a row: SGR/OSC bytes and trailing pad spaces are
* write framing, not content. A styled line's closing escape moves when the
@@ -126,9 +154,15 @@ function deriveLiveCommitState(
): LiveCommitState {
let appendOnly = false;
let volatileCooldown = 0;
let stablePrefixLength = 0;
let candidatePrefixLength = 0;
let candidatePrefixAge = 0;
if (hasValidSnapshot(previous, width, generation)) {
appendOnly = previous.appendOnly;
volatileCooldown = previous.volatileCooldown;
stablePrefixLength = previous.stablePrefixLength;
candidatePrefixLength = previous.candidatePrefixLength;
candidatePrefixAge = previous.candidatePrefixAge;
const prefixLength = commonPrefixLength(previous.lines, current);
const staticRender = prefixLength === previous.lines.length && prefixLength === current.length;
@@ -169,12 +203,41 @@ function deriveLiveCommitState(
}
}
if (cleanFrame && volatileCooldown > 0) volatileCooldown--;
// Stable-prefix ratchet, independent of append-only. `prefixLength` is
// this frame's visibly-unchanged leading run; the candidate accumulates
// the MINIMUM prefix across a STABLE_PREFIX_COMMIT_FRAMES window, so
// promotion means every promoted row stayed identical for the whole
// window (row r is inside frame i's common prefix iff r < p_i, so
// r < min(p) holds for every frame of the window). A row settling
// mid-window promotes at most two windows later. A change above the
// already-promoted run retreats it to the divergence — the engine
// audit owns any rows that already committed (recommit, never loss).
if (prefixLength < stablePrefixLength) {
stablePrefixLength = prefixLength;
candidatePrefixLength = prefixLength;
candidatePrefixAge = 0;
} else {
candidatePrefixLength =
candidatePrefixAge === 0 ? prefixLength : Math.min(candidatePrefixLength, prefixLength);
candidatePrefixAge++;
if (candidatePrefixAge >= STABLE_PREFIX_COMMIT_FRAMES) {
stablePrefixLength = candidatePrefixLength;
candidatePrefixLength = prefixLength;
candidatePrefixAge = 0;
}
}
}
return {
appendOnly,
volatileCooldown,
safeLength: appendOnly ? current.length : 0,
stablePrefixLength,
candidatePrefixLength,
candidatePrefixAge,
// An append-only block's whole body is committable; otherwise the
// settled head still is — only the volatile tail stays deferred.
safeLength: appendOnly ? current.length : stablePrefixLength,
};
}
@@ -281,6 +344,9 @@ export class TranscriptContainer extends Container implements NativeScrollbackLi
generation: this.#generation,
appendOnly: liveCommitState?.appendOnly ?? false,
volatileCooldown: liveCommitState?.volatileCooldown ?? 0,
stablePrefixLength: liveCommitState?.stablePrefixLength ?? 0,
candidatePrefixLength: liveCommitState?.candidatePrefixLength ?? 0,
candidatePrefixAge: liveCommitState?.candidatePrefixAge ?? 0,
};
// Empty (or stripped-to-nothing) children contribute nothing and never
@@ -152,11 +152,52 @@ describe("transcript reactive commit boundary", () => {
chat.render(80);
expect(chat.getNativeScrollbackCommitSafeEnd()).toBeUndefined();
});
it("commits the settled head of a block whose tail keeps rewriting (task progress shape)", () => {
const chat = new TranscriptContainer();
const head = markerLines("head-", 8);
const block = new MutableLiveBlock([...head, "⠋ agents running · 0 tools"]);
chat.addChild(block);
chat.render(80);
// The progress tail rewrites every frame, so append-only is never
// earned — but the head rows stay visibly identical the whole time.
for (let i = 1; i <= 62; i++) {
block.setLines([...head, `⠋ agents running · ${i} tools`]);
chat.render(80);
}
// The settled head must become commit-safe; otherwise a tall block's
// scrolled-off head is neither committed nor on screen for the whole
// run — the transcript reads as cut off until the tool seals.
expect(chat.getNativeScrollbackCommitSafeEnd()).toBe(8);
});
it("retreats the settled-head boundary when a promoted row is rewritten", () => {
const chat = new TranscriptContainer();
const head = markerLines("head-", 8);
const block = new MutableLiveBlock([...head, "tail-0"]);
chat.addChild(block);
chat.render(80);
for (let i = 1; i <= 62; i++) {
block.setLines([...head, `tail-${i}`]);
chat.render(80);
}
expect(chat.getNativeScrollbackCommitSafeEnd()).toBe(8);
// A collapse/re-layout rewrites a promoted row: the boundary retreats
// to the divergence (the engine audit owns rows already committed).
block.setLines([...head.slice(0, 3), "rewritten", ...head.slice(4), "tail-x"]);
chat.render(80);
expect(chat.getNativeScrollbackCommitSafeEnd()).toBe(3);
});
});
describe("tool live-region scrollback", () => {
beforeAll(async () => {
await initTheme();
// The task progress renderer reads settings (resolved-model badge).
await Settings.init({ inMemory: true, cwd: process.cwd() });
});
it("does not splice stale pending eval preview above the running eval viewport", async () => {
@@ -351,6 +392,80 @@ describe("tool live-region scrollback", () => {
}
});
it("keeps the static task context reachable in scrollback while progress ticks below it", async () => {
if (process.platform === "win32") return;
const term = new VirtualTerminal(120, 12);
const tui = new TUI(term);
const chat = new TranscriptContainer();
const context = Array.from({ length: 40 }, (_unused, i) => `- CTX-${i}`).join("\n");
const args = {
agent: "explore",
context,
tasks: [{ id: "alpha", description: "probe", assignment: "Inspect the repo." }],
};
const component = new ToolExecutionComponent("task", args, {}, undefined, tui, process.cwd());
const progressAt = (toolCount: number) => ({
index: 0,
id: "alpha",
agent: "explore",
agentSource: "bundled" as const,
status: "running" as const,
task: "probe",
description: "probe",
recentTools: [],
recentOutput: [],
toolCount,
tokens: 0,
cost: 0,
durationMs: toolCount * 250,
});
const partial = (toolCount: number) =>
component.updateResult(
{
content: [{ type: "text", text: "" }],
details: {
projectAgentsDir: null,
results: [],
totalDurationMs: 0,
progress: [progressAt(toolCount)],
},
},
true,
);
try {
chat.addChild(component);
tui.addChild(chat);
tui.start();
await term.waitForRender();
// A running task rewrites its progress line (tool counts, spinner)
// below the static context for the whole run. The context head that
// scrolled above the viewport must still reach native scrollback —
// previously the ticking tail suspended commits for the entire
// block, leaving the context neither in history nor on screen.
// Two full promotion windows: the call→result transition frame
// poisons the first window's minimum, the second promotes the head.
for (let i = 1; i <= 70; i++) {
partial(i);
tui.requestRender();
await term.waitForRender();
}
const scrollText = stripRows(term.getScrollBuffer());
const viewportText = stripRows(term.getViewport());
expect(viewportText).not.toContain("CTX-0");
expect(scrollText).toContain("CTX-0");
expect(scrollText).toContain("CTX-20");
} finally {
component.stopAnimation();
tui.stop();
await term.flush();
}
}, 20000);
it("commits the scrolled-off head of a tall finalized bottom tool result", async () => {
if (process.platform === "win32") return;