fix(coding-agent): fixed live-region IRC card cap and repeated-rewrite promotion behavior

- Capped live transcript IRC cards at four and evicted oldest cards when over limit.
- Reworked IRC card expiry handling to retire cards via timers only while live.
- Added rewrite-floor tracking so rewritten rows are not repeatedly promoted to scrollback.
This commit is contained in:
can1357
2026-06-10 00:09:46 +02:00
parent 57ae61ed8a
commit abc21fd8d2
6 changed files with 230 additions and 19 deletions
+9 -1
View File
@@ -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
@@ -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
@@ -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<string, NodeJS.Timeout>();
// Insertion-ordered IRC cards not yet retired; values are the transcript
// components each card contributed (see #retireIrcCard for the guard).
#liveIrcCards = new Map<string, Component[]>();
#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<AgentSessionEvent, { type: "notice" }>): Promise<void> {
+2 -1
View File
@@ -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<H
export interface InteractiveModeContext {
// UI access
ui: TUI;
chatContainer: Container;
chatContainer: TranscriptContainer;
pendingMessagesContainer: Container;
statusContainer: Container;
todoContainer: Container;
@@ -1,11 +1,12 @@
import { afterEach, beforeAll, describe, expect, it, vi } from "bun:test";
import type { TextContent, UserMessage } from "@oh-my-pi/pi-ai";
import { TranscriptContainer } from "@oh-my-pi/pi-coding-agent/modes/components/transcript-container";
import { EventController } from "@oh-my-pi/pi-coding-agent/modes/controllers/event-controller";
import { initTheme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme";
import type { InteractiveModeContext } from "@oh-my-pi/pi-coding-agent/modes/types";
import { UiHelpers } from "@oh-my-pi/pi-coding-agent/modes/utils/ui-helpers";
import type { CustomMessage } from "@oh-my-pi/pi-coding-agent/session/messages";
import { Container } from "@oh-my-pi/pi-tui";
import type { Component } from "@oh-my-pi/pi-tui";
beforeAll(() => {
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 () => {
@@ -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", () => {