mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-13 19:26:02 +00:00
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:
@@ -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(),
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user