diff --git a/packages/coding-agent/src/modes/interactive-mode.ts b/packages/coding-agent/src/modes/interactive-mode.ts index a9b392174..b167672bf 100644 --- a/packages/coding-agent/src/modes/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive-mode.ts @@ -1756,6 +1756,16 @@ export class InteractiveMode implements InteractiveModeContext { if (message.role !== "toolResult") continue; const resolved = livePendingTools.get(message.toolCallId); if (!resolved) continue; + // A background task's initial `async.state === "running"` result is + // persisted while `EventController#handleToolExecutionEnd` deliberately + // keeps its component in `pendingTools` so a later + // `tool_execution_update`/`_end` settles it. Such a handle is still + // live — dropping it would strand those updates on the running snapshot + // — so keep it and let the live component retain ownership; only + // 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; livePendingTools.delete(message.toolCallId); const index = liveComponents.indexOf(resolved as unknown as Component); if (index >= 0) liveComponents.splice(index, 1); 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 be2ac989c..0d3447ff1 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 @@ -208,4 +208,79 @@ describe("issue #6516 — tool output appears twice", () => { // tool's result still lands in the on-screen block. expect(mode.pendingTools.get("call-1")).toBe(live); }); + + it("keeps a still-running background task's live handle across a rebuild", () => { + const runningDetails = { async: { state: "running", jobId: "job-1", type: "task" } }; + const entries: SessionEntry[] = [ + { + type: "message", + id: "m1", + parentId: null, + timestamp: Date.now(), + message: { role: "user", content: [{ type: "text", text: "spawn it" }], timestamp: 1 }, + }, + { + type: "message", + id: "m2", + parentId: "m1", + timestamp: Date.now(), + message: { + role: "assistant", + content: [ + { type: "toolCall", id: "call-1", name: "task", arguments: { description: "run", prompt: "go" } }, + ], + 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: "task", + content: [{ type: "text", text: "running…" }], + details: runningDetails, + 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 }), + ); + + const live = new ToolExecutionComponent( + "task", + { description: "run", prompt: "go" }, + {}, + undefined, + mode.ui, + tempDir.path(), + "call-1", + ); + live.updateResult( + { content: [{ type: "text", text: "running…" }], details: runningDetails, isError: false }, + true, + "call-1", + ); + created.push(live); + mode.chatContainer.addChild(live); + mode.pendingTools.set("call-1", live); + + 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". + expect(mode.pendingTools.get("call-1")).toBe(live); + }); });