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:
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user