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 <vincentkoc@ieee.org>
This commit is contained in:
Peter Lee
2026-07-01 10:12:13 -05:00
committed by GitHub
parent a5a8d992ae
commit 9fabdcf49d
2 changed files with 97 additions and 1 deletions

View File

@@ -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(),

View File

@@ -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<string>();
let sentIndependentBlockText = false;
let partialUpdateQueue: Promise<void> = Promise.resolve();
let streamingStartPromise: Promise<void> | 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();