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 <tl2493@columbia.edu>
This commit is contained in:
Ted Li
2026-05-31 14:41:38 -07:00
committed by GitHub
parent b653d94918
commit bfc151e9d3
2 changed files with 128 additions and 11 deletions

View File

@@ -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" });

View File

@@ -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;