diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 07adc457e..610571c82 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,6 +1,14 @@ # Changelog ## [Unreleased] +### Changed + +- Added a limit of 4 concurrent IRC cards in the transcript live region and evicted the oldest live-region card when new IRC cards would exceed the cap + +### Fixed + +- Kept IRC cards from being removed after their TTL once they had entered committed history above the live region +- Prevented slowly changing live-region rows from being repeatedly promoted to native scrollback, eliminating duplicate blocks from periodic in-place rewrites ## [15.10.10] - 2026-06-09 @@ -9836,4 +9844,4 @@ Initial public release. - Git branch display in footer - Message queueing during streaming responses - OAuth integration for Gmail and Google Calendar access -- HTML export with syntax highlighting and collapsible sections +- HTML export with syntax highlighting and collapsible sections \ No newline at end of file diff --git a/packages/coding-agent/src/modes/components/transcript-container.ts b/packages/coding-agent/src/modes/components/transcript-container.ts index 4410b28d2..ed1f4c6d9 100644 --- a/packages/coding-agent/src/modes/components/transcript-container.ts +++ b/packages/coding-agent/src/modes/components/transcript-container.ts @@ -27,6 +27,12 @@ interface LiveDiffSnapshot { stablePrefixLength: number; candidatePrefixLength: number; candidatePrefixAge: number; + /** + * Topmost row index ever observed rewritten in place (see + * {@link deriveLiveCommitState}): the stable-prefix ratchet never promotes + * rows at/after it. `Infinity` until the first rewrite. + */ + rewriteFloor: number; } interface SnapshotCarrier { @@ -74,6 +80,7 @@ interface LiveCommitState { stablePrefixLength: number; candidatePrefixLength: number; candidatePrefixAge: number; + rewriteFloor: number; safeLength: number; } @@ -157,12 +164,14 @@ function deriveLiveCommitState( let stablePrefixLength = 0; let candidatePrefixLength = 0; let candidatePrefixAge = 0; + let rewriteFloor = Number.POSITIVE_INFINITY; if (hasValidSnapshot(previous, width, generation)) { appendOnly = previous.appendOnly; volatileCooldown = previous.volatileCooldown; stablePrefixLength = previous.stablePrefixLength; candidatePrefixLength = previous.candidatePrefixLength; candidatePrefixAge = previous.candidatePrefixAge; + rewriteFloor = previous.rewriteFloor; const prefixLength = commonPrefixLength(previous.lines, current); const staticRender = prefixLength === previous.lines.length && prefixLength === current.length; @@ -196,10 +205,31 @@ function deriveLiveCommitState( } if ((preservedEveryRow || tailExtendedInPlace) && current.length >= previous.lines.length) { if (volatileCooldown === 0) appendOnly = true; + // Clean growth inserts rows at the divergence; rows the floor + // points at travel down with the preserved suffix. (On a tail + // extension the divergent row itself stays put — only rows + // strictly below it shift.) + const delta = current.length - previous.lines.length; + if (delta > 0 && Number.isFinite(rewriteFloor)) { + const floorShifts = preservedEveryRow ? rewriteFloor >= prefixLength : rewriteFloor > prefixLength; + if (floorShifts) rewriteFloor += delta; + } } else { cleanFrame = false; appendOnly = false; volatileCooldown = VOLATILE_REARM_FRAMES; + // A row rewritten in place once (an agent row's tool/cost + // counter, a periodically relocating footer) will be rewritten + // again: it is a ticker, not settling content. Floor the + // ratchet there permanently — only rows above the topmost + // ever-rewritten row may promote. Without this, a slow ticker + // (quiet for one promotion window between updates) gets + // promoted, committed, then rewritten — and the engine audit + // recommits on every tick, spraying stale snapshots of the + // block into native scrollback for the whole run. One-off + // re-layouts lose nothing: the append-only re-arm path commits + // the full block regardless of the floor. + rewriteFloor = Math.min(rewriteFloor, prefixLength); } } if (cleanFrame && volatileCooldown > 0) volatileCooldown--; @@ -222,7 +252,7 @@ function deriveLiveCommitState( candidatePrefixAge === 0 ? prefixLength : Math.min(candidatePrefixLength, prefixLength); candidatePrefixAge++; if (candidatePrefixAge >= STABLE_PREFIX_COMMIT_FRAMES) { - stablePrefixLength = candidatePrefixLength; + stablePrefixLength = Math.min(candidatePrefixLength, rewriteFloor); candidatePrefixLength = prefixLength; candidatePrefixAge = 0; } @@ -235,6 +265,7 @@ function deriveLiveCommitState( stablePrefixLength, candidatePrefixLength, candidatePrefixAge, + rewriteFloor, // 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, @@ -293,6 +324,24 @@ export class TranscriptContainer extends Container implements NativeScrollbackLi return this.#nativeScrollbackCommitSafeEnd; } + /** + * Whether `component` sits below a still-mutating block — i.e. inside the + * live region, where its rows cannot have been committed to native + * scrollback yet (commits are prefix-only and stop at the first + * still-live block). Callers that retract ephemeral blocks (IRC cards) + * must check this: removing a block whose rows may already be in history + * is an interior deletion of the committed prefix, which the engine can + * only repair by recommitting everything below it — duplication. + */ + isWithinLiveRegion(component: Component): boolean { + const index = this.children.indexOf(component); + if (index < 0) return false; + for (let i = 0; i < index; i++) { + if (!isBlockFinalized(this.children[i]!)) return true; + } + return false; + } + override render(width: number): string[] { width = Math.max(1, width); this.#nativeScrollbackLiveRegionStart = undefined; @@ -347,6 +396,7 @@ export class TranscriptContainer extends Container implements NativeScrollbackLi stablePrefixLength: liveCommitState?.stablePrefixLength ?? 0, candidatePrefixLength: liveCommitState?.candidatePrefixLength ?? 0, candidatePrefixAge: liveCommitState?.candidatePrefixAge ?? 0, + rewriteFloor: liveCommitState?.rewriteFloor ?? Number.POSITIVE_INFINITY, }; // Empty (or stripped-to-nothing) children contribute nothing and never diff --git a/packages/coding-agent/src/modes/controllers/event-controller.ts b/packages/coding-agent/src/modes/controllers/event-controller.ts index 8ec843bc0..edddc463d 100644 --- a/packages/coding-agent/src/modes/controllers/event-controller.ts +++ b/packages/coding-agent/src/modes/controllers/event-controller.ts @@ -25,6 +25,16 @@ import { StreamingRevealController } from "./streaming-reveal"; type AgentSessionEventKind = AgentSessionEvent["type"]; const IRC_MESSAGE_VISIBLE_TTL_MS = 10_000; +/** + * Concurrent IRC cards allowed in the transcript's live region. Cards land + * below a still-live block (a running task), where they cannot commit to + * native scrollback (commits are prefix-only) — every visible card inflates + * the live region and pushes the live block's uncommitted rows above the + * window top, where they are neither on screen nor in history. A swarm burst + * (several agents coordinating at once) must therefore stay bounded: the + * oldest live-region card retires as soon as a new one would exceed the cap. + */ +const MAX_LIVE_IRC_CARDS = 4; /** * Loader label shown the instant a user interrupt (Esc) is requested, kept until @@ -64,6 +74,9 @@ export class EventController { #pinnedErrorComponent: AssistantMessageComponent | undefined = undefined; #idleCompactionTimer?: NodeJS.Timeout; #ircExpiryTimers = new Map(); + // Insertion-ordered IRC cards not yet retired; values are the transcript + // components each card contributed (see #retireIrcCard for the guard). + #liveIrcCards = new Map(); #streamingReveal: StreamingRevealController; #handlers: AgentSessionEventHandlers; @@ -111,6 +124,7 @@ export class EventController { clearTimeout(timer); } this.#ircExpiryTimers.clear(); + this.#liveIrcCards.clear(); } #resetReadGroup(): void { @@ -324,6 +338,7 @@ export class EventController { this.#resetReadGroup(); const components = this.ctx.addMessageToChat(event.message); this.#scheduleIrcExpiry(signature, components); + this.#enforceIrcCardCap(signature); this.ctx.ui.requestRender(); } @@ -331,13 +346,47 @@ export class EventController { if (components.length === 0 || this.#ircExpiryTimers.has(signature)) return; const timer = setTimeout(() => { this.#ircExpiryTimers.delete(signature); - for (const component of components) { - this.ctx.chatContainer.removeChild(component); - } - this.ctx.ui.requestRender(); + this.#retireIrcCard(signature); }, IRC_MESSAGE_VISIBLE_TTL_MS); timer.unref?.(); this.#ircExpiryTimers.set(signature, timer); + this.#liveIrcCards.set(signature, components); + } + + /** + * Remove an expired/evicted IRC card — but only while it still sits below a + * live block, where its rows cannot have entered native scrollback. Once + * everything above it has finalized, its rows may already be committed; + * removing them then is an interior deletion of the committed prefix, which + * the engine can only repair by recommitting every row below the gap — + * exactly the duplicated-block artifact this guard exists to prevent. Such + * a card simply stays: it is final history, and the window scrolls past it. + */ + #retireIrcCard(signature: string): void { + const components = this.#liveIrcCards.get(signature); + this.#liveIrcCards.delete(signature); + if (!components) return; + let removed = false; + for (const component of components) { + if (!this.ctx.chatContainer.isWithinLiveRegion(component)) continue; + this.ctx.chatContainer.removeChild(component); + removed = true; + } + if (removed) this.ctx.ui.requestRender(); + } + + /** Evict oldest live-region cards beyond {@link MAX_LIVE_IRC_CARDS}. */ + #enforceIrcCardCap(latestSignature: string): void { + while (this.#liveIrcCards.size > MAX_LIVE_IRC_CARDS) { + const oldest = this.#liveIrcCards.keys().next().value; + if (oldest === undefined || oldest === latestSignature) return; + const timer = this.#ircExpiryTimers.get(oldest); + if (timer) { + clearTimeout(timer); + this.#ircExpiryTimers.delete(oldest); + } + this.#retireIrcCard(oldest); + } } async #handleNotice(event: Extract): Promise { diff --git a/packages/coding-agent/src/modes/types.ts b/packages/coding-agent/src/modes/types.ts index 5d920dc37..bec732f20 100644 --- a/packages/coding-agent/src/modes/types.ts +++ b/packages/coding-agent/src/modes/types.ts @@ -28,6 +28,7 @@ import type { HookInputComponent } from "./components/hook-input"; import type { HookSelectorComponent, HookSelectorOptions } from "./components/hook-selector"; import type { StatusLineComponent } from "./components/status-line"; import type { ToolExecutionHandle } from "./components/tool-execution"; +import type { TranscriptContainer } from "./components/transcript-container"; import type { LoopLimitRuntime } from "./loop-limit"; import type { OAuthManualInputManager } from "./oauth-manual-input"; import type { Theme } from "./theme/theme"; @@ -76,7 +77,7 @@ export type InteractiveSelectorDialogOptions = ExtensionUIDialogOptions & Pick { initTheme(); @@ -156,13 +157,22 @@ function createIrcMessage(timestamp: number): CustomMessage<{ from: string; mess customType: "irc:incoming", content: "Ready", display: true, - details: { from: "0-Main", message: "Ready" }, + details: { from: "0-Main", message: `Ready ${timestamp}` }, timestamp, }; } -function createIrcContext() { - const chatContainer = new Container(); +function createIrcContext(options: { liveBlockAbove?: boolean } = {}) { + const chatContainer = new TranscriptContainer(); + if (options.liveBlockAbove) { + // A still-running tool above the cards: they sit in the live region, + // where their rows cannot have committed to native scrollback. + chatContainer.addChild({ + render: () => ["running tool"], + invalidate: () => {}, + isTranscriptBlockFinalized: () => false, + } as Component); + } const requestRender = vi.fn(); const ctx = { isInitialized: true, @@ -186,38 +196,70 @@ describe("EventController IRC expiry", () => { vi.restoreAllMocks(); }); - it("renders IRC messages immediately and removes their components after the TTL", async () => { + it("renders IRC messages immediately and removes live-region cards after the TTL", async () => { vi.useFakeTimers(); const message = createIrcMessage(1); - const { ctx, chatContainer, requestRender } = createIrcContext(); + const { ctx, chatContainer, requestRender } = createIrcContext({ liveBlockAbove: true }); const controller = new EventController(ctx); await controller.handleEvent({ type: "irc_message", message }); - expect(chatContainer.children).toHaveLength(1); + expect(chatContainer.children).toHaveLength(2); expect(requestRender).toHaveBeenCalledTimes(1); vi.advanceTimersByTime(9_999); - expect(chatContainer.children).toHaveLength(1); + expect(chatContainer.children).toHaveLength(2); vi.advanceTimersByTime(1); - expect(chatContainer.children).toHaveLength(0); + expect(chatContainer.children).toHaveLength(1); expect(requestRender).toHaveBeenCalledTimes(2); }); + it("keeps a card whose rows may already be committed (no live block above)", async () => { + vi.useFakeTimers(); + const message = createIrcMessage(4); + const { ctx, chatContainer } = createIrcContext(); + const controller = new EventController(ctx); + + await controller.handleEvent({ type: "irc_message", message }); + expect(chatContainer.children).toHaveLength(1); + + // Everything above the card is finalized, so its rows may already be in + // native scrollback. Removing it would be an interior deletion of the + // committed prefix — the engine repairs that by recommitting everything + // below the gap (the duplicated-block artifact). It must stay. + vi.advanceTimersByTime(10_000); + expect(chatContainer.children).toHaveLength(1); + }); + + it("evicts the oldest live-region card beyond the cap", async () => { + vi.useFakeTimers(); + const { ctx, chatContainer } = createIrcContext({ liveBlockAbove: true }); + const controller = new EventController(ctx); + + for (let i = 0; i < 5; i++) { + await controller.handleEvent({ type: "irc_message", message: createIrcMessage(100 + i) }); + } + // live block + MAX_LIVE_IRC_CARDS (4): the 5th card evicted the 1st. + expect(chatContainer.children).toHaveLength(5); + const rendered = chatContainer.children.map(child => child.render(80).join("\n")); + expect(rendered.some(text => text.includes("100"))).toBe(false); + expect(rendered.some(text => text.includes("104"))).toBe(true); + }); + it("does not schedule duplicate expiry for duplicate IRC events", async () => { vi.useFakeTimers(); const message = createIrcMessage(2); - const { ctx, chatContainer, addMessageToChat } = createIrcContext(); + const { ctx, chatContainer, addMessageToChat } = createIrcContext({ liveBlockAbove: true }); const controller = new EventController(ctx); await controller.handleEvent({ type: "irc_message", message }); await controller.handleEvent({ type: "irc_message", message }); expect(addMessageToChat).toHaveBeenCalledTimes(1); - expect(chatContainer.children).toHaveLength(1); + expect(chatContainer.children).toHaveLength(2); vi.advanceTimersByTime(10_000); - expect(chatContainer.children).toHaveLength(0); + expect(chatContainer.children).toHaveLength(1); }); it("clears pending IRC expiry timers on dispose", async () => { 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 4eafb3c1d..a0effc0ef 100644 --- a/packages/coding-agent/test/tool-live-region-scrollback.test.ts +++ b/packages/coding-agent/test/tool-live-region-scrollback.test.ts @@ -191,6 +191,67 @@ describe("transcript reactive commit boundary", () => { chat.render(80); expect(chat.getNativeScrollbackCommitSafeEnd()).toBe(3); }); + + it("never re-promotes rows that have ever been rewritten in place (slow ticker)", () => { + const chat = new TranscriptContainer(); + const head = markerLines("head-", 8); + // Task progress tree shape: per-agent rows whose tool/cost counters tick + // every few seconds — far slower than the promotion window, so each row + // looks "settled" between updates. + const tree = (a: number, b: number, c: number) => [ + `agent-one · ${a} tools`, + `agent-two · ${b} tools`, + `agent-three · ${c} tools`, + ]; + const block = new MutableLiveBlock([...head, ...tree(0, 0, 0)]); + chat.addChild(block); + chat.render(80); + + // Stagger slow updates with long quiet stretches in between. Once any + // tree row has rewritten in place, no tree row may ever promote again: + // a promoted-then-rewritten row is a committed-then-rewritten row, and + // the engine audit can only repair that by recommitting — spraying a + // stale snapshot of the block into scrollback on every later tick. + let maxSafeEnd = 0; + const counters: [number, number, number] = [0, 0, 0]; + for (let tick = 0; tick < 6; tick++) { + counters[tick % 3] += 1; + block.setLines([...head, ...tree(...counters)]); + for (let frame = 0; frame < 40; frame++) { + chat.render(80); + const safeEnd = chat.getNativeScrollbackCommitSafeEnd() ?? 0; + if (tick > 0) maxSafeEnd = Math.max(maxSafeEnd, safeEnd); + } + } + + // The static head still commits; the slow-ticking tree never does. + expect(chat.getNativeScrollbackCommitSafeEnd()).toBe(8); + expect(maxSafeEnd).toBe(8); + }); + + it("keeps the rewrite floor anchored across append growth below it", () => { + const chat = new TranscriptContainer(); + const head = markerLines("head-", 4); + const block = new MutableLiveBlock([...head, "ticker · 0"]); + chat.addChild(block); + chat.render(80); + + // Tick once: the floor lands on the ticker row (index 4). + block.setLines([...head, "ticker · 1"]); + chat.render(80); + + // Settled rows are inserted above the ticker (append above stable + // trailing chrome): the ticker shifts down and the floor must travel + // with it, or the new settled rows would be barred from promoting. + block.setLines([...head, "settled-a", "settled-b", "ticker · 1"]); + for (let i = 0; i < 70; i++) chat.render(80); + expect(chat.getNativeScrollbackCommitSafeEnd()).toBe(6); + + // And the shifted ticker itself still never promotes. + block.setLines([...head, "settled-a", "settled-b", "ticker · 2"]); + for (let i = 0; i < 70; i++) chat.render(80); + expect(chat.getNativeScrollbackCommitSafeEnd()).toBe(6); + }); }); describe("tool live-region scrollback", () => {