From bd2edcdba464ff5d9922293c8738541501678065 Mon Sep 17 00:00:00 2001 From: Ted Li Date: Sun, 26 Apr 2026 10:35:01 -0700 Subject: [PATCH] fix(feishu): suppress late streaming card finals --- .../feishu/src/reply-dispatcher.test.ts | 40 +++++++++++++++++++ extensions/feishu/src/reply-dispatcher.ts | 16 ++++++-- 2 files changed, 53 insertions(+), 3 deletions(-) diff --git a/extensions/feishu/src/reply-dispatcher.test.ts b/extensions/feishu/src/reply-dispatcher.test.ts index fb0b7342b4e..ca58a9704da 100644 --- a/extensions/feishu/src/reply-dispatcher.test.ts +++ b/extensions/feishu/src/reply-dispatcher.test.ts @@ -399,6 +399,46 @@ describe("createFeishuReplyDispatcher streaming behavior", () => { expect(sendStructuredCardFeishuMock).not.toHaveBeenCalled(); }); + it("skips distinct late final text after streaming card close", async () => { + resolveFeishuAccountMock.mockReturnValue({ + accountId: "main", + appId: "app_id", + appSecret: "app_secret", + domain: "feishu", + config: { + renderMode: "card", + streaming: true, + }, + }); + + const { options } = createDispatcherHarness({ + runtime: createRuntimeLogger(), + }); + + await options.deliver({ text: "First complete answer" }, { kind: "final" }); + await options.onIdle?.(); + await options.deliver( + { text: "Late tool-result final", mediaUrl: "https://example.com/a.png" }, + { kind: "final" }, + ); + await options.onIdle?.(); + + expect(streamingInstances).toHaveLength(1); + expect(streamingInstances[0].close).toHaveBeenCalledTimes(1); + expect(streamingInstances[0].close).toHaveBeenCalledWith("First complete answer", { + note: "Agent: agent", + }); + expect(sendMessageFeishuMock).not.toHaveBeenCalled(); + expect(sendMarkdownCardFeishuMock).not.toHaveBeenCalled(); + expect(sendStructuredCardFeishuMock).not.toHaveBeenCalled(); + expect(sendMediaFeishuMock).toHaveBeenCalledTimes(1); + expect(sendMediaFeishuMock).toHaveBeenCalledWith( + expect.objectContaining({ + mediaUrl: "https://example.com/a.png", + }), + ); + }); + 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 8fa6e09df67..e8659574b63 100644 --- a/extensions/feishu/src/reply-dispatcher.ts +++ b/extensions/feishu/src/reply-dispatcher.ts @@ -228,6 +228,7 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP const deliveredFinalTexts = new Set(); let partialUpdateQueue: Promise = Promise.resolve(); let streamingStartPromise: Promise | null = null; + let streamingClosedForReply = false; type StreamTextUpdateMode = "snapshot" | "delta"; const formatReasoningPrefix = (thinking: string): string => { @@ -378,6 +379,7 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP // the streaming card. if (streamText) { deliveredFinalTexts.add(streamText); + streamingClosedForReply = true; } } } finally { @@ -451,6 +453,7 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, agentId), onReplyStart: async () => { deliveredFinalTexts.clear(); + streamingClosedForReply = false; if (streamingEnabled && renderMode === "card") { startStreaming(); } @@ -461,17 +464,24 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP const text = reply.text; const hasText = reply.hasText; const hasMedia = reply.hasMedia; + const useCard = + hasText && (renderMode === "card" || (renderMode === "auto" && shouldUseCard(text))); const skipTextForDuplicateFinal = info?.kind === "final" && hasText && deliveredFinalTexts.has(text); - const shouldDeliverText = hasText && !skipTextForDuplicateFinal; + const skipTextForClosedStreamingFinal = + info?.kind === "final" && + hasText && + streamingClosedForReply && + streamingEnabled && + useCard; + const shouldDeliverText = + hasText && !skipTextForDuplicateFinal && !skipTextForClosedStreamingFinal; if (!shouldDeliverText && !hasMedia) { return; } if (shouldDeliverText) { - const useCard = renderMode === "card" || (renderMode === "auto" && shouldUseCard(text)); - if (info?.kind === "block") { // Drop internal block chunks unless we can safely consume them as // streaming-card fallback content.