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:
@@ -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> {
|
||||
|
||||
@@ -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;
|
||||
|
||||
+54
-12
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user