From 1c80bf8e56a9279dc799e7bbdcae87241e99c18b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hugo=20Bara=C3=BAna?= Date: Fri, 19 Jun 2026 05:28:54 -0300 Subject: [PATCH] fix: duplicate assistant text when turn activates mid-message (#789) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit I generated this PR using AI. if not helpful, please delete it. Or, maybe its value is just to show a potential bug. I found this bug when using Tidewave. I noticed text was being duplicated when using claude and claude-agent-acp 0.47.0. I asked the agent to investigate, and this PR seems to fix it. --- ## Summary - Stop `resetTurnScratch()` from nulling `currentStreamMessageId` on turn activation, which was causing the assistant's message text to render **twice** (concatenated) in the feed. ## Problem When a turn activates mid-message, the agent's message text renders twice, back-to-back. Only prose duplicates — never tool calls — and specifically the text/thinking block that streams *after* the activation. Reproduces on `0.47.0` with a prompt like *"say a sentence, run a shell command that sleeps 10s, then say a sentence."* --- src/acp-agent.ts | 7 +++++- src/tests/acp-agent.test.ts | 45 +++++++++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/src/acp-agent.ts b/src/acp-agent.ts index 503d72bf..9b189013 100644 --- a/src/acp-agent.ts +++ b/src/acp-agent.ts @@ -972,7 +972,12 @@ export class ClaudeAcpAgent implements Agent { lastAssistantError = undefined; lastRefusalExplanation = null; compactionInProgress = false; - currentStreamMessageId = undefined; + // Do NOT reset currentStreamMessageId here. `message_start` sets it per + // message; clearing it on turn activation drops the id for any block that + // streams after a mid-message activation (the replayed user echo with + // --replay-user-messages), so those ids never enter streamedTextIds and the + // consolidated assistant message re-emits already-streamed text as a + // duplicate. #785 stopped resetting streamedTextIds here but left this line. stopReason = "end_turn"; session.accumulatedUsage = { inputTokens: 0, diff --git a/src/tests/acp-agent.test.ts b/src/tests/acp-agent.test.ts index 3bbaedbf..9d7084b0 100644 --- a/src/tests/acp-agent.test.ts +++ b/src/tests/acp-agent.test.ts @@ -3618,6 +3618,20 @@ describe("assembled assistant text fallback", () => { }; } + function thinkingDelta(thinking: string) { + return { + type: "stream_event" as const, + parent_tool_use_id: null, + uuid: randomUUID(), + session_id: "test-session", + event: { + type: "content_block_delta" as const, + index: 0, + delta: { type: "thinking_delta" as const, thinking }, + }, + }; + } + function assistantMessage(apiId: string, content: any[], parentToolUseId: string | null = null) { return { type: "assistant" as const, @@ -3775,6 +3789,37 @@ describe("assembled assistant text fallback", () => { expect(messageChunkTexts(updates)).toEqual(["hello ", "world"]); }); + it("dedupes streamed text when the user echo activates the turn mid-message, between a thinking and a text block", async () => { + const { agent, updates } = createMockAgentWithCapture(); + // Production ordering captured with: inside a single message id, the + // thinking block streams, THEN the SDK replays the user message that + // activates the turn, THEN the text block streams. Turn activation runs + // `resetTurnScratch()`; if that nulls `currentStreamMessageId`, every text + // delta after the echo streams untracked, so the consolidated `assistant` + // text fails dedupe and is re-emitted as a duplicate. #785 fixed the + // stream-before-echo case but left this residual mid-message path. + injectSessionEchoAt(agent, [ + messageStart("msg-mixed"), + thinkingDelta("private reasoning"), + "ECHO", + textDelta("Starting now."), + assistantMessage("msg-mixed", [ + { type: "thinking", thinking: "private reasoning" }, + { type: "text", text: "Starting now." }, + ]), + result(), + idle, + ]); + + await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "hi" }] }); + + // The text streamed once; the consolidated copy must be deduped, not doubled. + expect(messageChunkTexts(updates)).toEqual(["Starting now."]); + // The thinking streamed before the echo (still tracked) so it is deduped — + // mirrors the production signature where only the text block doubled. + expect(thoughtChunkTexts(updates)).toEqual(["private reasoning"]); + }); + it("dedupes per block type: streamed text is dropped but an un-streamed thinking block in the same message is forwarded", async () => { const { agent, updates } = createMockAgentWithCapture(); // Gateway streams the text live but delivers the thinking block only in the