From 647eb1e47e390f54ab17694cb34477bb31eacb72 Mon Sep 17 00:00:00 2001 From: can1357 Date: Tue, 9 Jun 2026 23:16:22 +0200 Subject: [PATCH] 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. --- docs/tui-core-renderer.md | 22 +++- packages/coding-agent/CHANGELOG.md | 6 +- .../modes/components/transcript-container.ts | 68 ++++++++++- .../test/tool-live-region-scrollback.test.ts | 115 ++++++++++++++++++ 4 files changed, 202 insertions(+), 9 deletions(-) diff --git a/docs/tui-core-renderer.md b/docs/tui-core-renderer.md index aef72e4a4..27e292f6a 100644 --- a/docs/tui-core-renderer.md +++ b/docs/tui-core-renderer.md @@ -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. --- diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 4e73ef357..6b3946d7e 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -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 `` 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 `` 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 diff --git a/packages/coding-agent/src/modes/components/transcript-container.ts b/packages/coding-agent/src/modes/components/transcript-container.ts index c77bdb70f..4410b28d2 100644 --- a/packages/coding-agent/src/modes/components/transcript-container.ts +++ b/packages/coding-agent/src/modes/components/transcript-container.ts @@ -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 diff --git a/packages/coding-agent/test/tool-live-region-scrollback.test.ts b/packages/coding-agent/test/tool-live-region-scrollback.test.ts index 838e5b3a7..4eafb3c1d 100644 --- a/packages/coding-agent/test/tool-live-region-scrollback.test.ts +++ b/packages/coding-agent/test/tool-live-region-scrollback.test.ts @@ -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;