From 736e542304a58cb79ed0a2694a7dc9d1cffb223f Mon Sep 17 00:00:00 2001 From: Nils Reeh Date: Wed, 15 Apr 2026 02:35:20 +0200 Subject: [PATCH 1/3] test(pi-coding-agent): add regression tests for agent_end DOM destruction (issue #4197) --- .../src/core/chat-controller-ordering.test.ts | 80 +++++++++++++++++++ 1 file changed, 80 insertions(+) diff --git a/packages/pi-coding-agent/src/core/chat-controller-ordering.test.ts b/packages/pi-coding-agent/src/core/chat-controller-ordering.test.ts index ae24d455d..bacdb2da4 100644 --- a/packages/pi-coding-agent/src/core/chat-controller-ordering.test.ts +++ b/packages/pi-coding-agent/src/core/chat-controller-ordering.test.ts @@ -813,3 +813,83 @@ test("chat-controller updates pinned zone after sub-turn shrink", async () => { // Finalize so the module-level pinned spinner (setInterval) is torn down and the test process can exit. await handleAgentEvent(host, { type: "message_end", message: makeAssistant([{ type: "text", text: "second" }, t2]) } as any); }); + +test("chat-controller: agent_end without message_end must not remove streaming component from DOM (regression #4197)", async () => { + const host = createHost(); + + await handleAgentEvent(host, { + type: "message_start", + message: makeAssistant([]), + } as any); + + // Simulate partial streaming that creates an AssistantMessageComponent + await handleAgentEvent(host, { + type: "message_update", + message: makeAssistant([{ type: "text", text: "partial answer" }]), + assistantMessageEvent: { + type: "text_delta", + contentIndex: 0, + delta: "partial answer", + partial: makeAssistant([{ type: "text", text: "partial answer" }]), + }, + } as any); + + // Precondition: component is in DOM + assert.equal( + host.chatContainer.children.length, + 1, + "streaming component must be in DOM after message_update", + ); + const comp = host.chatContainer.children[0]; + + // Simulate abort: agent_end fires WITHOUT message_end + await handleAgentEvent(host, { type: "agent_end" } as any); + + assert.equal( + host.chatContainer.children.length, + 1, + "agent_end must NOT remove the streaming component from the DOM (issue #4197)", + ); + assert.equal( + host.chatContainer.children[0], + comp, + "the same component instance must remain in the DOM after agent_end", + ); +}); + +test("chat-controller: agent_end after message_end must not alter DOM", async () => { + const host = createHost(); + const content = [{ type: "text", text: "complete answer" }]; + + await handleAgentEvent(host, { + type: "message_start", + message: makeAssistant([]), + } as any); + + await handleAgentEvent(host, { + type: "message_update", + message: makeAssistant(content), + assistantMessageEvent: { + type: "text_delta", + contentIndex: 0, + delta: "complete answer", + partial: makeAssistant(content), + }, + } as any); + + await handleAgentEvent(host, { + type: "message_end", + message: makeAssistant(content), + } as any); + + const countAfterMessageEnd = host.chatContainer.children.length; + assert.ok(countAfterMessageEnd > 0, "component must be present after message_end"); + + await handleAgentEvent(host, { type: "agent_end" } as any); + + assert.equal( + host.chatContainer.children.length, + countAfterMessageEnd, + "agent_end after message_end must not add or remove DOM nodes", + ); +}); From b721ec1445f3078006a405fe0040360714475f4f Mon Sep 17 00:00:00 2001 From: Nils Reeh Date: Wed, 15 Apr 2026 02:36:07 +0200 Subject: [PATCH 2/3] fix(pi-coding-agent): finalize streaming component on agent_end instead of removing it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When message_end does not fire before agent_end (e.g. abort path), the agent_end case was calling chatContainer.removeChild(streamingComponent), which silently erased the last assistant message from the TUI chat history. Fix: follow the message_end finalization pattern — call setShowMetadata(true) and updateContent() before clearing the reference. Never call removeChild on a component that was added to the persistent chat history. Closes #4197 --- .../interactive/controllers/chat-controller.ts | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/packages/pi-coding-agent/src/modes/interactive/controllers/chat-controller.ts b/packages/pi-coding-agent/src/modes/interactive/controllers/chat-controller.ts index 59d780223..cc6078394 100644 --- a/packages/pi-coding-agent/src/modes/interactive/controllers/chat-controller.ts +++ b/packages/pi-coding-agent/src/modes/interactive/controllers/chat-controller.ts @@ -546,11 +546,18 @@ export async function handleAgentEvent(host: InteractiveModeStateHost & { host.loadingAnimation = undefined; host.statusContainer.clear(); } - if (host.streamingComponent) { - host.chatContainer.removeChild(host.streamingComponent); - host.streamingComponent = undefined; - host.streamingMessage = undefined; + // If message_end did not already finalize the streaming component + // (e.g. abort path or event ordering edge case), finalize it now + // instead of removing it. Calling removeChild would erase the last + // assistant message from the chat history (issue #4197). + if (host.streamingComponent && host.streamingMessage) { + host.streamingComponent.setShowMetadata(true); + host.streamingComponent.updateContent(host.streamingMessage); } + host.streamingComponent = undefined; + host.streamingMessage = undefined; + renderedSegments = []; + lastContentLength = 0; host.pendingTools.clear(); // Pinned output is only useful while work is actively streaming. // Keep chat history as the single source after completion. From 3509107228dfc2af1aab32cdcad5fd9e79f380ef Mon Sep 17 00:00:00 2001 From: Nils Reeh Date: Wed, 15 Apr 2026 03:12:17 +0200 Subject: [PATCH 3/3] fix(pi-coding-agent): remove explanatory comment from agent_end handler --- .../src/modes/interactive/controllers/chat-controller.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/packages/pi-coding-agent/src/modes/interactive/controllers/chat-controller.ts b/packages/pi-coding-agent/src/modes/interactive/controllers/chat-controller.ts index cc6078394..c2f6bc1eb 100644 --- a/packages/pi-coding-agent/src/modes/interactive/controllers/chat-controller.ts +++ b/packages/pi-coding-agent/src/modes/interactive/controllers/chat-controller.ts @@ -546,10 +546,6 @@ export async function handleAgentEvent(host: InteractiveModeStateHost & { host.loadingAnimation = undefined; host.statusContainer.clear(); } - // If message_end did not already finalize the streaming component - // (e.g. abort path or event ordering edge case), finalize it now - // instead of removing it. Calling removeChild would erase the last - // assistant message from the chat history (issue #4197). if (host.streamingComponent && host.streamingMessage) { host.streamingComponent.setShowMetadata(true); host.streamingComponent.updateContent(host.streamingMessage);