refactor(coding-agent): restructured transcript rendering during initialization

- Stage transcript initialization inside a detached TranscriptContainer to keep existing messages visible during incremental rendering.
- Add fallback state restoration in InteractiveMode.renderInitialMessages when chat rendering is aborted or fails.
- Update render-initial-messages tests to assert that old transcripts remain visible until replacements are fully committed.
This commit is contained in:
can1357
2026-08-13 18:55:18 +02:00
parent 778dfaccca
commit 28997c0d46
5 changed files with 1153 additions and 1084 deletions
@@ -4597,7 +4597,7 @@ export class InteractiveMode implements InteractiveModeContext {
this.#uiHelpers.renderSessionContext(sessionContext, options);
}
/** Render a session context in bounded chunks so terminal input runs between transcript paints. */
/** Build a session context in bounded chunks so terminal input runs between event-loop turns. */
async renderSessionContextIncrementally(
sessionContext: SessionContext,
options: RenderSessionContextOptions,
@@ -33,8 +33,8 @@ import {
import { SkillMessageComponent } from "../../modes/components/skill-message";
import { StrippedToolCallsPlaceholder } from "../../modes/components/stripped-tool-calls-placeholder";
import { ToolActivityContainer } from "../../modes/components/tool-activity";
import { ToolExecutionComponent } from "../../modes/components/tool-execution";
import { TranscriptBlock } from "../../modes/components/transcript-container";
import { ToolExecutionComponent, type ToolExecutionHandle } from "../../modes/components/tool-execution";
import { TranscriptBlock, TranscriptContainer } from "../../modes/components/transcript-container";
import { createUsageRowBlock } from "../../modes/components/usage-row";
import { UserMessageComponent } from "../../modes/components/user-message";
import { decodeStreamedToolArgs, streamingStringKeysForTool } from "../../modes/controllers/tool-args-reveal";
@@ -325,7 +325,7 @@ export class UiHelpers {
while (!steps.next().done) {}
}
/** Render a session context in bounded chunks so terminal input runs between transcript paints. */
/** Build a session context in bounded chunks so terminal input runs between event-loop turns. */
async renderSessionContextIncrementally(
sessionContext: SessionContext,
options: RenderSessionContextOptions,
@@ -729,18 +729,23 @@ export class UiHelpers {
}
async renderInitialMessages(options: RenderInitialMessagesOptions = {}): Promise<void> {
// This path is used to rebuild the visible chat transcript (e.g. after custom/debug UI).
// Clear existing rendered chat first to avoid duplicating the full session in the container.
// On a non-preserving rebuild the existing blocks are discarded for good, so
// dispose them (stopping any live timers/subscriptions) before clearing. When
// preserving, the same instances are re-added below, so detach without dispose.
const preservedChatChildren = options.preserveExistingChat ? this.ctx.chatContainer.children : undefined;
// Build against a detached container. Incremental construction still yields
// to terminal input, while paints keep using the complete visible transcript
// until the replacement is ready to swap in.
const visibleChatContainer = this.ctx.chatContainer;
const stagedChatContainer = new TranscriptContainer();
stagedChatContainer.setToolActivityVisible(!this.ctx.hideToolActivity);
const preservedChatChildren = options.preserveExistingChat ? [...visibleChatContainer.children] : undefined;
const previousTranscriptMessageComponents = this.ctx.transcriptMessageComponents;
const previousPendingTools = this.ctx.pendingTools;
const previousPendingBashComponents = this.ctx.pendingBashComponents;
const previousPendingPythonComponents = this.ctx.pendingPythonComponents;
const previousLastAssistantUsage = this.ctx.lastAssistantUsage;
const chatWasAlreadyRendered = this.ctx.initialChatRendered;
if (preservedChatChildren) {
this.ctx.chatContainer.clear();
} else {
this.ctx.resetTranscript();
}
this.ctx.chatContainer = stagedChatContainer;
this.ctx.transcriptMessageComponents = new WeakMap<AgentMessage, Component>();
this.ctx.pendingTools = new Map<string, ToolExecutionHandle>();
this.ctx.pendingMessagesContainer.disposeChildren();
this.ctx.pendingBashComponents = [];
this.ctx.pendingPythonComponents = [];
@@ -751,13 +756,6 @@ export class UiHelpers {
// (focus attach/unfocus while a tool executes) keep dangling toolCalls so
// the in-flight call re-renders as pending instead of vanishing;
// renderSessionContext then keeps it in `pendingTools` for live routing.
let terminalHistoryCleared = false;
const renderChunk = options.clearTerminalHistory
? () => {
this.ctx.ui.requestRender(true, { clearScrollback: !terminalHistoryCleared });
terminalHistoryCleared = true;
}
: undefined;
let context = this.ctx.viewSession.buildTranscriptSessionContext({
collapseCompactedHistory: settings.get("display.collapseCompacted"),
keepDanglingToolCalls: this.ctx.viewSession.isStreaming,
@@ -770,6 +768,7 @@ export class UiHelpers {
// every attempt.
populateHistory: false,
};
let committed = false;
this.ctx.initialChatRendered = false;
try {
while (true) {
@@ -777,8 +776,6 @@ export class UiHelpers {
// Live events mutate the same component maps; keep their replay atomic so
// a delta cannot land halfway through rebuilding its pending tool block.
this.ctx.renderSessionContext(context, renderOptions);
} else if (renderChunk) {
await this.ctx.renderSessionContextIncrementally(context, renderOptions, renderChunk);
} else {
await this.ctx.renderSessionContextIncrementally(context, renderOptions);
}
@@ -790,49 +787,73 @@ export class UiHelpers {
// yielded. The display callback stayed gated by initialChatRendered;
// discard the stale partial tree and replay the current session once
// more instead of letting a reentrant synchronous rebuild interleave.
this.ctx.resetTranscript();
stagedChatContainer.disposeChildren();
this.ctx.transcriptMessageComponents = new WeakMap<AgentMessage, Component>();
this.ctx.pendingTools.clear();
this.ctx.pendingBashComponents = [];
this.ctx.pendingPythonComponents = [];
terminalHistoryCleared = false;
context = this.ctx.viewSession.buildTranscriptSessionContext({
collapseCompactedHistory: settings.get("display.collapseCompacted"),
keepDanglingToolCalls: this.ctx.viewSession.isStreaming,
});
replayEntryCount = this.ctx.viewSession.sessionManager.getEntries().length;
}
} finally {
this.ctx.initialChatRendered = chatWasAlreadyRendered;
}
if (!this.ctx.focusedAgentId) {
for (const message of context.messages) {
if (message.role !== "user" || message.synthetic) continue;
const text = this.getUserMessageText(message);
if (text) this.ctx.editor.addToHistory(text);
}
}
// Show compaction info if session was compacted
const allEntries = this.ctx.viewSession.sessionManager.getEntries();
let compactionCount = 0;
for (const entry of allEntries) {
if (entry.type === "compaction") {
compactionCount++;
const replayedChatChildren = [...stagedChatContainer.children];
stagedChatContainer.clear();
this.ctx.chatContainer = visibleChatContainer;
if (preservedChatChildren) {
visibleChatContainer.clear();
} else {
visibleChatContainer.disposeChildren();
}
}
if (compactionCount > 0) {
const times = compactionCount === 1 ? "1 time" : `${compactionCount} times`;
this.ctx.showStatus(`Session compacted ${times}`);
}
if (options.clearTerminalHistory) {
this.ctx.ui.requestRender(true, { clearScrollback: !terminalHistoryCleared });
}
if (preservedChatChildren && preservedChatChildren.length > 0) {
for (const child of preservedChatChildren) {
this.ctx.chatContainer.addChild(child);
for (const child of replayedChatChildren) {
visibleChatContainer.addChild(child);
}
this.ctx.ui.requestRender();
if (preservedChatChildren) {
for (const child of preservedChatChildren) {
visibleChatContainer.addChild(child);
}
}
committed = true;
if (!this.ctx.focusedAgentId) {
for (const message of context.messages) {
if (message.role !== "user" || message.synthetic) continue;
const text = this.getUserMessageText(message);
if (text) this.ctx.editor.addToHistory(text);
}
}
// Show compaction info if session was compacted.
const allEntries = this.ctx.viewSession.sessionManager.getEntries();
let compactionCount = 0;
for (const entry of allEntries) {
if (entry.type === "compaction") {
compactionCount++;
}
}
if (compactionCount > 0) {
const times = compactionCount === 1 ? "1 time" : `${compactionCount} times`;
this.ctx.showStatus(`Session compacted ${times}`);
}
if (options.clearTerminalHistory) {
this.ctx.ui.requestRender(true, { clearScrollback: true });
} else {
this.ctx.ui.requestRender();
}
} finally {
if (!committed) {
this.ctx.chatContainer = visibleChatContainer;
this.ctx.transcriptMessageComponents = previousTranscriptMessageComponents;
this.ctx.pendingTools = previousPendingTools;
this.ctx.pendingBashComponents = previousPendingBashComponents;
this.ctx.pendingPythonComponents = previousPendingPythonComponents;
this.ctx.lastAssistantUsage = previousLastAssistantUsage;
stagedChatContainer.disposeChildren();
}
this.ctx.initialChatRendered = committed ? true : chatWasAlreadyRendered;
}
this.ctx.initialChatRendered = true;
}
clearEditor(): void {
@@ -30,6 +30,8 @@ import { TempDir } from "@oh-my-pi/pi-utils";
type AutoRetryStartEvent = Extract<AgentSessionEvent, { type: "auto_retry_start" }>;
type AutoRetryEndEvent = Extract<AgentSessionEvent, { type: "auto_retry_end" }>;
const FALLBACK_TEST_RETRY_AFTER_MS = 60_000;
function trackRetryEvents(session: AgentSession): {
retryStartEvents: AutoRetryStartEvent[];
retryEndEvents: AutoRetryEndEvent[];
@@ -55,7 +57,12 @@ function getLastAssistantMessage(session: AgentSession): AssistantMessage {
return lastMessage;
}
function createFallbackAgent(primaryModel: Model, requestedModels: string[]): Agent {
function createFallbackAgent(
primaryModel: Model,
requestedModels: string[],
options: { retryAfterMs?: number } = {},
): Agent {
const retryAfterMs = options.retryAfterMs ?? FALLBACK_TEST_RETRY_AFTER_MS;
const mock = createMockModel();
let primaryAttempts = 0;
return new Agent({
@@ -70,7 +77,7 @@ function createFallbackAgent(primaryModel: Model, requestedModels: string[]): Ag
requestedModels.push(`${model.provider}/${model.id}`);
if (model.provider === primaryModel.provider && model.id === primaryModel.id && primaryAttempts === 0) {
primaryAttempts += 1;
mock.push({ throw: "rate limit exceeded retry-after-ms=200" });
mock.push({ throw: `rate limit exceeded retry-after-ms=${retryAfterMs}` });
} else {
mock.push({ content: [`ok:${model.provider}/${model.id}`] });
}
@@ -3383,7 +3390,7 @@ describe("AgentSession retry fallback", () => {
requestedModels.push(`${requestedModel.provider}/${requestedModel.id}`);
if (requestedModel.provider === primaryModel.provider && primaryAttempts === 0) {
primaryAttempts += 1;
mock.push({ throw: "rate limit exceeded retry-after-ms=200" });
mock.push({ throw: `rate limit exceeded retry-after-ms=${FALLBACK_TEST_RETRY_AFTER_MS}` });
} else {
mock.push({ content: [`ok:${requestedModel.provider}/${requestedModel.id}`] });
}
@@ -3424,7 +3431,7 @@ describe("AgentSession retry fallback", () => {
}
const requestedModels: string[] = [];
const agent = createFallbackAgent(primaryModel, requestedModels);
const agent = createFallbackAgent(primaryModel, requestedModels, { retryAfterMs: 200 });
const settings = Settings.isolated({
"compaction.enabled": false,
@@ -3990,7 +3997,7 @@ describe("AgentSession retry fallback", () => {
}
const requestedModels: string[] = [];
const agent = createFallbackAgent(primaryModel, requestedModels);
const agent = createFallbackAgent(primaryModel, requestedModels, { retryAfterMs: 200 });
const settings = Settings.isolated({
"compaction.enabled": false,
File diff suppressed because one or more lines are too long
@@ -66,12 +66,17 @@ function makeCtx(): {
const transcriptSpy = vi.fn(() => makeEmptyContext());
const llmContextSpy = vi.fn(() => makeEmptyContext());
const renderSessionContextSpy = vi.fn(async () => {});
const chatContainer = new TranscriptContainer();
const ctx = {
chatContainer: { clear: vi.fn(), addChild: vi.fn() },
chatContainer,
pendingMessagesContainer: { clear: vi.fn(), disposeChildren: vi.fn() },
pendingBashComponents: [],
pendingPythonComponents: [],
transcriptMessageComponents: new WeakMap<AgentMessage, Component>(),
pendingTools: new Map(),
hideToolActivity: false,
initialChatRendered: true,
session: { buildTranscriptSessionContext: transcriptSpy },
viewSession: {
buildTranscriptSessionContext: transcriptSpy,
@@ -90,7 +95,7 @@ function makeCtx(): {
renderSessionContextIncrementally: renderSessionContextSpy,
showStatus: vi.fn(),
ui: { requestRender: vi.fn() },
resetTranscript: () => ctx.chatContainer.clear(),
resetTranscript: () => ctx.chatContainer.disposeChildren(),
} as unknown as InteractiveModeContext;
return { ctx, transcriptSpy, llmContextSpy, renderSessionContextSpy };
@@ -151,16 +156,19 @@ function makeRenderCtx(
pendingMessagesContainer: new Container(),
pendingBashComponents: [],
pendingPythonComponents: [],
transcriptMessageComponents: new WeakMap(),
transcriptMessageComponents: new WeakMap<AgentMessage, Component>(),
pendingTools: new Map(),
statusLine: { invalidate: vi.fn() },
updateEditorBorderColor: vi.fn(),
updateEditorTopBorder: vi.fn(),
ui: { requestRender: vi.fn(), imageBudget: undefined },
resetTranscript: () => chatContainer.clear(),
resetTranscript: () => {
ctx.transcriptMessageComponents = new WeakMap<AgentMessage, Component>();
ctx.chatContainer.disposeChildren();
},
present: (content: Component | readonly Component[]) => {
const components = Array.isArray(content) ? content : [content];
for (const component of components) chatContainer.addChild(component);
for (const component of components) ctx.chatContainer.addChild(component);
},
// Rebuild paths honor terminal.showImages since the native-image work;
// keep it on so the image-replay contracts below stay meaningful.
@@ -286,6 +294,37 @@ describe("UiHelpers.renderInitialMessages — responsiveness", () => {
expect(await countRebuildChunks(messages)).toBeGreaterThan(0);
});
it("keeps the complete transcript visible until an incremental replacement is ready", async () => {
await Settings.init({ inMemory: true });
const messages: AgentMessage[] = Array.from({ length: 256 }, (_, index) => ({
role: "user",
content: `replacement ${index}`,
timestamp: index,
}));
const { ctx, chatContainer } = makeRenderCtx(transcriptWith(messages));
const helpers = new UiHelpers(ctx);
helpers.addMessageToChat({
role: "user",
content: "VISIBLE_OLD_TRANSCRIPT",
timestamp: -1,
});
const requestRender = ctx.ui.requestRender as Mock<(...args: unknown[]) => void>;
const replay = helpers.renderInitialMessages({ clearTerminalHistory: true });
const duringReplay = Bun.stripANSI(chatContainer.render(100).join("\n"));
expect(duringReplay).toContain("VISIBLE_OLD_TRANSCRIPT");
expect(duringReplay).not.toContain("replacement 0");
expect(requestRender.mock.calls.some(([force]) => force === true)).toBeFalse();
await replay;
const afterReplay = Bun.stripANSI(chatContainer.render(100).join("\n"));
expect(afterReplay).not.toContain("VISIBLE_OLD_TRANSCRIPT");
expect(afterReplay).toContain("replacement 255");
expect(requestRender.mock.calls.filter(([force]) => force === true)).toEqual([[true, { clearScrollback: true }]]);
});
it("yields across a large parallel read-result batch", async () => {
// Regression: a single assistant turn whose results are all grouped `read`
// toolResults replays entirely through the `isReadGroupResult` early