fix(coding-agent): prevented duplicate rows in task execution scrollback
- Updated task tool execution to pin live regions and drop partial snapshots once rows commit. - Tracked background task frozen styled rows and render timestamps to prevent clock drift on committed history. - Added tests verifying detached and blocking task progress do not duplicate rows in scrollback.
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<object>,
|
||||
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<object> = new WeakSet<object>(),
|
||||
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,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user