mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-10-27 04:30:59 +00:00
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:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user