From 94c51945d44930eb98be730c58f44bcbe43375e8 Mon Sep 17 00:00:00 2001 From: roboomp Date: Mon, 10 Aug 2026 05:30:18 +0000 Subject: [PATCH 1/3] fix(tui): chunked large transcript rebuilds Replayed idle transcripts in bounded message and time chunks, painting cleared scrollback between macrotasks so terminal input remains responsive during restore and tree navigation. Kept streaming rebuilds atomic and migrated every rebuild caller to await completion. Fixes #8133 --- packages/coding-agent/CHANGELOG.md | 4 + packages/coding-agent/src/collab/guest.ts | 4 +- packages/coding-agent/src/main.ts | 2 +- .../modes/controllers/command-controller.ts | 2 +- .../src/modes/controllers/event-controller.ts | 2 +- .../controllers/extension-ui-controller.ts | 16 ++-- .../modes/controllers/selector-controller.ts | 8 +- .../controllers/session-focus-controller.ts | 2 +- .../src/modes/interactive-mode.ts | 21 ++++- packages/coding-agent/src/modes/types.ts | 8 +- .../src/modes/utils/ui-helpers.ts | 66 +++++++++++++- .../test/interactive-mode-status.test.ts | 13 ++- .../test/issue-4348-repro.test.ts | 15 +-- .../utils/render-initial-messages.test.ts | 91 ++++++++++++++----- ...sue-1955-sendmessage-double-render.test.ts | 15 ++- 15 files changed, 204 insertions(+), 65 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index a4d66e8d2..18d8be528 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed large-session restore and `/tree` navigation blocking input while rebuilding the transcript by chunking idle rebuilds and terminal paints across event-loop turns ([#8133](https://github.com/can1357/oh-my-pi/issues/8133)). + ## [17.2.12] - 2026-08-08 ### Fixed diff --git a/packages/coding-agent/src/collab/guest.ts b/packages/coding-agent/src/collab/guest.ts index db8aa640b..26b7dde57 100644 --- a/packages/coding-agent/src/collab/guest.ts +++ b/packages/coding-agent/src/collab/guest.ts @@ -452,7 +452,7 @@ export class CollabGuestLink { this.#assistantStreamSynced = false; setSessionTerminalTitle(pending.state.sessionName ?? pending.header.title, pending.state.cwd); this.#ctx.chatContainer.clear(); - this.#ctx.renderInitialMessages({ clearTerminalHistory: true }); + await this.#ctx.renderInitialMessages({ clearTerminalHistory: true }); await this.#ctx.reloadTodos(); this.#updateStatusSegment(); this.#readOnly = pending.readOnly; @@ -749,7 +749,7 @@ export class CollabGuestLink { this.#ctx.statusLine.resetActiveTime(); this.#ctx.ui.requestRender(); this.#ctx.updateEditorBorderColor(); - this.#ctx.renderInitialMessages({ clearTerminalHistory: true }); + await this.#ctx.renderInitialMessages({ clearTerminalHistory: true }); await this.#ctx.reloadTodos(); this.#ctx.ui.requestRender(true, { clearScrollback: true }); } diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index c9de644e9..c90fed459 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -517,7 +517,7 @@ async function runInteractiveMode( // Every in-process session load also uses `clearTerminalHistory`; cold launch // follows the same clean-cutover path instead of preserving a previous run's // transcript above the fresh one. - mode.renderInitialMessages({ preserveExistingChat: true, clearTerminalHistory: true }); + await mode.renderInitialMessages({ preserveExistingChat: true, clearTerminalHistory: true }); for (const notify of notifs) { if (!notify) { diff --git a/packages/coding-agent/src/modes/controllers/command-controller.ts b/packages/coding-agent/src/modes/controllers/command-controller.ts index b434ad016..861a8e9e3 100644 --- a/packages/coding-agent/src/modes/controllers/command-controller.ts +++ b/packages/coding-agent/src/modes/controllers/command-controller.ts @@ -1422,7 +1422,7 @@ export class CommandController { // Rebuild chat from the new session (which now contains the handoff document). this.ctx.clearTransientSessionUi(); - this.ctx.renderInitialMessages(); + await this.ctx.renderInitialMessages(); this.ctx.statusLine.invalidate(); this.ctx.updateEditorBorderColor(); await this.ctx.reloadTodos(); diff --git a/packages/coding-agent/src/modes/controllers/event-controller.ts b/packages/coding-agent/src/modes/controllers/event-controller.ts index c0937abaa..10e77e63a 100644 --- a/packages/coding-agent/src/modes/controllers/event-controller.ts +++ b/packages/coding-agent/src/modes/controllers/event-controller.ts @@ -1862,7 +1862,7 @@ export class EventController { } else if (isHandoffAction) { this.ctx.clearTransientSessionUi(); this.ctx.lastAssistantUsage = undefined; - this.ctx.renderInitialMessages(); + await this.ctx.renderInitialMessages(); this.ctx.statusLine.invalidate(); await this.ctx.reloadTodos(); this.ctx.ui.requestRender(true, { clearScrollback: true }); diff --git a/packages/coding-agent/src/modes/controllers/extension-ui-controller.ts b/packages/coding-agent/src/modes/controllers/extension-ui-controller.ts index 4b2b57bf5..ff3934df7 100644 --- a/packages/coding-agent/src/modes/controllers/extension-ui-controller.ts +++ b/packages/coding-agent/src/modes/controllers/extension-ui-controller.ts @@ -202,7 +202,7 @@ export class ExtensionUiController { waitForIdle: () => this.ctx.session.agent.waitForIdle(), reload: async () => { await this.ctx.session.reload(); - this.ctx.renderInitialMessages({ clearTerminalHistory: true }); + await this.ctx.renderInitialMessages({ clearTerminalHistory: true }); await this.ctx.reloadTodos(); this.ctx.showStatus("Reloaded session"); }, @@ -245,7 +245,7 @@ export class ExtensionUiController { } // Update UI - this.ctx.renderInitialMessages({ clearTerminalHistory: true }); + await this.ctx.renderInitialMessages({ clearTerminalHistory: true }); await this.ctx.reloadTodos(); this.ctx.editor.setDraft(result.selectedText, result.selectedImages); this.ctx.showStatus("Branched to new session"); @@ -259,7 +259,7 @@ export class ExtensionUiController { } // Update UI - this.ctx.renderInitialMessages({ clearTerminalHistory: true }); + await this.ctx.renderInitialMessages({ clearTerminalHistory: true }); await this.ctx.reloadTodos(); if (result.editorText && !this.ctx.editor.getText().trim()) { this.ctx.editor.setDraft(result.editorText, result.editorImages); @@ -276,7 +276,7 @@ export class ExtensionUiController { return { cancelled: true }; } setSessionTerminalTitle(this.ctx.sessionManager.getSessionName(), this.ctx.sessionManager.getCwd()); - this.ctx.renderInitialMessages({ clearTerminalHistory: true }); + await this.ctx.renderInitialMessages({ clearTerminalHistory: true }); await this.ctx.reloadTodos(); return { cancelled: false }; }, @@ -435,7 +435,7 @@ export class ExtensionUiController { waitForIdle: () => this.ctx.session.agent.waitForIdle(), reload: async () => { await this.ctx.session.reload(); - this.ctx.renderInitialMessages({ clearTerminalHistory: true }); + await this.ctx.renderInitialMessages({ clearTerminalHistory: true }); await this.ctx.reloadTodos(); this.ctx.showStatus("Reloaded session"); }, @@ -475,7 +475,7 @@ export class ExtensionUiController { } // Update UI - this.ctx.renderInitialMessages({ clearTerminalHistory: true }); + await this.ctx.renderInitialMessages({ clearTerminalHistory: true }); await this.ctx.reloadTodos(); this.ctx.editor.setDraft(result.selectedText, result.selectedImages); this.ctx.showStatus("Branched to new session"); @@ -489,7 +489,7 @@ export class ExtensionUiController { } // Update UI - this.ctx.renderInitialMessages({ clearTerminalHistory: true }); + await this.ctx.renderInitialMessages({ clearTerminalHistory: true }); await this.ctx.reloadTodos(); if (result.editorText && !this.ctx.editor.getText().trim()) { this.ctx.editor.setDraft(result.editorText, result.editorImages); @@ -505,7 +505,7 @@ export class ExtensionUiController { if (!result) { return { cancelled: true }; } - this.ctx.renderInitialMessages({ clearTerminalHistory: true }); + await this.ctx.renderInitialMessages({ clearTerminalHistory: true }); await this.ctx.reloadTodos(); return { cancelled: false }; }, diff --git a/packages/coding-agent/src/modes/controllers/selector-controller.ts b/packages/coding-agent/src/modes/controllers/selector-controller.ts index ff68a4979..4f10b8874 100644 --- a/packages/coding-agent/src/modes/controllers/selector-controller.ts +++ b/packages/coding-agent/src/modes/controllers/selector-controller.ts @@ -1150,7 +1150,7 @@ export class SelectorController { return; } - this.ctx.renderInitialMessages({ clearTerminalHistory: true }); + await this.ctx.renderInitialMessages({ clearTerminalHistory: true }); this.ctx.editor.setDraft(result.selectedText, result.selectedImages); done(); this.ctx.showStatus("Branched to new session"); @@ -1323,7 +1323,7 @@ export class SelectorController { // Update UI — rebuild the display transcript for the new leaf (the // context from navigateTree is the LLM context, not the transcript). - this.ctx.renderInitialMessages({ clearTerminalHistory: true }); + await this.ctx.renderInitialMessages({ clearTerminalHistory: true }); await this.ctx.reloadTodos(); if (result.editorText && !this.ctx.editor.getText().trim()) { this.ctx.editor.setDraft(result.editorText, result.editorImages); @@ -1563,7 +1563,7 @@ export class SelectorController { this.ctx.statusLine.resetActiveTime(); this.ctx.ui.requestRender(); this.ctx.updateEditorBorderColor(); - this.ctx.renderInitialMessages({ clearTerminalHistory: true }); + await this.ctx.renderInitialMessages({ clearTerminalHistory: true }); await this.ctx.reloadTodos(); this.ctx.ui.requestRender(true, { clearScrollback: true }); return true; @@ -1597,7 +1597,7 @@ export class SelectorController { this.ctx.updateEditorBorderColor(); // Clear and re-render the chat - this.ctx.renderInitialMessages({ clearTerminalHistory: true }); + await this.ctx.renderInitialMessages({ clearTerminalHistory: true }); await this.ctx.reloadTodos(); this.ctx.showStatus(movedProject ? `Resumed session in ${shortenPath(newCwd)}` : "Resumed session"); return true; diff --git a/packages/coding-agent/src/modes/controllers/session-focus-controller.ts b/packages/coding-agent/src/modes/controllers/session-focus-controller.ts index 8a9ebb19f..0999e7544 100644 --- a/packages/coding-agent/src/modes/controllers/session-focus-controller.ts +++ b/packages/coding-agent/src/modes/controllers/session-focus-controller.ts @@ -104,7 +104,7 @@ export class SessionFocusController { await this.ctx.eventController.handleEvent(event); }); this.ctx.statusLine.setSession(target, this.#focusedAgentId); - this.ctx.renderInitialMessages({ clearTerminalHistory: true }); + await this.ctx.renderInitialMessages({ clearTerminalHistory: true }); // Sync the run-state title to the attached target: a streaming target has no // agent_start incoming, so arm the loader/working title manually; an idle // target would otherwise inherit the previous session's stuck spinner, so diff --git a/packages/coding-agent/src/modes/interactive-mode.ts b/packages/coding-agent/src/modes/interactive-mode.ts index 72fd2d5f5..e55c974b8 100644 --- a/packages/coding-agent/src/modes/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive-mode.ts @@ -4427,8 +4427,23 @@ export class InteractiveMode implements InteractiveModeContext { this.#uiHelpers.renderSessionContext(sessionContext, options); } - renderInitialMessages(options?: { preserveExistingChat?: boolean; clearTerminalHistory?: boolean }): void { - this.#uiHelpers.renderInitialMessages(options); + /** Render a session context in bounded chunks so terminal input runs between transcript paints. */ + async renderSessionContextIncrementally( + sessionContext: SessionContext, + options: RenderSessionContextOptions, + renderChunk?: () => void, + ): Promise { + for (const message of sessionContext.messages) { + this.noteDisplayableThinkingContent(message); + } + await this.#uiHelpers.renderSessionContextIncrementally(sessionContext, options, renderChunk); + } + + async renderInitialMessages(options?: { + preserveExistingChat?: boolean; + clearTerminalHistory?: boolean; + }): Promise { + await this.#uiHelpers.renderInitialMessages(options); } getUserMessageText(message: Message): string { @@ -4890,7 +4905,7 @@ export class InteractiveMode implements InteractiveModeContext { } this.#btwController.dispose(); this.#omfgController.dispose(); - this.renderInitialMessages({ clearTerminalHistory: true }); + await this.renderInitialMessages({ clearTerminalHistory: true }); this.updateEditorBorderColor(); this.showStatus( result.sessionFile ? `Branched /btw to ${path.basename(result.sessionFile)}` : "Branched /btw", diff --git a/packages/coding-agent/src/modes/types.ts b/packages/coding-agent/src/modes/types.ts index b65951262..c1ef556f7 100644 --- a/packages/coding-agent/src/modes/types.ts +++ b/packages/coding-agent/src/modes/types.ts @@ -321,7 +321,13 @@ export interface InteractiveModeContext { }, ): Component[]; renderSessionContext(sessionContext: SessionContext, options?: RenderSessionContextOptions): void; - renderInitialMessages(options?: { preserveExistingChat?: boolean; clearTerminalHistory?: boolean }): void; + /** Render a session context in bounded chunks so terminal input runs between transcript paints. */ + renderSessionContextIncrementally( + sessionContext: SessionContext, + options: RenderSessionContextOptions, + renderChunk?: () => void, + ): Promise; + renderInitialMessages(options?: { preserveExistingChat?: boolean; clearTerminalHistory?: boolean }): Promise; getUserMessageText(message: Message): string; findLastAssistantMessage(): AssistantMessage | undefined; extractAssistantText(message: AssistantMessage): string; diff --git a/packages/coding-agent/src/modes/utils/ui-helpers.ts b/packages/coding-agent/src/modes/utils/ui-helpers.ts index 346b52d16..066650fef 100644 --- a/packages/coding-agent/src/modes/utils/ui-helpers.ts +++ b/packages/coding-agent/src/modes/utils/ui-helpers.ts @@ -68,6 +68,15 @@ interface RenderInitialMessagesOptions { clearTerminalHistory?: boolean; } +const TRANSCRIPT_RENDER_CHUNK_MESSAGES = 32; +const TRANSCRIPT_RENDER_CHUNK_MS = 8; + +function waitForImmediate(): Promise { + const { promise, resolve } = Promise.withResolvers(); + setImmediate(resolve); + return promise; +} + type QueuedMessages = { steering: string[]; followUp: string[]; @@ -299,6 +308,38 @@ export class UiHelpers { * @param options.populateHistory Add user messages to editor history */ renderSessionContext(sessionContext: SessionContext, options: RenderSessionContextOptions = {}): void { + const steps = this.#renderSessionContextSteps(sessionContext, options); + while (!steps.next().done) {} + } + + /** Render a session context in bounded chunks so terminal input runs between transcript paints. */ + async renderSessionContextIncrementally( + sessionContext: SessionContext, + options: RenderSessionContextOptions, + renderChunk?: () => void, + ): Promise { + const steps = this.#renderSessionContextSteps(sessionContext, options); + let messagesSinceYield = 0; + let chunkStartedAt = performance.now(); + while (!steps.next().done) { + messagesSinceYield++; + if ( + messagesSinceYield < TRANSCRIPT_RENDER_CHUNK_MESSAGES && + performance.now() - chunkStartedAt < TRANSCRIPT_RENDER_CHUNK_MS + ) { + continue; + } + renderChunk?.(); + await waitForImmediate(); + messagesSinceYield = 0; + chunkStartedAt = performance.now(); + } + } + + *#renderSessionContextSteps( + sessionContext: SessionContext, + options: RenderSessionContextOptions = {}, + ): Generator { // Preserved: message_start handler owns this lifecycle (see #783) this.ctx.pendingTools.clear(); // Reseed the cache-invalidation baseline: this rebuild re-derives every @@ -623,6 +664,7 @@ export class UiHelpers { // All other messages use standard rendering this.ctx.addMessageToChat(message, options); } + yield; } flushPendingUsage(); @@ -670,7 +712,7 @@ export class UiHelpers { this.ctx.ui.requestRender(); } - renderInitialMessages(options: RenderInitialMessagesOptions = {}): void { + async renderInitialMessages(options: RenderInitialMessagesOptions = {}): Promise { // 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 @@ -693,14 +735,30 @@ 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; const context = this.ctx.viewSession.buildTranscriptSessionContext({ collapseCompactedHistory: settings.get("display.collapseCompacted"), keepDanglingToolCalls: this.ctx.viewSession.isStreaming, }); - this.ctx.renderSessionContext(context, { + const renderOptions = { updateFooter: true, populateHistory: !this.ctx.focusedAgentId, - }); + }; + if (this.ctx.viewSession.isStreaming) { + // 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); + } // Show compaction info if session was compacted const allEntries = this.ctx.viewSession.sessionManager.getEntries(); @@ -715,7 +773,7 @@ export class UiHelpers { this.ctx.showStatus(`Session compacted ${times}`); } if (options.clearTerminalHistory) { - this.ctx.ui.requestRender(true, { clearScrollback: true }); + this.ctx.ui.requestRender(true, { clearScrollback: !terminalHistoryCleared }); } if (preservedChatChildren && preservedChatChildren.length > 0) { for (const child of preservedChatChildren) { diff --git a/packages/coding-agent/test/interactive-mode-status.test.ts b/packages/coding-agent/test/interactive-mode-status.test.ts index 69b504bd9..aa8430db5 100644 --- a/packages/coding-agent/test/interactive-mode-status.test.ts +++ b/packages/coding-agent/test/interactive-mode-status.test.ts @@ -2,7 +2,7 @@ import { beforeAll, describe, expect, test, vi } from "bun:test"; import type { AgentMessage } from "@oh-my-pi/pi-agent-core"; import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; 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 type { InteractiveModeContext, RenderSessionContextOptions } from "@oh-my-pi/pi-coding-agent/modes/types"; import { UiHelpers } from "@oh-my-pi/pi-coding-agent/modes/utils/ui-helpers"; import { buildSessionContext, type SessionContext } from "@oh-my-pi/pi-coding-agent/session/session-context"; import { type Component, Container } from "@oh-my-pi/pi-tui"; @@ -38,10 +38,13 @@ function createInitialRenderHarness(): { ctx: InteractiveModeContext; helpers: U }, statusLine: { invalidate: vi.fn() }, updateEditorBorderColor: vi.fn(), - renderSessionContext: ( + renderSessionContext: (context: SessionContext, options?: RenderSessionContextOptions) => + helpers.renderSessionContext(context, options), + renderSessionContextIncrementally: ( context: SessionContext, - options?: { updateFooter?: boolean; populateHistory?: boolean }, - ) => helpers.renderSessionContext(context, options), + options: RenderSessionContextOptions, + renderChunk?: () => void, + ) => helpers.renderSessionContextIncrementally(context, options, renderChunk), addMessageToChat: (message: AgentMessage) => helpers.addMessageToChat(message), settings: { get: () => false }, session: { @@ -131,7 +134,7 @@ describe("InteractiveMode.showStatus", () => { const { ctx, helpers } = createInitialRenderHarness(); helpers.showWarning("startup notification probe"); - helpers.renderInitialMessages({ preserveExistingChat: true }); + await helpers.renderInitialMessages({ preserveExistingChat: true }); expect(renderContainer(ctx.chatContainer)).toContain("startup notification probe"); } finally { diff --git a/packages/coding-agent/test/issue-4348-repro.test.ts b/packages/coding-agent/test/issue-4348-repro.test.ts index 5f6e9061f..2d76e7c6a 100644 --- a/packages/coding-agent/test/issue-4348-repro.test.ts +++ b/packages/coding-agent/test/issue-4348-repro.test.ts @@ -22,7 +22,7 @@ import type { AgentMessage } from "@oh-my-pi/pi-agent-core"; import type { AssistantMessage, Usage } from "@oh-my-pi/pi-ai"; import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; 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 type { InteractiveModeContext, RenderSessionContextOptions } from "@oh-my-pi/pi-coding-agent/modes/types"; import { UiHelpers } from "@oh-my-pi/pi-coding-agent/modes/utils/ui-helpers"; import type { SessionContext } from "@oh-my-pi/pi-coding-agent/session/session-context"; import { Container } from "@oh-my-pi/pi-tui"; @@ -93,10 +93,13 @@ function makeRenderCtx(transcript: SessionContext): { ctx: InteractiveModeContex }, addMessageToChat: (message: AgentMessage, options?: { populateHistory?: boolean }) => helpers.addMessageToChat(message, options), - renderSessionContext: ( + renderSessionContext: (context: SessionContext, options?: RenderSessionContextOptions) => + helpers.renderSessionContext(context, options), + renderSessionContextIncrementally: ( context: SessionContext, - options?: { updateFooter?: boolean; populateHistory?: boolean }, - ) => helpers.renderSessionContext(context, options), + options: RenderSessionContextOptions, + renderChunk?: () => void, + ) => helpers.renderSessionContextIncrementally(context, options, renderChunk), showStatus: vi.fn(), } as unknown as InteractiveModeContext; helpers = new UiHelpers(ctx); @@ -155,7 +158,7 @@ describe("issue #4348: cursor exec-channel tool results pair with synthesized to const transcript = transcriptWith(cursorTurn()); const { ctx, chatContainer } = makeRenderCtx(transcript); - new UiHelpers(ctx).renderInitialMessages(); + await new UiHelpers(ctx).renderInitialMessages(); // Component structure: an assistant message, then a bash // ToolExecutionComponent for the synthesized bash block, then a @@ -205,7 +208,7 @@ describe("issue #4348: cursor exec-channel tool results pair with synthesized to ]); const { ctx, chatContainer } = makeRenderCtx(transcript); - new UiHelpers(ctx).renderInitialMessages(); + await new UiHelpers(ctx).renderInitialMessages(); const rendered = Bun.stripANSI(chatContainer.render(120).join("\n")); expect(rendered).toContain("Running command:"); diff --git a/packages/coding-agent/test/modes/utils/render-initial-messages.test.ts b/packages/coding-agent/test/modes/utils/render-initial-messages.test.ts index f96a7ebe2..0028a25fd 100644 --- a/packages/coding-agent/test/modes/utils/render-initial-messages.test.ts +++ b/packages/coding-agent/test/modes/utils/render-initial-messages.test.ts @@ -13,13 +13,13 @@ import { afterEach, beforeAll, beforeEach, describe, expect, it, type Mock, vi } from "bun:test"; import type { AgentMessage } from "@oh-my-pi/pi-agent-core"; -import type { AssistantMessage, ImageContent, Usage } from "@oh-my-pi/pi-ai"; +import type { AssistantMessage, ImageContent, Message, Usage } from "@oh-my-pi/pi-ai"; import { kStreamingPartialJson } from "@oh-my-pi/pi-ai/utils/block-symbols"; import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { AssistantMessageComponent } from "@oh-my-pi/pi-coding-agent/modes/components/assistant-message"; import { StrippedToolCallsPlaceholder } from "@oh-my-pi/pi-coding-agent/modes/components/stripped-tool-calls-placeholder"; 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 type { InteractiveModeContext, RenderSessionContextOptions } from "@oh-my-pi/pi-coding-agent/modes/types"; import { UiHelpers } from "@oh-my-pi/pi-coding-agent/modes/utils/ui-helpers"; import type { SessionContext, StrippedToolCallsMarker } from "@oh-my-pi/pi-coding-agent/session/session-context"; import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager"; @@ -61,11 +61,11 @@ function makeCtx(): { ctx: InteractiveModeContext; transcriptSpy: Mock<(options?: { collapseCompactedHistory?: boolean }) => SessionContext>; llmContextSpy: Mock<() => SessionContext>; - renderSessionContextSpy: Mock<(...args: unknown[]) => void>; + renderSessionContextSpy: Mock<(...args: unknown[]) => Promise>; } { const transcriptSpy = vi.fn(() => makeEmptyContext()); const llmContextSpy = vi.fn(() => makeEmptyContext()); - const renderSessionContextSpy = vi.fn(); + const renderSessionContextSpy = vi.fn(async () => {}); const ctx = { chatContainer: { clear: vi.fn(), addChild: vi.fn() }, @@ -87,7 +87,7 @@ function makeCtx(): { getEntries: vi.fn(() => []), getCwd: vi.fn(() => "/tmp"), }, - renderSessionContext: renderSessionContextSpy, + renderSessionContextIncrementally: renderSessionContextSpy, showStatus: vi.fn(), ui: { requestRender: vi.fn() }, resetTranscript: () => ctx.chatContainer.clear(), @@ -179,6 +179,12 @@ function makeRenderCtx( sessionManager: { getEntries: vi.fn(() => []), getCwd: vi.fn(() => "/tmp"), + putBlobSync: vi.fn(() => ({ + hash: "hash", + path: "/tmp/hash", + displayPath: "/tmp/hash.png", + ref: "blob:sha256:hash", + })), }, }, sessionManager: { @@ -193,10 +199,14 @@ function makeRenderCtx( }, addMessageToChat: (message: AgentMessage, options?: { populateHistory?: boolean }) => helpers.addMessageToChat(message, options), - renderSessionContext: ( + getUserMessageText: (message: Message) => helpers.getUserMessageText(message), + renderSessionContext: (context: SessionContext, options?: RenderSessionContextOptions) => + helpers.renderSessionContext(context, options), + renderSessionContextIncrementally: ( context: SessionContext, - options?: { updateFooter?: boolean; populateHistory?: boolean }, - ) => helpers.renderSessionContext(context, options), + options: RenderSessionContextOptions, + renderChunk?: () => void, + ) => helpers.renderSessionContextIncrementally(context, options, renderChunk), showStatus: vi.fn(), } as unknown as InteractiveModeContext; helpers = new UiHelpers(ctx); @@ -210,7 +220,7 @@ describe("UiHelpers.renderInitialMessages — transcript source", () => { const transcript = makeEmptyContext(); transcriptSpy.mockReturnValue(transcript); - new UiHelpers(ctx).renderInitialMessages(); + await new UiHelpers(ctx).renderInitialMessages(); expect(transcriptSpy).toHaveBeenCalledWith({ collapseCompactedHistory: true }); expect(llmContextSpy).not.toHaveBeenCalled(); @@ -225,14 +235,14 @@ describe("UiHelpers.renderInitialMessages — clearTerminalHistory", () => { it("requests a scrollback-clearing repaint when clearTerminalHistory is set", async () => { await Settings.init({ inMemory: true }); const { ctx } = makeCtx(); - new UiHelpers(ctx).renderInitialMessages({ clearTerminalHistory: true }); + await new UiHelpers(ctx).renderInitialMessages({ clearTerminalHistory: true }); expect(ctx.ui.requestRender).toHaveBeenCalledWith(true, { clearScrollback: true }); }); it("never clears scrollback when clearTerminalHistory is unset", async () => { await Settings.init({ inMemory: true }); const { ctx } = makeCtx(); - new UiHelpers(ctx).renderInitialMessages(); + await new UiHelpers(ctx).renderInitialMessages(); const clearedCall = (ctx.ui.requestRender as Mock<(...a: unknown[]) => void>).mock.calls.find( ([force, opts]) => force === true && (opts as { clearScrollback?: boolean } | undefined)?.clearScrollback, ); @@ -240,6 +250,41 @@ describe("UiHelpers.renderInitialMessages — clearTerminalHistory", () => { }); }); +describe("UiHelpers.renderInitialMessages — responsiveness", () => { + it("yields to a macrotask while rebuilding a large transcript", async () => { + vi.useFakeTimers(); + try { + const messages: AgentMessage[] = []; + for (let index = 0; index < 256; index++) { + messages.push({ + role: "user", + content: `message ${index}`, + timestamp: index, + }); + } + const { ctx } = makeRenderCtx(transcriptWith(messages)); + let complete = false; + let yieldedBeforeComplete = false; + setTimeout(() => { + if (!complete) yieldedBeforeComplete = true; + }, 0); + + const render = new UiHelpers(ctx).renderInitialMessages({ clearTerminalHistory: true }).finally(() => { + complete = true; + }); + for (let index = 0; index < 1_000 && !complete; index++) { + vi.runOnlyPendingTimers(); + await Promise.resolve(); + } + await render; + + expect(yieldedBeforeComplete).toBe(true); + } finally { + vi.useRealTimers(); + } + }); +}); + describe("UiHelpers.renderInitialMessages — image replay", () => { it("restores read tool image blocks onto the rebuilt assistant transcript", async () => { await Settings.init({ inMemory: true, overrides: { "terminal.showImages": true } }); @@ -257,7 +302,7 @@ describe("UiHelpers.renderInitialMessages — image replay", () => { ]); const { ctx, chatContainer } = makeRenderCtx(transcript); - new UiHelpers(ctx).renderInitialMessages(); + await new UiHelpers(ctx).renderInitialMessages(); expect(hasImageComponent(chatContainer)).toBe(true); expect(Bun.stripANSI(chatContainer.render(100).join("\n"))).toContain("Read sample.png"); @@ -284,7 +329,7 @@ describe("UiHelpers.renderInitialMessages — image replay", () => { const { ctx, chatContainer } = makeRenderCtx(transcript); - new UiHelpers(ctx).renderInitialMessages(); + await new UiHelpers(ctx).renderInitialMessages(); expect(hasImageComponent(chatContainer)).toBe(true); expect(Bun.stripANSI(chatContainer.render(100).join("\n"))).toContain("display image 1: 1x1"); @@ -306,7 +351,7 @@ describe("UiHelpers.renderInitialMessages — image replay", () => { ]); const { ctx, chatContainer } = makeRenderCtx(transcript, false); - new UiHelpers(ctx).renderInitialMessages(); + await new UiHelpers(ctx).renderInitialMessages(); expect(hasImageComponent(chatContainer)).toBe(false); const assistant = chatContainer.children.find( @@ -333,7 +378,7 @@ describe("UiHelpers.renderInitialMessages — image replay", () => { ]); const { ctx, chatContainer } = makeRenderCtx(transcript, true, true); - new UiHelpers(ctx).renderInitialMessages(); + await new UiHelpers(ctx).renderInitialMessages(); expect(hasImageComponent(chatContainer)).toBe(false); const assistant = chatContainer.children.find( @@ -378,7 +423,7 @@ describe("UiHelpers.renderInitialMessages — image replay", () => { const transcript = reloaded.buildSessionContext({ transcript: true }); const { ctx, chatContainer } = makeRenderCtx(transcript); - new UiHelpers(ctx).renderInitialMessages({ clearTerminalHistory: true }); + await new UiHelpers(ctx).renderInitialMessages({ clearTerminalHistory: true }); expect(countImageComponents(chatContainer)).toBe(2); expect(Bun.stripANSI(chatContainer.render(100).join("\n"))).toContain("Read reopened.png"); @@ -387,7 +432,7 @@ describe("UiHelpers.renderInitialMessages — image replay", () => { }); describe("UiHelpers.renderInitialMessages — hidden tool activity", () => { - it("hides replayed tool cards without discarding them from the persisted transcript", () => { + it("hides replayed tool cards without discarding them from the persisted transcript", async () => { const toolCallId = "replayed-hidden-tool"; const toolArgumentMarker = "REPLAYED TOOL ARGUMENT MARKER"; const toolResultMarker = "REPLAYED TOOL RESULT MARKER"; @@ -422,7 +467,7 @@ describe("UiHelpers.renderInitialMessages — hidden tool activity", () => { ]); const hidden = makeRenderCtx(transcript, true, true); - new UiHelpers(hidden.ctx).renderInitialMessages(); + await new UiHelpers(hidden.ctx).renderInitialMessages(); const hiddenRender = Bun.stripANSI(hidden.chatContainer.render(120).join("\n")); expect(hiddenRender).toContain(narrationMarker); expect(hiddenRender).toContain(finalMarker); @@ -430,13 +475,13 @@ describe("UiHelpers.renderInitialMessages — hidden tool activity", () => { expect(hiddenRender).not.toContain(toolResultMarker); const visible = makeRenderCtx(transcript, true, false); - new UiHelpers(visible.ctx).renderInitialMessages(); + await new UiHelpers(visible.ctx).renderInitialMessages(); const visibleRender = Bun.stripANSI(visible.chatContainer.render(120).join("\n")); expect(visibleRender).toContain(toolArgumentMarker); expect(visibleRender).toContain(toolResultMarker); }); - it("hides the stripped-tool-calls placeholder with tool activity and restores it on reveal", () => { + it("hides the stripped-tool-calls placeholder with tool activity and restores it on reveal", async () => { const strippedAssistant: AgentMessage & StrippedToolCallsMarker = { role: "assistant", content: [{ type: "text", text: "narration" }], @@ -451,7 +496,7 @@ describe("UiHelpers.renderInitialMessages — hidden tool activity", () => { const transcript = transcriptWith([strippedAssistant]); const hidden = makeRenderCtx(transcript, true, true); - new UiHelpers(hidden.ctx).renderInitialMessages(); + await new UiHelpers(hidden.ctx).renderInitialMessages(); expect(Bun.stripANSI(hidden.chatContainer.render(120).join("\n"))).not.toContain( "elided — no result on this branch", ); @@ -499,7 +544,7 @@ describe("UiHelpers.renderSessionContext — error-stop tool calls", () => { ]); const { ctx, chatContainer } = makeRenderCtx(transcript); - new UiHelpers(ctx).renderInitialMessages(); + await new UiHelpers(ctx).renderInitialMessages(); const rendered = Bun.stripANSI(chatContainer.render(120).join("\n")); expect(rendered).toContain("synthetic assistant stop error"); @@ -541,7 +586,7 @@ describe("UiHelpers.renderSessionContext — mid-stream tool call rebuild", () = ]); const { ctx, chatContainer } = makeRenderCtx(transcript); - new UiHelpers(ctx).renderInitialMessages(); + await new UiHelpers(ctx).renderInitialMessages(); const rendered = Bun.stripANSI(chatContainer.render(120).join("\n")); expect(rendered).toContain("GROWN_TAIL_SENTINEL"); diff --git a/packages/coding-agent/test/repro-issue-1955-sendmessage-double-render.test.ts b/packages/coding-agent/test/repro-issue-1955-sendmessage-double-render.test.ts index db19625cc..729002b95 100644 --- a/packages/coding-agent/test/repro-issue-1955-sendmessage-double-render.test.ts +++ b/packages/coding-agent/test/repro-issue-1955-sendmessage-double-render.test.ts @@ -10,7 +10,7 @@ import type { } from "@oh-my-pi/pi-coding-agent/extensibility/extensions"; import { ExtensionUiController } from "@oh-my-pi/pi-coding-agent/modes/controllers/extension-ui-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 type { InteractiveModeContext, RenderSessionContextOptions } from "@oh-my-pi/pi-coding-agent/modes/types"; import { UiHelpers } from "@oh-my-pi/pi-coding-agent/modes/utils/ui-helpers"; import { buildSessionContext, type SessionContext } from "@oh-my-pi/pi-coding-agent/session/session-context"; import type { CustomMessageEntry, SessionEntry } from "@oh-my-pi/pi-coding-agent/session/session-entries"; @@ -144,8 +144,13 @@ function createHarness(): Harness { handleInput: vi.fn(), getText: () => "", }, - renderSessionContext: (c: SessionContext, o?: { updateFooter?: boolean; populateHistory?: boolean }) => - helpers.renderSessionContext(c, o), + renderSessionContext: (context: SessionContext, options?: RenderSessionContextOptions) => + helpers.renderSessionContext(context, options), + renderSessionContextIncrementally: ( + context: SessionContext, + options: RenderSessionContextOptions, + renderChunk?: () => void, + ) => helpers.renderSessionContextIncrementally(context, options, renderChunk), addMessageToChat: (m: AgentMessage) => helpers.addMessageToChat(m), rebuildChatFromMessages: () => { ctx.chatContainer.clear(); @@ -212,7 +217,7 @@ describe("issue #1955 — sendMessage(display:true) during session_start", () => // Mirror main.ts: after `mode.init()` returns, the host renders the // initial transcript while preserving anything previously added to chat. - harness.helpers.renderInitialMessages({ preserveExistingChat: true }); + await harness.helpers.renderInitialMessages({ preserveExistingChat: true }); const rendered = Bun.stripANSI(harness.ctx.chatContainer.render(120).join("\n")); const occurrences = countOccurrences(rendered, marker); @@ -226,7 +231,7 @@ describe("issue #1955 — sendMessage(display:true) during session_start", () => // Establish the initial render — the host's `renderInitialMessages` // flips `initialChatRendered` so subsequent extension sends can rebuild. - harness.helpers.renderInitialMessages({ preserveExistingChat: true }); + await harness.helpers.renderInitialMessages({ preserveExistingChat: true }); const actions = harness.getActions(); actions!.sendMessage( From b606e7a17518fd2ce58f18263f1b8d8dd71886a5 Mon Sep 17 00:00:00 2001 From: roboomp Date: Mon, 10 Aug 2026 05:41:48 +0000 Subject: [PATCH 2/3] fix(tui): yield before each replayed message Moved the per-message rebuild yield to the top of the loop so it runs regardless of which early continue path (preserved-live, image-only, or grouped read result) an iteration takes. A large parallel read-result batch replays entirely through the isReadGroupResult continue, which previously skipped the trailing yield and rebuilt the whole batch in one event-loop turn. Fixes #8133 --- .../src/modes/utils/ui-helpers.ts | 9 +- .../utils/render-initial-messages.test.ts | 91 +++++++++++++------ 2 files changed, 71 insertions(+), 29 deletions(-) diff --git a/packages/coding-agent/src/modes/utils/ui-helpers.ts b/packages/coding-agent/src/modes/utils/ui-helpers.ts index 066650fef..e27f050a0 100644 --- a/packages/coding-agent/src/modes/utils/ui-helpers.ts +++ b/packages/coding-agent/src/modes/utils/ui-helpers.ts @@ -430,6 +430,14 @@ export class UiHelpers { const messages = sessionContext.messages; const count = messages.length; for (let i = 0; i < count; i++) { + // Yield BEFORE each message (except the first) rather than after: the + // per-message body has several early `continue` paths (preserved live + // results, image-only and grouped `read` results), and a trailing yield + // is skipped by all of them. A large parallel-read batch is entirely + // such results, so an after-body yield never trips the chunk counter and + // the whole batch replays in one event-loop turn. Yielding at the top of + // the next iteration is reached no matter how the prior message exited. + if (i > 0) yield; const message = messages[i]!; if (message.role !== "toolResult") flushPendingUsage(); // Assistant messages need special handling for tool calls @@ -664,7 +672,6 @@ export class UiHelpers { // All other messages use standard rendering this.ctx.addMessageToChat(message, options); } - yield; } flushPendingUsage(); diff --git a/packages/coding-agent/test/modes/utils/render-initial-messages.test.ts b/packages/coding-agent/test/modes/utils/render-initial-messages.test.ts index 0028a25fd..d1e03848a 100644 --- a/packages/coding-agent/test/modes/utils/render-initial-messages.test.ts +++ b/packages/coding-agent/test/modes/utils/render-initial-messages.test.ts @@ -251,37 +251,72 @@ describe("UiHelpers.renderInitialMessages — clearTerminalHistory", () => { }); describe("UiHelpers.renderInitialMessages — responsiveness", () => { - it("yields to a macrotask while rebuilding a large transcript", async () => { - vi.useFakeTimers(); - try { - const messages: AgentMessage[] = []; - for (let index = 0; index < 256; index++) { - messages.push({ - role: "user", - content: `message ${index}`, - timestamp: index, - }); - } - const { ctx } = makeRenderCtx(transcriptWith(messages)); - let complete = false; - let yieldedBeforeComplete = false; - setTimeout(() => { - if (!complete) yieldedBeforeComplete = true; - }, 0); + // Count the chunk boundaries an idle rebuild produces: each boundary calls + // `renderChunk` and then awaits a macrotask, so a positive count proves the + // rebuild handed control back to the event loop mid-replay instead of + // running as one uninterruptible turn. Drives `renderSessionContextIncrementally` + // directly (the layer that owns the chunk counter) so the assertion is + // deterministic and never races a timer. + async function countRebuildChunks(messages: AgentMessage[]): Promise { + const transcript = transcriptWith(messages); + const { ctx } = makeRenderCtx(transcript); + let chunks = 0; + await new UiHelpers(ctx).renderSessionContextIncrementally( + transcript, + { updateFooter: true, populateHistory: true }, + () => { + chunks++; + }, + ); + return chunks; + } - const render = new UiHelpers(ctx).renderInitialMessages({ clearTerminalHistory: true }).finally(() => { - complete = true; + it("splits a large plain transcript rebuild across event-loop turns", async () => { + await Settings.init({ inMemory: true }); + const messages: AgentMessage[] = Array.from({ length: 256 }, (_, index) => ({ + role: "user", + content: `message ${index}`, + timestamp: index, + })); + expect(await countRebuildChunks(messages)).toBeGreaterThan(0); + }); + + 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 + // `continue`. A trailing per-message yield is skipped by every one of + // those results, so the whole batch would rebuild in one uninterruptible + // event-loop turn and the chunk counter would never trip (zero chunks). + // The top-of-loop yield must still hand control back between results. + await Settings.init({ inMemory: true }); + const readCalls = Array.from({ length: 128 }, (_, index) => ({ + type: "toolCall" as const, + id: `read-${index}`, + name: "read", + arguments: { path: `src/file-${index}.ts` }, + })); + const assistant: AssistantMessage = { + role: "assistant", + content: readCalls, + api: "anthropic-messages", + provider: "anthropic", + model: "claude-sonnet", + usage: emptyUsage, + stopReason: "toolUse", + timestamp: 1, + }; + const messages: AgentMessage[] = [assistant]; + for (let index = 0; index < 128; index++) { + messages.push({ + role: "toolResult", + toolCallId: `read-${index}`, + toolName: "read", + content: [{ type: "text", text: `contents ${index}` }], + isError: false, + timestamp: index + 2, }); - for (let index = 0; index < 1_000 && !complete; index++) { - vi.runOnlyPendingTimers(); - await Promise.resolve(); - } - await render; - - expect(yieldedBeforeComplete).toBe(true); - } finally { - vi.useRealTimers(); } + expect(await countRebuildChunks(messages)).toBeGreaterThan(0); }); }); From 492cf010f1c4a1a3831575ca4e3a6e4389e18fa9 Mon Sep 17 00:00:00 2001 From: roboomp Date: Mon, 10 Aug 2026 05:48:57 +0000 Subject: [PATCH 3/3] fix(tui): serialized initial transcript replay Kept initialChatRendered false until the incremental cold-start replay settles, preventing extension display callbacks from launching a synchronous rebuild into a partially replayed transcript. If session entries arrive while the replay yields, discard the stale partial tree and replay the current context before publishing the initialized state. Fixes #8133 --- .../src/modes/utils/ui-helpers.ts | 53 +++++++++++++++---- .../utils/render-initial-messages.test.ts | 2 +- ...sue-1955-sendmessage-double-render.test.ts | 36 +++++++++++++ 3 files changed, 79 insertions(+), 12 deletions(-) diff --git a/packages/coding-agent/src/modes/utils/ui-helpers.ts b/packages/coding-agent/src/modes/utils/ui-helpers.ts index e27f050a0..5201e91f3 100644 --- a/packages/coding-agent/src/modes/utils/ui-helpers.ts +++ b/packages/coding-agent/src/modes/utils/ui-helpers.ts @@ -726,7 +726,7 @@ export class UiHelpers { // 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; - this.ctx.initialChatRendered = true; + const isInitialReplay = !this.ctx.initialChatRendered; if (preservedChatChildren) { this.ctx.chatContainer.clear(); } else { @@ -749,22 +749,52 @@ export class UiHelpers { terminalHistoryCleared = true; } : undefined; - const context = this.ctx.viewSession.buildTranscriptSessionContext({ + let context = this.ctx.viewSession.buildTranscriptSessionContext({ collapseCompactedHistory: settings.get("display.collapseCompacted"), keepDanglingToolCalls: this.ctx.viewSession.isStreaming, }); + let replayEntryCount = this.ctx.viewSession.sessionManager.getEntries().length; const renderOptions = { updateFooter: true, - populateHistory: !this.ctx.focusedAgentId, + // A dirty initial replay may restart from a newer context. Populate + // history once from the stable context below instead of duplicating it + // on every attempt. + populateHistory: !this.ctx.focusedAgentId && !isInitialReplay, }; - if (this.ctx.viewSession.isStreaming) { - // 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); + while (true) { + if (this.ctx.viewSession.isStreaming) { + // 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); + } + if (!isInitialReplay || this.ctx.viewSession.sessionManager.getEntries().length === replayEntryCount) { + break; + } + + // An extension persisted a display message while the initial replay + // 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(); + 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; + } + if (isInitialReplay && !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 @@ -788,6 +818,7 @@ export class UiHelpers { } this.ctx.ui.requestRender(); } + this.ctx.initialChatRendered = true; } clearEditor(): void { diff --git a/packages/coding-agent/test/modes/utils/render-initial-messages.test.ts b/packages/coding-agent/test/modes/utils/render-initial-messages.test.ts index d1e03848a..9fc972c89 100644 --- a/packages/coding-agent/test/modes/utils/render-initial-messages.test.ts +++ b/packages/coding-agent/test/modes/utils/render-initial-messages.test.ts @@ -226,7 +226,7 @@ describe("UiHelpers.renderInitialMessages — transcript source", () => { expect(llmContextSpy).not.toHaveBeenCalled(); expect(renderSessionContextSpy).toHaveBeenCalledWith(transcript, { updateFooter: true, - populateHistory: true, + populateHistory: false, }); }); }); diff --git a/packages/coding-agent/test/repro-issue-1955-sendmessage-double-render.test.ts b/packages/coding-agent/test/repro-issue-1955-sendmessage-double-render.test.ts index 729002b95..f161c28e6 100644 --- a/packages/coding-agent/test/repro-issue-1955-sendmessage-double-render.test.ts +++ b/packages/coding-agent/test/repro-issue-1955-sendmessage-double-render.test.ts @@ -117,6 +117,7 @@ function createHarness(): Harness { transcriptMessageComponents: new WeakMap(), pendingTools: new Map(), ui: { requestRender: vi.fn() }, + resetTranscript: () => ctx.chatContainer.clear(), isBackgrounded: false, initialChatRendered: false, statusLine: { invalidate: vi.fn() }, @@ -248,4 +249,39 @@ describe("issue #1955 — sendMessage(display:true) during session_start", () => const rendered = Bun.stripANSI(harness.ctx.chatContainer.render(120).join("\n")); expect(countOccurrences(rendered, marker)).toBe(1); }); + + test("defers display rebuilds that arrive during the incremental initial replay", async () => { + const initialMarker = "INITIAL_ENTRY_127_END"; + const lateMarker = "LATE_EXTENSION_MESSAGE_END"; + const harness = createHarness(); + for (let index = 0; index < 128; index++) { + harness.entries.push( + makeCustomEntry(index + 1, `INITIAL_ENTRY_${index}_END`, index === 0 ? null : `entry-${index}`), + ); + } + await harness.controller.initHooksAndCustomTools(); + const actions = harness.getActions(); + expect(actions).toBeDefined(); + + const initialReplay = harness.helpers.renderInitialMessages({ + preserveExistingChat: true, + clearTerminalHistory: true, + }); + expect(harness.ctx.initialChatRendered).toBe(false); + actions!.sendMessage( + { + customType: "issue-1955-probe", + content: [{ type: "text", text: lateMarker }], + display: true, + attribution: "agent", + }, + { deliverAs: "nextTurn" }, + ); + await initialReplay; + + const rendered = Bun.stripANSI(harness.ctx.chatContainer.render(120).join("\n")); + expect(countOccurrences(rendered, initialMarker)).toBe(1); + expect(countOccurrences(rendered, lateMarker)).toBe(1); + expect(rendered.indexOf(initialMarker)).toBeLessThan(rendered.indexOf(lateMarker)); + }); });