mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-10-27 12:40:32 +00:00
fix: Duplicate assistant messages in feed (#785)
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user