fix: Duplicate assistant messages in feed (#785)

This commit is contained in:
Ben Brandt
2026-06-17 12:12:26 +02:00
committed by GitHub
parent 59a098c2b5
commit 12d34e64e5
2 changed files with 61 additions and 5 deletions

View File

@@ -959,9 +959,8 @@ export class ClaudeAcpAgent implements Agent {
// via `stream_event` deltas, split by block type, so the consolidated // via `stream_event` deltas, split by block type, so the consolidated
// `assistant` message can drop the duplicate blocks that already reached the // `assistant` message can drop the duplicate blocks that already reached the
// client as chunks while still forwarding any that a non-streaming gateway // client as chunks while still forwarding any that a non-streaming gateway
// delivered only as an assembled block. const streamedTextIds = new Set<string>();
let streamedTextIds = new Set<string>(); const streamedThinkingIds = new Set<string>();
let streamedThinkingIds = new Set<string>();
// Stop reason accumulated for the active turn (result subtype, refusal, // Stop reason accumulated for the active turn (result subtype, refusal,
// max_tokens, …). Reset per turn; read when the turn settles at idle. // max_tokens, …). Reset per turn; read when the turn settles at idle.
let stopReason: StopReason = "end_turn"; let stopReason: StopReason = "end_turn";
@@ -974,8 +973,6 @@ export class ClaudeAcpAgent implements Agent {
lastRefusalExplanation = null; lastRefusalExplanation = null;
compactionInProgress = false; compactionInProgress = false;
currentStreamMessageId = undefined; currentStreamMessageId = undefined;
streamedTextIds = new Set<string>();
streamedThinkingIds = new Set<string>();
stopReason = "end_turn"; stopReason = "end_turn";
session.accumulatedUsage = { session.accumulatedUsage = {
inputTokens: 0, inputTokens: 0,
@@ -1864,6 +1861,13 @@ export class ClaudeAcpAgent implements Agent {
} }
return true; return true;
}); });
// The consolidated message is the last place this id is needed for
// dedupe — drop it so the streamed-id sets stay bounded to in-flight
// streams rather than growing for the session's whole life.
if (id !== undefined) {
streamedTextIds.delete(id);
streamedThinkingIds.delete(id);
}
} else if (message.type === "assistant") { } else if (message.type === "assistant") {
// Subagent assistant message (`parent_tool_use_id !== null`). It is // Subagent assistant message (`parent_tool_use_id !== null`). It is
// never streamed live and its text/thinking is internal to the tool // never streamed live and its text/thinking is internal to the tool

View File

@@ -3679,6 +3679,36 @@ describe("assembled assistant text fallback", () => {
}); });
} }
// Like injectSession, but the user-message echo is yielded at the position of
// the "ECHO" sentinel in `messages` rather than always first — so a test can
// reproduce the production ordering where the assistant stream arrives before
// the SDK replays the user message.
function injectSessionEchoAt(agent: ClaudeAcpAgent, messages: any[]) {
const input = new Pushable<any>();
async function* messageGenerator() {
const iter = input[Symbol.asyncIterator]();
const { value: userMessage } = await iter.next();
for (const m of messages) {
if (m === "ECHO") {
yield {
type: "user",
message: userMessage.message,
parent_tool_use_id: null,
uuid: userMessage.uuid,
session_id: "test-session",
isReplay: true,
};
} else {
yield m;
}
}
}
agent.sessions["test-session"] = mockSessionState({
query: wrapQuery(messageGenerator()),
input,
});
}
function messageChunkTexts(updates: any[]): string[] { function messageChunkTexts(updates: any[]): string[] {
return updates return updates
.filter((u) => u.update?.sessionUpdate === "agent_message_chunk") .filter((u) => u.update?.sessionUpdate === "agent_message_chunk")
@@ -3723,6 +3753,28 @@ describe("assembled assistant text fallback", () => {
expect(messageChunkTexts(updates)).toEqual(["hello ", "world"]); expect(messageChunkTexts(updates)).toEqual(["hello ", "world"]);
}); });
it("dedupes streamed text even when the stream arrives before the user echo", async () => {
const { agent, updates } = createMockAgentWithCapture();
// Production ordering: the SDK emits the assistant's stream events before it
// replays the user message that activates the turn. The streamed-id tracking
// must survive activation, or the consolidated block is re-emitted as a
// duplicate (regression from the persistent-consumer rework).
injectSessionEchoAt(agent, [
messageStart("msg-streamed"),
textDelta("hello "),
textDelta("world"),
"ECHO",
assistantMessage("msg-streamed", [{ type: "text", text: "hello world" }]),
result(),
idle,
]);
await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "hi" }] });
// Still just the two streamed deltas — no duplicated assembled block.
expect(messageChunkTexts(updates)).toEqual(["hello ", "world"]);
});
it("dedupes per block type: streamed text is dropped but an un-streamed thinking block in the same message is forwarded", async () => { 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(); const { agent, updates } = createMockAgentWithCapture();
// Gateway streams the text live but delivers the thinking block only in the // Gateway streams the text live but delivers the thinking block only in the