diff --git a/packages/coding-agent/src/modes/interactive-mode.ts b/packages/coding-agent/src/modes/interactive-mode.ts index b167672bf..3a2c6cd4a 100644 --- a/packages/coding-agent/src/modes/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive-mode.ts @@ -1740,6 +1740,7 @@ export class InteractiveMode implements InteractiveModeContext { const context = this.viewSession.buildTranscriptSessionContext({ collapseCompactedHistory: settings.get("display.collapseCompacted"), }); + const preservedLiveToolCallIds = new Set(); // A preserved pending-tool component whose result has already landed in // the replayed transcript is re-rendered by `renderSessionContext` itself // (the toolResult message reconstructs the block with its output). Keeping @@ -1765,8 +1766,31 @@ export class InteractiveMode implements InteractiveModeContext { // terminal results are owned by the replay. (Cast mirrors the async // detail reads in tool-execution.ts / event-controller.ts.) const details = message.details as { async?: { state?: string } } | undefined; - if (details?.async?.state === "running") continue; + if (details?.async?.state === "running") { + preservedLiveToolCallIds.add(message.toolCallId); + continue; + } livePendingTools.delete(message.toolCallId); + // A `ReadToolGroupComponent` is shared by every read id it renders + // (ui-helpers sets the same group for each collapsed read call). While a + // sibling read id still points at it the component must stay on screen + // and preserved — splicing it here would detach the pending read's + // display and strand its future result on an off-screen component. + // Splice only once no remaining pending id shares it. + let stillShared = false; + for (const other of livePendingTools.values()) { + if (other === resolved) { + stillShared = true; + break; + } + } + if (stillShared) { + // The shared component still owns this completed member as well as + // its pending sibling. Suppress the replay copy so the group remains + // a single on-screen block while future results keep routing to it. + preservedLiveToolCallIds.add(message.toolCallId); + continue; + } const index = liveComponents.indexOf(resolved as unknown as Component); if (index >= 0) liveComponents.splice(index, 1); } @@ -1781,7 +1805,10 @@ export class InteractiveMode implements InteractiveModeContext { if (component) retained.set(message, component); } this.transcriptMessageComponents = retained; - this.renderSessionContext(context, { reuseSettledComponents: options.reuseSettledComponents }); + this.renderSessionContext(context, { + reuseSettledComponents: options.reuseSettledComponents, + preservedLiveToolCallIds, + }); for (const child of liveComponents) { this.chatContainer.addChild(child); } diff --git a/packages/coding-agent/src/modes/utils/ui-helpers.ts b/packages/coding-agent/src/modes/utils/ui-helpers.ts index 5cb931540..5ebecba94 100644 --- a/packages/coding-agent/src/modes/utils/ui-helpers.ts +++ b/packages/coding-agent/src/modes/utils/ui-helpers.ts @@ -76,6 +76,8 @@ type RenderSessionContextOptions = { updateFooter?: boolean; populateHistory?: boolean; reuseSettledComponents?: boolean; + /** Tool calls whose existing live component remains the sole render owner across a rebuild. */ + preservedLiveToolCallIds?: ReadonlySet; }; function imageLinksForMessage( @@ -422,8 +424,12 @@ export class UiHelpers { if (content.type !== "toolCall") { continue; } - resolveWaitingPoll(content.name); const afterToolSegment = timeline.afterToolCalls.get(content.id); + if (options.preservedLiveToolCallIds?.has(content.id)) { + appendAssistantSegment(afterToolSegment); + continue; + } + resolveWaitingPoll(content.name); if (content.name === "read" && readArgsCollapseIntoGroup(content.arguments)) { if (hasErrorStop && errorMessage) { @@ -538,6 +544,7 @@ export class UiHelpers { pendingUsageTtft = message.ttft; pendingUsageTimestamp = message.timestamp; } else if (message.role === "toolResult") { + if (options.preservedLiveToolCallIds?.has(message.toolCallId)) continue; const pendingReadComponent = this.ctx.pendingTools.get(message.toolCallId); const isReadGroupResult = message.toolName === "read" && diff --git a/packages/coding-agent/test/repro-issue-6516-tool-double-render.test.ts b/packages/coding-agent/test/repro-issue-6516-tool-double-render.test.ts index 0d3447ff1..4b2a12a2c 100644 --- a/packages/coding-agent/test/repro-issue-6516-tool-double-render.test.ts +++ b/packages/coding-agent/test/repro-issue-6516-tool-double-render.test.ts @@ -3,6 +3,7 @@ import * as path from "node:path"; import { Agent } from "@oh-my-pi/pi-agent-core"; import { ModelRegistry } from "@oh-my-pi/pi-coding-agent/config/model-registry"; import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; +import { ReadToolGroupComponent } from "@oh-my-pi/pi-coding-agent/modes/components/read-tool-group"; import { ToolExecutionComponent } from "@oh-my-pi/pi-coding-agent/modes/components/tool-execution"; import { InteractiveMode } from "@oh-my-pi/pi-coding-agent/modes/interactive-mode"; import { initTheme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme"; @@ -280,7 +281,78 @@ describe("issue #6516 — tool output appears twice", () => { mode.rebuildChatFromMessages(); // The still-running task's live handle must survive the rebuild so a later - // tool_execution_update/_end settles it instead of stranding on "running". + // tool_execution_update/_end settles it instead of stranding on "running", + // without also leaving the replayed snapshot on screen. expect(mode.pendingTools.get("call-1")).toBe(live); + expect(mode.chatContainer.children.filter(child => child instanceof ToolExecutionComponent)).toHaveLength(1); + }); + + it("keeps a shared read group attached while a sibling read is still in flight", () => { + const entries: SessionEntry[] = [ + { + type: "message", + id: "m1", + parentId: null, + timestamp: Date.now(), + message: { role: "user", content: [{ type: "text", text: "read them" }], timestamp: 1 }, + }, + { + type: "message", + id: "m2", + parentId: "m1", + timestamp: Date.now(), + message: { + role: "assistant", + content: [ + { type: "toolCall", id: "call-1", name: "read", arguments: { path: "a.txt" } }, + { type: "toolCall", id: "call-2", name: "read", arguments: { path: "b.txt" } }, + ], + api: "anthropic-messages", + provider: "anthropic", + model: "claude-sonnet-4-5", + usage, + stopReason: "toolUse", + timestamp: 2, + }, + }, + { + type: "message", + id: "m3", + parentId: "m2", + timestamp: Date.now(), + message: { + role: "toolResult", + toolCallId: "call-1", + toolName: "read", + content: [{ type: "text", text: "contents of a" }], + isError: false, + timestamp: 3, + }, + }, + ] as unknown as SessionEntry[]; + + Object.defineProperty(session, "isStreaming", { configurable: true, get: () => true }); + vi.spyOn(session, "buildTranscriptSessionContext").mockReturnValue( + buildSessionContext(entries, undefined, undefined, { transcript: true }), + ); + + // One group component shared by both read ids, exactly as the live path + // wires it (ui-helpers sets the same ReadToolGroupComponent per read id). + const group = new ReadToolGroupComponent(); + group.updateArgs({ path: "a.txt" }, "call-1"); + group.updateArgs({ path: "b.txt" }, "call-2"); + mode.chatContainer.addChild(group); + mode.pendingTools.set("call-1", group); + mode.pendingTools.set("call-2", group); + + mode.rebuildChatFromMessages(); + + // The shared group must stay the sole on-screen owner for both the completed + // call-1 and pending call-2 — splicing it would detach the pending read's + // display, while replaying call-1 would duplicate the group. + expect(mode.pendingTools.get("call-1")).toBeUndefined(); + expect(mode.pendingTools.get("call-2")).toBe(group); + expect(mode.chatContainer.children.includes(group)).toBe(true); + expect(mode.chatContainer.children.filter(child => child instanceof ReadToolGroupComponent)).toHaveLength(1); }); });