From 2aa313cd905cf2e8af9ec93a931579c80920588a Mon Sep 17 00:00:00 2001 From: ToToKr Date: Sat, 25 Apr 2026 15:18:10 +0900 Subject: [PATCH] fix(feishu): prevent duplicate message after streaming card close (#67791) (#68491) * fix(feishu): prevent duplicate message after streaming card close (#67791) When onIdle closed the streaming card before the final delivery arrived, the streamed text was not tracked in deliveredFinalTexts. The subsequent final payload bypassed the streaming?.isActive() guard (already closed) and fell through to the non-streaming path, sending the same content as a redundant text/card message. Track raw streamText in deliveredFinalTexts when closeStreaming finalizes the card so the duplicate-final check catches it. * test(feishu): cover idle streaming final dedupe --------- Co-authored-by: Vincent Koc --- .../feishu/src/reply-dispatcher.test.ts | 21 +++++++++++++++++++ extensions/feishu/src/reply-dispatcher.ts | 6 ++++++ 2 files changed, 27 insertions(+) diff --git a/extensions/feishu/src/reply-dispatcher.test.ts b/extensions/feishu/src/reply-dispatcher.test.ts index a4a58d7759d..190bc007a4a 100644 --- a/extensions/feishu/src/reply-dispatcher.test.ts +++ b/extensions/feishu/src/reply-dispatcher.test.ts @@ -364,6 +364,27 @@ describe("createFeishuReplyDispatcher streaming behavior", () => { expect(sendMessageFeishuMock).not.toHaveBeenCalled(); expect(sendMarkdownCardFeishuMock).not.toHaveBeenCalled(); }); + + it("skips final text already closed by idle streaming", async () => { + const { result, options } = createDispatcherHarness({ + runtime: createRuntimeLogger(), + }); + + await options.onReplyStart?.(); + result.replyOptions.onPartialReply?.({ text: "```md\nidle streamed reply\n```" }); + await options.onIdle?.(); + await options.deliver({ text: "```md\nidle streamed reply\n```" }, { kind: "final" }); + + expect(streamingInstances).toHaveLength(1); + expect(streamingInstances[0].close).toHaveBeenCalledTimes(1); + expect(streamingInstances[0].close).toHaveBeenCalledWith("```md\nidle streamed reply\n```", { + note: "Agent: agent", + }); + expect(sendMessageFeishuMock).not.toHaveBeenCalled(); + expect(sendMarkdownCardFeishuMock).not.toHaveBeenCalled(); + expect(sendStructuredCardFeishuMock).not.toHaveBeenCalled(); + }); + it("suppresses duplicate final text while still sending media", async () => { const options = setupNonStreamingAutoDispatcher(); await options.deliver({ text: "plain final" }, { kind: "final" }); diff --git a/extensions/feishu/src/reply-dispatcher.ts b/extensions/feishu/src/reply-dispatcher.ts index fc2e18eb07a..b4fa4f909d9 100644 --- a/extensions/feishu/src/reply-dispatcher.ts +++ b/extensions/feishu/src/reply-dispatcher.ts @@ -311,6 +311,12 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP } const finalNote = resolveCardNote(agentId, identity, prefixContext.prefixContext); await streaming.close(text, { note: finalNote }); + // Track the raw streamed text so the duplicate-final check in deliver() + // can skip the redundant text delivery that arrives after onIdle closes + // the streaming card. + if (streamText) { + deliveredFinalTexts.add(streamText); + } } streaming = null; streamingStartPromise = null;