fix: duplicate assistant text when turn activates mid-message (#789)

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."*
This commit is contained in:
Hugo Baraúna
2026-06-19 05:28:54 -03:00
committed by GitHub
parent ad3b5fe745
commit 1c80bf8e56
2 changed files with 51 additions and 1 deletions

View File

@@ -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,

View File

@@ -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