From b5fb1cba2d68806d0d952d7a541562d6818d26ea Mon Sep 17 00:00:00 2001 From: Tak Hoffman <781889+Takhoffman@users.noreply.github.com> Date: Tue, 3 Mar 2026 22:37:33 -0600 Subject: [PATCH] Feishu: close duplicate final gap and cover routing precedence --- CHANGELOG.md | 1 + .../feishu/src/reply-dispatcher.test.ts | 40 ++++++++++++ extensions/feishu/src/reply-dispatcher.ts | 15 ++--- extensions/feishu/src/streaming-card.test.ts | 65 ++++++++++++++++++- 4 files changed, 110 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3fc07ffb059..b7356c9df8f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Feishu/streaming card delivery synthesis: unify snapshot and delta streaming merge semantics, apply overlap-aware final merge, suppress duplicate final text delivery (including text+media final packets), prefer topic-thread `message.reply` routing when a reply target exists, and tune card print cadence to avoid duplicate incremental rendering. (from #33245, #32896, #33840) Thanks @rexl2018, @kcinzgg, and @aerelune. - Docs/tool-loop detection config keys: align `docs/tools/loop-detection.md` examples and field names with the current `tools.loopDetection` schema to prevent copy-paste validation failures from outdated keys. (#33182) Thanks @Mylszd. - Telegram/DM draft finalization reliability: require verified final-text draft emission before treating preview finalization as delivered, and fall back to normal payload send when final draft delivery is not confirmed (preventing missing final responses and preserving media/button delivery). (#32118) Thanks @OpenCils. - Discord/audit wildcard warnings: ignore "\*" wildcard keys when counting unresolved guild channels so doctor/status no longer warns on allow-all configs. (#33125) Thanks @thewilloftheshadow. diff --git a/extensions/feishu/src/reply-dispatcher.test.ts b/extensions/feishu/src/reply-dispatcher.test.ts index 846940192fa..4cd8664f9d3 100644 --- a/extensions/feishu/src/reply-dispatcher.test.ts +++ b/extensions/feishu/src/reply-dispatcher.test.ts @@ -280,6 +280,46 @@ describe("createFeishuReplyDispatcher streaming behavior", () => { expect(sendMarkdownCardFeishuMock).not.toHaveBeenCalled(); }); + it("suppresses duplicate final text while still sending media", async () => { + resolveFeishuAccountMock.mockReturnValue({ + accountId: "main", + appId: "app_id", + appSecret: "app_secret", + domain: "feishu", + config: { + renderMode: "auto", + streaming: false, + }, + }); + + createFeishuReplyDispatcher({ + cfg: {} as never, + agentId: "agent", + runtime: { log: vi.fn(), error: vi.fn() } as never, + chatId: "oc_chat", + }); + + const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0]; + await options.deliver({ text: "plain final" }, { kind: "final" }); + await options.deliver( + { text: "plain final", mediaUrl: "https://example.com/a.png" }, + { kind: "final" }, + ); + + expect(sendMessageFeishuMock).toHaveBeenCalledTimes(1); + expect(sendMessageFeishuMock).toHaveBeenLastCalledWith( + expect.objectContaining({ + text: "plain final", + }), + ); + expect(sendMediaFeishuMock).toHaveBeenCalledTimes(1); + expect(sendMediaFeishuMock).toHaveBeenCalledWith( + expect.objectContaining({ + mediaUrl: "https://example.com/a.png", + }), + ); + }); + it("treats block updates as delta chunks", async () => { resolveFeishuAccountMock.mockReturnValue({ accountId: "main", diff --git a/extensions/feishu/src/reply-dispatcher.ts b/extensions/feishu/src/reply-dispatcher.ts index c69d1063df0..16e9ebed549 100644 --- a/extensions/feishu/src/reply-dispatcher.ts +++ b/extensions/feishu/src/reply-dispatcher.ts @@ -165,7 +165,8 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP lastPartial = nextText; } const mode = options?.mode ?? "snapshot"; - streamText = mode === "delta" ? `${streamText}${nextText}` : mergeStreamingText(streamText, nextText); + streamText = + mode === "delta" ? `${streamText}${nextText}` : mergeStreamingText(streamText, nextText); partialUpdateQueue = partialUpdateQueue.then(async () => { if (streamingStartPromise) { await streamingStartPromise; @@ -245,18 +246,14 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP : []; const hasText = Boolean(text.trim()); const hasMedia = mediaList.length > 0; + const skipTextForDuplicateFinal = info?.kind === "final" && hasText && finalTextDelivered; + const shouldDeliverText = hasText && !skipTextForDuplicateFinal; - if (info?.kind === "final" && hasText && finalTextDelivered) { - if (!hasMedia) { - return; - } - } - - if (!hasText && !hasMedia) { + if (!shouldDeliverText && !hasMedia) { return; } - if (hasText) { + if (shouldDeliverText) { const useCard = renderMode === "card" || (renderMode === "auto" && shouldUseCard(text)); if (info?.kind === "block") { diff --git a/extensions/feishu/src/streaming-card.test.ts b/extensions/feishu/src/streaming-card.test.ts index e3a8b3f85ac..4d03981db14 100644 --- a/extensions/feishu/src/streaming-card.test.ts +++ b/extensions/feishu/src/streaming-card.test.ts @@ -1,5 +1,12 @@ -import { describe, expect, it } from "vitest"; -import { mergeStreamingText } from "./streaming-card.js"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const fetchWithSsrFGuardMock = vi.hoisted(() => vi.fn()); + +vi.mock("openclaw/plugin-sdk", () => ({ + fetchWithSsrFGuard: fetchWithSsrFGuardMock, +})); + +import { FeishuStreamingSession, mergeStreamingText } from "./streaming-card.js"; describe("mergeStreamingText", () => { it("prefers the latest full text when it already includes prior text", () => { @@ -23,3 +30,57 @@ describe("mergeStreamingText", () => { ); }); }); + +describe("FeishuStreamingSession routing", () => { + beforeEach(() => { + vi.clearAllMocks(); + fetchWithSsrFGuardMock.mockReset(); + }); + + it("prefers message.reply when reply target and root id both exist", async () => { + fetchWithSsrFGuardMock + .mockResolvedValueOnce({ + response: { json: async () => ({ code: 0, msg: "ok", tenant_access_token: "token" }) }, + release: async () => {}, + }) + .mockResolvedValueOnce({ + response: { json: async () => ({ code: 0, msg: "ok", data: { card_id: "card_1" } }) }, + release: async () => {}, + }); + + const replyMock = vi.fn(async () => ({ code: 0, data: { message_id: "msg_reply" } })); + const createMock = vi.fn(async () => ({ code: 0, data: { message_id: "msg_create" } })); + + const session = new FeishuStreamingSession( + { + im: { + message: { + reply: replyMock, + create: createMock, + }, + }, + } as never, + { + appId: "app", + appSecret: "secret", + domain: "feishu", + }, + ); + + await session.start("oc_chat", "chat_id", { + replyToMessageId: "om_parent", + replyInThread: true, + rootId: "om_topic_root", + }); + + expect(replyMock).toHaveBeenCalledTimes(1); + expect(replyMock).toHaveBeenCalledWith({ + path: { message_id: "om_parent" }, + data: expect.objectContaining({ + msg_type: "interactive", + reply_in_thread: true, + }), + }); + expect(createMock).not.toHaveBeenCalled(); + }); +});