mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-07 10:52:56 +00:00
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:
@@ -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" });
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user