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();