From 9fabdcf49d76a30ec0cb3fa72066e28621a79054 Mon Sep 17 00:00:00 2001 From: Peter Lee Date: Wed, 1 Jul 2026 10:12:13 -0500 Subject: [PATCH] fix(feishu): send blocks as independent messages when blockStreaming is enabled (#94250) * fix(feishu): send blocks as independent messages when blockStreaming is enabled * fix(feishu): preserve mention targets on first independently sent block * fix(feishu): route independent block sends through chunked text delivery Route the independent block-send path through sendChunkedTextReply instead of calling sendMessageFeishu directly, so the configured Feishu textChunkLimit is honored for completed blocks. Preserve mention targets only on the first emitted block chunk and keep sentBlockText based on the original block text for duplicate-final suppression. Add regression test with a small textChunkLimit that verifies a long block is split into chunked messages and mentions only appear on the first chunk. * fix(feishu): preserve block delivery target --------- Co-authored-by: Vincent Koc --- .../feishu/src/reply-dispatcher.test.ts | 66 +++++++++++++++++++ extensions/feishu/src/reply-dispatcher.ts | 32 ++++++++- 2 files changed, 97 insertions(+), 1 deletion(-) diff --git a/extensions/feishu/src/reply-dispatcher.test.ts b/extensions/feishu/src/reply-dispatcher.test.ts index cc6bbec307e4..b165873c471d 100644 --- a/extensions/feishu/src/reply-dispatcher.test.ts +++ b/extensions/feishu/src/reply-dispatcher.test.ts @@ -192,6 +192,20 @@ describe("createFeishuReplyDispatcher streaming behavior", () => { }); } + function useNonStreamingBlockAccount() { + resolveFeishuAccountMock.mockReturnValue({ + accountId: "main", + appId: "app_id", + appSecret: "app_secret", + domain: "feishu", + config: { + renderMode: "auto", + streaming: false, + blockStreaming: true, + }, + }); + } + function setupNonStreamingAutoDispatcher() { useNonStreamingAutoAccount(); @@ -667,6 +681,58 @@ describe("createFeishuReplyDispatcher streaming behavior", () => { }); }); + it("sends complete chunked blocks to the DM target", async () => { + useNonStreamingBlockAccount(); + const runtime = getFeishuRuntimeMock(); + runtime.channel.text.resolveTextChunkLimit.mockReturnValue(10); + runtime.channel.text.chunkTextWithMode.mockImplementation((text: string) => + text === "First paragraph." ? ["First ", "paragraph."] : [text], + ); + const mentions = [{ openId: "ou_target", name: "Target User", key: "@_user_1" }]; + const { options } = createDispatcherHarness({ + chatId: "oc_p2p_chat", + sendTarget: "user:ou_sender", + mentionTargets: mentions, + }); + + await options.deliver({ text: "First paragraph." }, { kind: "block" }); + await options.deliver( + { text: "Second paragraph.", mediaUrl: "https://example.com/block.png" }, + { kind: "block" }, + ); + await options.onIdle?.(); + + expect(sendMessageFeishuMock).toHaveBeenCalledTimes(3); + expectMockArgFields(sendMessageFeishuMock, "first block chunk", { + to: "user:ou_sender", + text: "First ", + mentions, + }); + expectMockArgFields(sendMessageFeishuMock, "second block chunk", { text: "paragraph." }, 1); + expectMockArgFields(sendMessageFeishuMock, "second block", { text: "Second paragraph." }, 2); + expect(sendMessageFeishuMock.mock.calls[1]?.[0]).not.toHaveProperty("mentions"); + expect(sendMessageFeishuMock.mock.calls[2]?.[0]).not.toHaveProperty("mentions"); + expectMockArgFields(sendMediaFeishuMock, "block media", { + to: "user:ou_sender", + mediaUrl: "https://example.com/block.png", + }); + expect(streamingInstances).toHaveLength(0); + expect(sendStructuredCardFeishuMock).not.toHaveBeenCalled(); + }); + + it("delivers a final message when it differs from independently sent blocks", async () => { + useNonStreamingBlockAccount(); + const { options } = createDispatcherHarness(); + + await options.deliver({ text: "partial block" }, { kind: "block" }); + await options.deliver({ text: "final answer" }, { kind: "final" }); + await options.onIdle?.(); + + expect(sendMessageFeishuMock).toHaveBeenCalledTimes(2); + expectMockArgFields(sendMessageFeishuMock, "block message", { text: "partial block" }); + expectMockArgFields(sendMessageFeishuMock, "final message", { text: "final answer" }, 1); + }); + it("does not prepend automatic mentions to streaming card closes", async () => { const overrides = { runtime: createRuntimeLogger(), diff --git a/extensions/feishu/src/reply-dispatcher.ts b/extensions/feishu/src/reply-dispatcher.ts index 7948e662b359..615480c4218e 100644 --- a/extensions/feishu/src/reply-dispatcher.ts +++ b/extensions/feishu/src/reply-dispatcher.ts @@ -272,6 +272,7 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP // Partial previews are replaceable; only committed final text may precede an error notice. let hasStreamingFinalText = false; const deliveredFinalTexts = new Set(); + let sentIndependentBlockText = false; let partialUpdateQueue: Promise = Promise.resolve(); let streamingStartPromise: Promise | null = null; let streamingClosedForReply = false; @@ -638,6 +639,7 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP if (!replyLifecycleStateInitialized) { replyLifecycleStateInitialized = true; deliveredFinalTexts.clear(); + sentIndependentBlockText = false; streamingClosedForReply = false; streamingCloseErroredForReply = false; visibleReplySent = false; @@ -717,8 +719,36 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP if (shouldDeliverText) { if (info?.kind === "block") { // Drop internal block chunks unless we can safely consume them as - // streaming-card fallback content. + // streaming-card fallback content or send them as independent + // messages for true progressive delivery. if (!useStreamingCard) { + if (coreBlockStreamingEnabled) { + // Reuse normal text chunking, but notify mentions only on the first visible chunk. + const isFirstBlock = !sentIndependentBlockText; + await sendChunkedTextReply({ + text, + useCard: false, + infoKind: "block", + sendChunk: async ({ chunk, isFirst }) => { + await sendMessageFeishu({ + cfg, + to: sendTarget, + text: chunk, + replyToMessageId: sendReplyToMessageId, + replyInThread: effectiveReplyInThread, + allowTopLevelReplyFallback, + accountId, + ...(isFirstBlock && isFirst && mentionTargets?.length + ? { mentions: mentionTargets } + : {}), + }); + }, + }); + sentIndependentBlockText = true; + if (hasMedia) { + await sendMediaReplies(payload); + } + } return; } startStreaming();