diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 5272651cd..2e8ffcd37 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -22,6 +22,7 @@ ### Fixed +- Fixed task tool blocks duplicating their per-agent progress rows into terminal scrollback on every update: live task frames now pin the transcript live region so mid-run rows are never recorded as frozen snapshots, and a detached background task freezes its progress the moment any of its rows commit to scrollback instead of mutating committed history. - Fixed Codex reset fireworks comparing different quota tiers or plans, preventing false celebrations when usage reports switch between Spark and base weekly limits. - Fixed Cursor ranged-read results losing the full file byte size after applying the requested window. - Fixed empty Codex final-stop recovery discarding an earlier commentary message when both messages shared response metadata. diff --git a/packages/coding-agent/src/modes/components/tool-execution.ts b/packages/coding-agent/src/modes/components/tool-execution.ts index 3a9534f01..061f890ed 100644 --- a/packages/coding-agent/src/modes/components/tool-execution.ts +++ b/packages/coding-agent/src/modes/components/tool-execution.ts @@ -203,6 +203,13 @@ class SafeToolRendererComponent implements Component { */ export interface TranscriptLiveRegionProbe { isBlockInLiveRegion(component: Component): boolean; + /** + * Whether none of the block's rows have entered native scrollback (see + * `TranscriptContainer.isBlockUncommitted`). Optional: standalone hosts + * without commit tracking omit it, and blocks treat their rows as + * uncommitted. + */ + isBlockUncommitted?(component: Component): boolean; } /** Minimal TUI surface ToolExecutionComponent uses to schedule repaints and share image budget. */ @@ -337,10 +344,21 @@ export class ToolExecutionComponent extends Container implements NativeScrollbac // transcript, e.g. in tests): whether this block is still repaintable. #liveRegion?: TranscriptLiveRegionProbe; // One-way latch for a detached (`async.state === "running"`) task block - // that left the transcript live region: its rows are commit-eligible - // history, so progress renders static gray and further partial snapshots are - // dropped (see #maybeFreezeBackgroundTask). + // whose rows became native-scrollback history — it left the transcript + // live region, or its head rows were committed while it was still the + // live tail. Further partial snapshots are dropped so committed rows are + // never mutated (see #maybeFreezeBackgroundTask). #backgroundTaskFrozen = false; + // Whether the freeze may restyle the progress rows static gray. Set only + // when the latch fired while no row was committed: a recolor of rows + // already on the tape would itself diverge immutable history and force an + // erase-replay (or, with scrollback rebuild off, a duplicate slab). + #backgroundTaskFrozenStyled = false; + // Wall clock captured at each repaintable rebuild of a task card and + // reused verbatim once the card freezes or any of its rows commit, so + // time-derived rows (current-tool elapsed, retry countdown) cannot drift + // a committed byte on later rebuilds (theme epoch, image toggles). + #taskRenderNowMs = Date.now(); // Set on each `render()` when the last painted pending shape must be // replayed wholesale when the first result arrives. Reset gates key off // these so a topology-changing update that lands before the shape reaches @@ -559,13 +577,17 @@ export class ToolExecutionComponent extends Container implements NativeScrollbac _toolCallId?: string, ): void { // A detached task spawn keeps streaming progress snapshots after the - // block froze (left the transcript live region). Drop them: the rows are - // static gray history now, and repainting would rewrite rows the engine - // may already have committed to native scrollback. The terminal snapshot - // (async completed/failed → isPartial=false) still applies so a block - // that is still on screen settles on real results. - if (isPartial && this.#toolName === "task" && this.#maybeFreezeBackgroundTask()) { - return; + // block froze (left the transcript live region, or its rows entered + // native scrollback). Drop them: repainting would rewrite rows the + // engine may already have committed. The terminal snapshot (async + // completed/failed → isPartial=false) still settles a card that is + // wholly uncommitted (still on screen); once any row is on the tape + // the card is immutable history — replacing it would re-commit the + // whole slab below the stale copy — so the settlement is dropped and + // the job's result surfaces through its own delivery message. + if (this.#toolName === "task" && this.#maybeFreezeBackgroundTask()) { + if (isPartial) return; + if (!(this.#liveRegion?.isBlockUncommitted?.(this) ?? true)) return; } const hadNoResult = this.#result === undefined; const wasPartialResult = this.#result !== undefined && this.#isPartial; @@ -713,22 +735,30 @@ export class ToolExecutionComponent extends Container implements NativeScrollbac } /** - * Freeze a detached (`async.state === "running"`) task block once it leaves - * the transcript's live region. Past that seam its rows are commit-eligible - * native-scrollback history: repaint the progress rows static gray and drop - * further partial snapshots. One-way — blocks never re-enter the live - * region. Returns whether the block is frozen. + * Freeze a detached (`async.state === "running"`) task block once its rows + * become native-scrollback history: the block left the transcript's live + * region (a later block streams below it), or — while it is still the + * live tail — its head rows were committed because the frame outgrew the + * viewport. Committed rows are immutable, so from that point every further + * partial snapshot is dropped. Rows restyle static gray only when nothing + * is committed yet; otherwise the bytes stay exactly as painted. One-way — + * blocks never re-enter the live region. Returns whether the block is + * frozen. */ #maybeFreezeBackgroundTask(): boolean { if (this.#backgroundTaskFrozen) return true; if (this.#toolName !== "task" || this.#liveRegion === undefined) return false; const asyncState = (this.#result?.details as { async?: { state?: string } } | undefined)?.async?.state; if (asyncState !== "running") return false; - if (this.#liveRegion.isBlockInLiveRegion(this)) return false; + const uncommitted = this.#liveRegion.isBlockUncommitted?.(this) ?? true; + if (uncommitted && this.#liveRegion.isBlockInLiveRegion(this)) return false; this.#backgroundTaskFrozen = true; this.#updateSpinnerAnimation(); - this.#updateDisplay(); - this.#ui.requestRender(); + if (uncommitted) { + this.#backgroundTaskFrozenStyled = true; + this.#updateDisplay(); + this.#ui.requestRender(); + } return true; } @@ -784,16 +814,16 @@ export class ToolExecutionComponent extends Container implements NativeScrollbac /** * Keeps in-flight TV-wall frames out of immutable native scrollback: the - * `vibe_wait` wall and displaceable snapshots (`hub` waiting polls, `todo` - * lists). Their frames replace each other rather than append, and their - * rows mutate every spinner tick — an unpinned commit records a per-tick - * frozen snapshot AND force-seals the block (see TranscriptContainer's - * committed-snapshot seal), so the next poll stacks a new frame instead of - * displacing this one. + * `vibe_wait` wall, displaceable snapshots (`hub` waiting polls, `todo` + * lists), and live `task` calls. Their frames replace each other rather + * than append — task progress rows rewrite in place on every snapshot — + * so an unpinned commit records a per-tick frozen snapshot (and for + * displaceable blocks force-seals them, stacking the next poll below). + * The finalized frame commits exactly once when the pin lifts. */ isNativeScrollbackLiveRegionPinned(): boolean { if (this.isTranscriptBlockFinalized()) return false; - return this.#toolName === "vibe_wait" || this.#displaceableByToolName !== undefined; + return this.#toolName === "vibe_wait" || this.#toolName === "task" || this.#displaceableByToolName !== undefined; } /** @@ -827,8 +857,12 @@ export class ToolExecutionComponent extends Container implements NativeScrollbac this.#sealed = true; this.#displaceableByToolName = undefined; // A sealed detached task is abandoned history: settle its progress rows - // on static gray. + // on static gray — but only while none of them are committed; a recolor + // on the tape would diverge immutable history. this.#backgroundTaskFrozen = true; + if (this.#liveRegion?.isBlockUncommitted?.(this) ?? true) { + this.#backgroundTaskFrozenStyled = true; + } this.stopAnimation(); this.#updateDisplay(); this.#ui.requestRender(); @@ -888,7 +922,7 @@ export class ToolExecutionComponent extends Container implements NativeScrollbac // TUI startup, so a result rendered before it lands must re-shape once it // does (it gates Image children vs text fallback in #rebuildDisplay); keyed // here for the same reason markdown.ts keys its render cache on it. - const key = `${this.#resultVersion}|${this.#expanded}|${this.#isPartial}|${this.#spinnerFrame ?? "-"}|${this.#showImages}|${getThemeEpoch()}|${this.#displayInputVersion}|${this.#backgroundTaskFrozen}|${TERMINAL.imageProtocol ?? "-"}|${this.#imageSizeKey()}`; + const key = `${this.#resultVersion}|${this.#expanded}|${this.#isPartial}|${this.#spinnerFrame ?? "-"}|${this.#showImages}|${getThemeEpoch()}|${this.#displayInputVersion}|${this.#backgroundTaskFrozenStyled}|${TERMINAL.imageProtocol ?? "-"}|${this.#imageSizeKey()}`; if (key === this.#lastDisplayKey && this.#displayBuilt) return; this.#lastDisplayKey = key; @@ -1292,9 +1326,17 @@ export class ToolExecutionComponent extends Container implements NativeScrollbac // draws every dispatched agent as a progress/result line, so tell // `renderCall` to drop its duplicate streaming preview list. context.hasResult = Boolean(this.#result); - // Out of the transcript live region: progress rows render static gray - // (see task/render.ts). - context.frozen = this.#backgroundTaskFrozen; + // Settled as history (out of the live region, before any row entered + // the tape): progress rows render static gray (see task/render.ts). + context.frozen = this.#backgroundTaskFrozenStyled; + // Freeze the render clock alongside the latch — and independently the + // moment any row commits, closing the window between a commit paint + // and the next snapshot where a settings-triggered rebuild could + // re-derive elapsed/countdown bytes under committed rows. + if (!this.#backgroundTaskFrozen && (this.#liveRegion?.isBlockUncommitted?.(this) ?? true)) { + this.#taskRenderNowMs = Date.now(); + } + context.nowMs = this.#taskRenderNowMs; } else if (isEditLikeToolName(this.#toolName)) { context.editMode = this.#editMode; const previews = this.#editDiffPreview; diff --git a/packages/coding-agent/src/task/render.ts b/packages/coding-agent/src/task/render.ts index 2045a2174..585e4e740 100644 --- a/packages/coding-agent/src/task/render.ts +++ b/packages/coding-agent/src/task/render.ts @@ -49,6 +49,13 @@ interface TaskRenderContext { * commit-eligible rows do not repaint after entering native scrollback. */ frozen?: boolean; + /** + * Wall clock for time-derived rows (current-tool elapsed, retry countdown). + * The component freezes it once the block settles or any of its rows enter + * native scrollback, so identical-input rebuilds stay byte-identical with + * committed history. Absent: render with the live clock. + */ + nowMs?: number; } type TaskRenderOptions = RenderResultOptions & { renderContext?: TaskRenderContext }; @@ -888,6 +895,7 @@ function renderAgentProgress( frozen = false, seenNestedTasks?: WeakSet, nestedDepth = 0, + nowMs = Date.now(), ): string[] { const lines: string[] = []; @@ -964,7 +972,7 @@ function renderAgentProgress( toolLine += `: ${theme.fg("dim", previewLine(sanitizeText(toolDetail), 40))}`; } if (progress.currentToolStartMs) { - const elapsed = Date.now() - progress.currentToolStartMs; + const elapsed = nowMs - progress.currentToolStartMs; if (elapsed > 5000) { toolLine += `${theme.sep.dot}${theme.fg("warning", formatDuration(elapsed))}`; } @@ -986,7 +994,7 @@ function renderAgentProgress( // long until the next attempt. Without this, the parent UI would just // keep spinning while a child sleeps on a 3-hour provider rate-limit. if (progress.retryState && progress.status === "running") { - const remainingMs = Math.max(0, progress.retryState.startedAtMs + progress.retryState.delayMs - Date.now()); + const remainingMs = Math.max(0, progress.retryState.startedAtMs + progress.retryState.delayMs - nowMs); const waitLabel = remainingMs > 0 ? `in ${formatDuration(remainingMs)}` : "now"; const summary = `retrying ${progress.retryState.attempt}/${progress.retryState.maxAttempts} ${waitLabel}: ` + @@ -1079,6 +1087,7 @@ function renderAgentProgress( frozen, seenNestedTasks, nestedDepth, + nowMs, ); for (const line of nestedLines) { lines.push(`${continuePrefix}${line}`); @@ -1560,6 +1569,7 @@ export function renderResult( return framedBlock(theme, width => { const { expanded, isPartial, spinnerFrame } = options; const frozen = options.renderContext?.frozen === true; + const nowMs = options.renderContext?.nowMs ?? Date.now(); const lines: string[] = []; // Result rows win once any exist; progress rows for spawns without a @@ -1577,7 +1587,9 @@ export function renderResult( lines.push(formatHiddenProgressLine(ordered.slice(0, ordered.length - visible.length), theme)); } for (const progress of visible) { - lines.push(...renderAgentProgress(progress, "", " ", expanded, theme, spinnerFrame, frozen)); + lines.push( + ...renderAgentProgress(progress, "", " ", expanded, theme, spinnerFrame, frozen, undefined, 0, nowMs), + ); } } else if (details.results && details.results.length > 0) { const ordered = orderResultsForDisplay(details.results); @@ -1602,7 +1614,9 @@ export function renderResult( ) : []; for (const progress of supplementalProgress) { - lines.push(...renderAgentProgress(progress, "", " ", expanded, theme, spinnerFrame, frozen)); + lines.push( + ...renderAgentProgress(progress, "", " ", expanded, theme, spinnerFrame, frozen, undefined, 0, nowMs), + ); } const summaryParts: string[] = []; @@ -1742,6 +1756,7 @@ function renderNestedTaskTree( frozen = false, seen: WeakSet = new WeakSet(), depth = 0, + nowMs = Date.now(), ): string[] { const lines: string[] = []; for (const details of detailsList) { @@ -1788,6 +1803,7 @@ function renderNestedTaskTree( frozen, seen, depth + 1, + nowMs, ), ); }); diff --git a/packages/coding-agent/test/streaming-output-scrollback.test.ts b/packages/coding-agent/test/streaming-output-scrollback.test.ts index 8d60dd3a2..94e580f5d 100644 --- a/packages/coding-agent/test/streaming-output-scrollback.test.ts +++ b/packages/coding-agent/test/streaming-output-scrollback.test.ts @@ -1,9 +1,11 @@ -import { afterEach, beforeAll, describe, expect, test, vi } from "bun:test"; +import { afterAll, afterEach, beforeAll, describe, expect, test, vi } from "bun:test"; import type { AssistantMessage } from "@oh-my-pi/pi-ai"; +import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { AssistantMessageComponent } from "@oh-my-pi/pi-coding-agent/modes/components/assistant-message"; import { ToolExecutionComponent } from "@oh-my-pi/pi-coding-agent/modes/components/tool-execution"; import { TranscriptContainer } from "@oh-my-pi/pi-coding-agent/modes/components/transcript-container"; import { theme as activeTheme, initTheme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme"; +import type { AgentProgress, TaskToolDetails } from "@oh-my-pi/pi-coding-agent/task/types"; import { evalToolRenderer } from "@oh-my-pi/pi-coding-agent/tools/eval-render"; import { previewWindowRows } from "@oh-my-pi/pi-coding-agent/tools/render-utils"; import { type Component, TUI } from "@oh-my-pi/pi-tui"; @@ -159,10 +161,62 @@ function makeEvalProbeResult(output: string, status: "running" | "complete") { isError: false, }; } + +/** + * Six running designer agents at `tick`, shaped as a detached (async-running) + * task snapshot. Rows carry the time-derived fields the real executor sets — + * a current-tool start (elapsed suffix) and one retry countdown — so the + * regression also covers clock-derived byte drift. + */ +function detachedTaskDetails(tick: number): TaskToolDetails { + const now = Date.now(); + const progress: AgentProgress[] = Array.from({ length: 6 }, (_, index) => ({ + index, + id: `Task${index + 1}`, + agent: "designer", + agentSource: "bundled", + status: "running", + task: `Design concept ${index + 1}`, + assignment: `Design concept ${index + 1}`, + description: `Concept ${index + 1}, update ${tick}`, + currentTool: tick % 2 === 0 ? "read" : "hub", + currentToolStartMs: now - 20_000, + lastIntent: `progress ${tick}`, + recentTools: [], + recentOutput: [], + toolCount: tick, + requests: tick, + tokens: tick * 100, + cost: tick / 100, + durationMs: tick * 100, + retryState: + index === 5 + ? { + attempt: 1, + maxAttempts: 5, + delayMs: 120_000, + errorMessage: "429 rate limited", + startedAtMs: now, + } + : undefined, + })); + return { + projectAgentsDir: null, + results: [], + totalDurationMs: tick * 100, + progress, + async: { state: "running", jobId: "Task1", type: "task" }, + }; +} describe("streaming tool output never sprays duplicate scrollback banners", () => { beforeAll(async () => { + resetSettingsForTest(); + await Settings.init({ inMemory: true, cwd: process.cwd() }); await initTheme(); }); + afterAll(() => { + resetSettingsForTest(); + }); afterEach(() => { if (ORIGINAL_ROWS) Object.defineProperty(process.stdout, "rows", ORIGINAL_ROWS); else Reflect.deleteProperty(process.stdout, "rows"); @@ -206,6 +260,224 @@ describe("streaming tool output never sprays duplicate scrollback banners", () = } }, 30_000); + // The detached-task smear (user report): a background (`async.state === + // "running"`) task block reports finalized so later turns can commit, but + // as the transcript tail its head rows enter native scrollback the moment + // the frame outgrows the viewport. Progress snapshots kept mutating those + // committed rows; with scrollback rebuild off (default) every audit repair + // appended a fresh copy of the agent one-liners below the stale one. + test("task: detached progress never duplicates agent rows in scrollback", async () => { + const rows = 24; + stubStdoutRows(rows); + const term = new VirtualTerminal(100, rows); + const scheduler = makeDrainableScheduler(); + const tui = new TUI(term, undefined, { renderScheduler: scheduler }); + // Default engine mode: divergence repairs append below the stale copy, + // which is exactly the duplication this test guards against. + tui.setScrollbackRebuild(false); + const transcript = new TranscriptContainer(); + const taskArgs = { + context: Array.from({ length: 40 }, (_, index) => `context-line-${index}`).join("\n"), + tasks: Array.from({ length: 6 }, (_, index) => ({ + name: `Task${index + 1}`, + agent: "designer", + task: `Design concept ${index + 1}`, + })), + }; + const task = new ToolExecutionComponent( + "task", + taskArgs, + { liveRegion: transcript }, + undefined, + tui, + process.cwd(), + ); + transcript.addChild(task); + tui.addChild(transcript); + tui.addChild(new Footer(6)); + + try { + tui.start(); + scheduler.flush(); + await term.flush(); + for (let tick = 1; tick <= 12; tick++) { + task.updateResult( + { content: [{ type: "text", text: "running" }], details: detachedTaskDetails(tick) }, + true, + ); + term.scrollLines(1000); + tui.requestRender(); + scheduler.flush(); + await term.flush(); + } + + // The turn's reply streams in below the card (the screenshot shape): + // the whole card — progress rows included — is pushed above the + // window top and committed to the tape as frozen history. + transcript.addChild(new StaticBlock(Array.from({ length: 24 }, (_, i) => `reply-line-${i}`))); + term.scrollLines(1000); + tui.requestRender(); + scheduler.flush(); + await term.flush(); + + // A rebuild without a new snapshot (settings toggle, theme epoch) + // re-derives the frame; time-derived rows (current-tool elapsed, + // retry countdown) must not drift under committed history, so the + // render clock freezes with the latch: the rebuild output must be + // byte-identical even with the wall clock 90s ahead. + const beforeRebuild = task.render(100).join("\n"); + vi.spyOn(Date, "now").mockReturnValue(Date.now() + 90_000); + task.setShowImages(false); + expect(task.render(100).join("\n")).toBe(beforeRebuild); + term.scrollLines(1000); + tui.requestRender(); + scheduler.flush(); + await term.flush(); + + // The terminal snapshot settles an on-screen card in place, but a + // card with committed rows is immutable history: replacing it would + // re-commit the whole slab below the stale copy, so it is dropped + // (the job result surfaces through its own delivery message). + const settledDetails = detachedTaskDetails(13); + settledDetails.async = { state: "completed", jobId: "Task1", type: "task" }; + settledDetails.progress = []; + settledDetails.results = Array.from({ length: 6 }, (_, index) => ({ + index, + id: `Task${index + 1}`, + agent: "designer", + agentSource: "bundled" as const, + task: `Design concept ${index + 1}`, + assignment: `Design concept ${index + 1}`, + description: `Concept ${index + 1}`, + exitCode: 0, + output: `concept ${index + 1} shipped`, + stderr: "", + truncated: false, + durationMs: 1000 + index, + tokens: 1200, + requests: 12, + })); + task.updateResult({ content: [{ type: "text", text: "done" }], details: settledDetails }, false); + // A committed card is immutable: the dropped settlement leaves its + // render byte-identical (the frozen progress rows, not result rows). + expect(task.render(100).join("\n")).toBe(beforeRebuild); + for (let i = 0; i < 2; i++) { + term.scrollLines(1000); + tui.requestRender(); + scheduler.flush(); + await term.flush(); + } + + const buffer = plainScrollBuffer(term); + // The launch brief is append-only content: it belongs in history, + // exactly once. + expect(buffer.filter(line => line.includes("context-line-0"))).toHaveLength(1); + // Collapsed view shows the last 4 agents; each one-liner must appear + // exactly once across history + grid — no stale generations stacked. + for (let index = 3; index <= 6; index++) { + expect(buffer.filter(line => line.includes(`Task${index}`)).length).toBeLessThanOrEqual(1); + } + expect(buffer.filter(line => line.includes("more agents"))).toHaveLength(1); + expect(buffer.filter(line => line.includes("retrying 1/5")).length).toBeLessThanOrEqual(1); + // The dropped settlement never re-rendered the card as results. + expect(buffer.some(line => line.includes("shipped"))).toBe(false); + } finally { + task.stopAnimation(); + tui.stop(); + await term.flush(); + } + }, 30_000); + + // A blocking (foreground) task call is a self-replacing dashboard: its + // progress rows rewrite in place, so none of them may be recorded as + // frozen scrollback snapshots mid-run. Only the finalized result commits. + test("task: blocking progress taller than the viewport records only the final frame", async () => { + const rows = 24; + stubStdoutRows(rows); + const term = new VirtualTerminal(100, rows); + const scheduler = makeDrainableScheduler(); + const tui = new TUI(term, undefined, { renderScheduler: scheduler }); + tui.setScrollbackRebuild(false); + const transcript = new TranscriptContainer(); + const taskArgs = { + context: Array.from({ length: 40 }, (_, index) => `context-line-${index}`).join("\n"), + tasks: Array.from({ length: 6 }, (_, index) => ({ + name: `Task${index + 1}`, + agent: "designer", + task: `Design concept ${index + 1}`, + })), + }; + const task = new ToolExecutionComponent( + "task", + taskArgs, + { liveRegion: transcript }, + undefined, + tui, + process.cwd(), + ); + transcript.addChild(task); + tui.addChild(transcript); + tui.addChild(new Footer(6)); + + try { + tui.start(); + scheduler.flush(); + await term.flush(); + for (let tick = 1; tick <= 12; tick++) { + const details = detachedTaskDetails(tick); + delete details.async; // blocking call: no async job attached + task.updateResult({ content: [{ type: "text", text: "running" }], details }, true); + term.scrollLines(1000); + tui.requestRender(); + scheduler.flush(); + await term.flush(); + } + + const midRun = plainScrollBuffer(term); + for (let index = 1; index <= 6; index++) { + expect(midRun.filter(line => line.includes(`Task${index}`)).length).toBeLessThanOrEqual(1); + } + + // Finalize: results replace the progress rows and commit exactly once. + const finalDetails = detachedTaskDetails(12); + delete finalDetails.async; + finalDetails.progress = []; + finalDetails.results = Array.from({ length: 6 }, (_, index) => ({ + index, + id: `Task${index + 1}`, + agent: "designer", + agentSource: "bundled" as const, + task: `Design concept ${index + 1}`, + assignment: `Design concept ${index + 1}`, + description: `Concept ${index + 1}`, + exitCode: 0, + output: `concept ${index + 1} shipped`, + stderr: "", + truncated: false, + durationMs: 1000 + index, + tokens: 1200, + requests: 12, + })); + task.updateResult({ content: [{ type: "text", text: "done" }], details: finalDetails }, false); + for (let i = 0; i < 2; i++) { + term.scrollLines(1000); + tui.requestRender(); + scheduler.flush(); + await term.flush(); + } + + const settled = plainScrollBuffer(term); + expect(settled.filter(line => line.includes("context-line-0"))).toHaveLength(1); + for (let index = 1; index <= 6; index++) { + expect(settled.filter(line => line.includes(`Task${index}`)).length).toBeLessThanOrEqual(1); + } + } finally { + task.stopAnimation(); + tui.stop(); + await term.flush(); + } + }, 30_000); + test("eval: collapsed cell output stays within the viewport budget", () => { const rows = 18; stubStdoutRows(rows);