From bfc151e9d31e1802c1b282c6e111b95f6d5cdf9f Mon Sep 17 00:00:00 2001 From: Ted Li Date: Sun, 31 May 2026 14:41:38 -0700 Subject: [PATCH] fix(feishu): preserve long streaming replies Preserve long Feishu streaming replies by falling oversized finals back to chunked message/static-card delivery instead of closing through an over-limit streaming CardKit payload. Keeps late-final suppression after a streaming card closes, and uses markdown-aware chunking for static card fallback replies. Fixes #88631. Co-authored-by: Ted Li --- .../feishu/src/reply-dispatcher.test.ts | 107 ++++++++++++++++++ extensions/feishu/src/reply-dispatcher.ts | 32 ++++-- 2 files changed, 128 insertions(+), 11 deletions(-) diff --git a/extensions/feishu/src/reply-dispatcher.test.ts b/extensions/feishu/src/reply-dispatcher.test.ts index c7239f0b688..dd85e025cc7 100644 --- a/extensions/feishu/src/reply-dispatcher.test.ts +++ b/extensions/feishu/src/reply-dispatcher.test.ts @@ -162,6 +162,7 @@ describe("createFeishuReplyDispatcher streaming behavior", () => { resolveMarkdownTableMode: vi.fn(() => "preserve"), convertMarkdownTables: vi.fn((text) => text), chunkTextWithMode: vi.fn((text) => [text]), + chunkMarkdownTextWithMode: vi.fn((text) => [text]), }, reply: { createReplyDispatcherWithTyping: createReplyDispatcherWithTypingMock, @@ -403,6 +404,85 @@ describe("createFeishuReplyDispatcher streaming behavior", () => { expect(sendMarkdownCardFeishuMock).not.toHaveBeenCalled(); }); + it("keeps oversized auto mode plain final text on the chunked message path", async () => { + const runtime = getFeishuRuntimeMock(); + runtime.channel.text.resolveTextChunkLimit.mockReturnValue(10); + runtime.channel.text.chunkTextWithMode.mockReturnValue(["0123456789", "abcdefghij"]); + + const { options } = createDispatcherHarness(); + await options.deliver({ text: "0123456789abcdefghij" }, { kind: "final" }); + await options.onIdle?.(); + + expect(streamingInstances).toHaveLength(0); + expect(sendMessageFeishuMock).toHaveBeenCalledTimes(2); + expectMockArgFields(sendMessageFeishuMock, "first message send params", { + text: "0123456789", + }); + expectMockArgFields( + sendMessageFeishuMock, + "second message send params", + { + text: "abcdefghij", + }, + 1, + ); + expect(sendStructuredCardFeishuMock).not.toHaveBeenCalled(); + }); + + it("keeps oversized auto mode markdown final text on the chunked card path", async () => { + const runtime = getFeishuRuntimeMock(); + runtime.channel.text.resolveTextChunkLimit.mockReturnValue(10); + runtime.channel.text.chunkMarkdownTextWithMode.mockReturnValue(["```ts\nx\n```", "tail"]); + + const { options } = createDispatcherHarness({ runtime: createRuntimeLogger() }); + await options.deliver({ text: "```ts\nconst x = 1\n```\ntail" }, { kind: "final" }); + await options.onIdle?.(); + + expect(streamingInstances).toHaveLength(0); + expect(runtime.channel.text.chunkMarkdownTextWithMode).toHaveBeenCalledTimes(1); + expect(runtime.channel.text.chunkTextWithMode).not.toHaveBeenCalled(); + expect(sendStructuredCardFeishuMock).toHaveBeenCalledTimes(2); + expectMockArgFields(sendStructuredCardFeishuMock, "first card send params", { + text: "```ts\nx\n```", + }); + expectMockArgFields( + sendStructuredCardFeishuMock, + "second card send params", + { + text: "tail", + }, + 1, + ); + expect(sendMessageFeishuMock).not.toHaveBeenCalled(); + }); + + it("discards partial streaming preview before oversized final text fallback", async () => { + const runtime = getFeishuRuntimeMock(); + runtime.channel.text.resolveTextChunkLimit.mockReturnValue(10); + runtime.channel.text.chunkTextWithMode.mockReturnValue(["final text", " overflow"]); + + const { result, options } = createDispatcherHarness({ runtime: createRuntimeLogger() }); + result.replyOptions.onPartialReply?.({ text: "partial" }); + await options.deliver({ text: "final text overflow" }, { kind: "final" }); + await options.onIdle?.(); + + expect(streamingInstances).toHaveLength(1); + expect(streamingInstances[0].discard).toHaveBeenCalledTimes(1); + expect(streamingInstances[0].close).not.toHaveBeenCalled(); + expect(sendMessageFeishuMock).toHaveBeenCalledTimes(2); + expectMockArgFields(sendMessageFeishuMock, "first message send params", { + text: "final text", + }); + expectMockArgFields( + sendMessageFeishuMock, + "second message send params", + { + text: " overflow", + }, + 1, + ); + }); + it("keeps auto mode plain tool text on the message path when streaming is enabled", async () => { const { options } = createDispatcherHarness(); await options.deliver({ text: "tool summary" }, { kind: "tool" }); @@ -760,6 +840,33 @@ describe("createFeishuReplyDispatcher streaming behavior", () => { }); }); + it("skips oversized late final text after streaming card close", async () => { + const runtime = getFeishuRuntimeMock(); + runtime.channel.text.resolveTextChunkLimit.mockReturnValue(10); + runtime.channel.text.chunkTextWithMode.mockReturnValue(["oversized ", "late final"]); + + const { options } = createDispatcherHarness({ + runtime: createRuntimeLogger(), + }); + + await options.deliver({ text: "First" }, { kind: "final" }); + await options.onIdle?.(); + await options.deliver( + { text: "oversized late final", mediaUrl: "https://example.com/a.png" }, + { kind: "final" }, + ); + await options.onIdle?.(); + + expect(streamingInstances).toHaveLength(1); + expect(streamingInstances[0].close).toHaveBeenCalledTimes(1); + expect(sendMessageFeishuMock).not.toHaveBeenCalled(); + expect(sendStructuredCardFeishuMock).not.toHaveBeenCalled(); + expect(sendMediaFeishuMock).toHaveBeenCalledTimes(1); + expectMockArgFields(sendMediaFeishuMock, "media send params", { + mediaUrl: "https://example.com/a.png", + }); + }); + it("suppresses duplicate final text while still sending media", async () => { const options = setupNonStreamingAutoDispatcher(); await options.deliver({ text: "plain final" }, { kind: "final" }); diff --git a/extensions/feishu/src/reply-dispatcher.ts b/extensions/feishu/src/reply-dispatcher.ts index 1bd1f56548a..2a365d74ed2 100644 --- a/extensions/feishu/src/reply-dispatcher.ts +++ b/extensions/feishu/src/reply-dispatcher.ts @@ -463,9 +463,12 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP const chunkSource = paramsLocal.useCard ? paramsLocal.text : core.channel.text.convertMarkdownTables(paramsLocal.text, tableMode); + const chunkText = paramsLocal.useCard + ? core.channel.text.chunkMarkdownTextWithMode + : core.channel.text.chunkTextWithMode; const chunks = resolveTextChunksWithFallback( chunkSource, - core.channel.text.chunkTextWithMode(chunkSource, textChunkLimit, chunkMode), + chunkText(chunkSource, textChunkLimit, chunkMode), ); for (const [index, chunk] of chunks.entries()) { await paramsLocal.sendChunk({ @@ -629,13 +632,21 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP ...(payload.audioAsVoice === true ? { audioAsVoice: true } : {}), }), ); - const streamingCardEnabledForReplyKind = streamingEnabled && info?.kind === "final"; - const useCard = + const finalTextExceedsStreamingLimit = + info?.kind === "final" && hasText && text.length > textChunkLimit; + const useStaticCard = hasText && - (streamingCardEnabledForReplyKind || - renderMode === "card" || + (renderMode === "card" || (info?.kind === "block" && coreBlockStreamingEnabled && renderMode !== "raw") || (renderMode === "auto" && shouldUseCard(text))); + const useStreamingCard = + hasText && + streamingEnabled && + !finalTextExceedsStreamingLimit && + (info?.kind === "final" || useStaticCard); + const finalTextWouldUseStreamingCard = + info?.kind === "final" && hasText && streamingEnabled; + const useCard = useStaticCard || useStreamingCard; const skipTextForDuplicateFinal = info?.kind === "final" && hasText && deliveredFinalTexts.has(text); const skipTextForClosedStreamingFinal = @@ -643,8 +654,7 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP hasText && streamingClosedForReply && !streamingCloseErroredForReply && - streamingEnabled && - useCard; + finalTextWouldUseStreamingCard; const shouldDeliverText = hasText && !hasVoiceMedia && @@ -652,8 +662,8 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP !skipTextForClosedStreamingFinal; const shouldDiscardStreamingPreview = info?.kind === "final" && - hasMedia && - ((hasVoiceMedia && !shouldDeliverText) || skipTextForDuplicateFinal); + (finalTextExceedsStreamingLimit || + (hasMedia && ((hasVoiceMedia && !shouldDeliverText) || skipTextForDuplicateFinal))); if (!shouldDeliverText && !hasMedia) { return; @@ -667,7 +677,7 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP if (info?.kind === "block") { // Drop internal block chunks unless we can safely consume them as // streaming-card fallback content. - if (!(streamingEnabled && useCard)) { + if (!useStreamingCard) { return; } startStreaming(); @@ -676,7 +686,7 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP } } - if (info?.kind === "final" && streamingEnabled && useCard) { + if (info?.kind === "final" && useStreamingCard) { startStreaming(); if (streamingStartPromise) { await streamingStartPromise;