fix(feishu): suppress late streaming card finals

This commit is contained in:
Ted Li
2026-04-26 10:35:01 -07:00
committed by Mason Huang
parent fceaaa4494
commit bd2edcdba4
2 changed files with 53 additions and 3 deletions

View File

@@ -399,6 +399,46 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
expect(sendStructuredCardFeishuMock).not.toHaveBeenCalled();
});
it("skips distinct late final text after streaming card close", async () => {
resolveFeishuAccountMock.mockReturnValue({
accountId: "main",
appId: "app_id",
appSecret: "app_secret",
domain: "feishu",
config: {
renderMode: "card",
streaming: true,
},
});
const { options } = createDispatcherHarness({
runtime: createRuntimeLogger(),
});
await options.deliver({ text: "First complete answer" }, { kind: "final" });
await options.onIdle?.();
await options.deliver(
{ text: "Late tool-result final", mediaUrl: "https://example.com/a.png" },
{ kind: "final" },
);
await options.onIdle?.();
expect(streamingInstances).toHaveLength(1);
expect(streamingInstances[0].close).toHaveBeenCalledTimes(1);
expect(streamingInstances[0].close).toHaveBeenCalledWith("First complete answer", {
note: "Agent: agent",
});
expect(sendMessageFeishuMock).not.toHaveBeenCalled();
expect(sendMarkdownCardFeishuMock).not.toHaveBeenCalled();
expect(sendStructuredCardFeishuMock).not.toHaveBeenCalled();
expect(sendMediaFeishuMock).toHaveBeenCalledTimes(1);
expect(sendMediaFeishuMock).toHaveBeenCalledWith(
expect.objectContaining({
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

@@ -228,6 +228,7 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
const deliveredFinalTexts = new Set<string>();
let partialUpdateQueue: Promise<void> = Promise.resolve();
let streamingStartPromise: Promise<void> | null = null;
let streamingClosedForReply = false;
type StreamTextUpdateMode = "snapshot" | "delta";
const formatReasoningPrefix = (thinking: string): string => {
@@ -378,6 +379,7 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
// the streaming card.
if (streamText) {
deliveredFinalTexts.add(streamText);
streamingClosedForReply = true;
}
}
} finally {
@@ -451,6 +453,7 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, agentId),
onReplyStart: async () => {
deliveredFinalTexts.clear();
streamingClosedForReply = false;
if (streamingEnabled && renderMode === "card") {
startStreaming();
}
@@ -461,17 +464,24 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
const text = reply.text;
const hasText = reply.hasText;
const hasMedia = reply.hasMedia;
const useCard =
hasText && (renderMode === "card" || (renderMode === "auto" && shouldUseCard(text)));
const skipTextForDuplicateFinal =
info?.kind === "final" && hasText && deliveredFinalTexts.has(text);
const shouldDeliverText = hasText && !skipTextForDuplicateFinal;
const skipTextForClosedStreamingFinal =
info?.kind === "final" &&
hasText &&
streamingClosedForReply &&
streamingEnabled &&
useCard;
const shouldDeliverText =
hasText && !skipTextForDuplicateFinal && !skipTextForClosedStreamingFinal;
if (!shouldDeliverText && !hasMedia) {
return;
}
if (shouldDeliverText) {
const useCard = renderMode === "card" || (renderMode === "auto" && shouldUseCard(text));
if (info?.kind === "block") {
// Drop internal block chunks unless we can safely consume them as
// streaming-card fallback content.