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:
@@ -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,
|
||||
|
||||
+1021
-1019
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
|
||||
|
||||
Reference in New Issue
Block a user