Skip to content

Commit 8c8f396

Browse files
MonkeyLeeThxy91819
andauthored
fix(feishu): suppress late streaming card finals (#72294)
Merged via squash. Prepared head SHA: d18a9ff Co-authored-by: MonkeyLeeT <6754057+MonkeyLeeT@users.noreply.github.com> Co-authored-by: hxy91819 <8814856+hxy91819@users.noreply.github.com> Reviewed-by: @hxy91819
1 parent 68ba1e7 commit 8c8f396

3 files changed

Lines changed: 143 additions & 5 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ Docs: https://docs.openclaw.ai
6161
- Plugins/runtime-deps: cache bundled runtime-deps JSON/package files and root chunk import scans by file signature, reducing repeated staged-runtime scanning during bundled channel startup. Refs #73647 and #73705. Thanks @mattmcintyre and @bmilne1981.
6262
- CLI/TUI: keep `chat.history` off model-catalog discovery so initial Gateway-backed TUI history loads cannot block behind slow provider/plugin model scans on low-core hosts. Refs #73524. Thanks @harshcatsystems-collab.
6363
- Channels/WhatsApp: flag recently reconnected linked accounts in channel status even when the socket is currently healthy, so flapping WhatsApp Web sessions no longer look clean after a brief reconnect. Refs #73602. Thanks @Vksh07.
64+
- Feishu: suppress distinct late `final` text deliveries after a streaming card has already closed, while keeping media attachments deliverable, so late-finals no longer reopen duplicate Feishu cards. Fixes #71977. (#72294) Thanks @MonkeyLeeT.
6465
- Gateway: expose `gateway.handshakeTimeoutMs` in config, schema, and docs while preserving `OPENCLAW_HANDSHAKE_TIMEOUT_MS` precedence, so loaded or low-powered hosts can tune local WebSocket pre-auth handshakes without patching dist files. Supersedes #51282; refs #73592 and #73652. Thanks @henry-the-frog.
6566
- Gateway/TUI/status: align configured and env-based WebSocket handshake budgets across local clients, probes, and fallback RPCs while preserving explicit status timeouts and paired-device auth fallback, so slow local gateways are not marked unreachable by a shorter client watchdog. Refs #73524, #73535, #73592, and #73602. Thanks @harshcatsystems-collab, @DJBlackhawk, and @Vksh07.
6667
- Agents/auth: scope external CLI credential discovery to configured providers during model auth status and startup prewarm, so opencode-only and other single-provider gateways do not block on unrelated Claude CLI Keychain probes. Fixes #73908. Thanks @Ailuras.

extensions/feishu/src/reply-dispatcher.test.ts

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -399,6 +399,46 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
399399
expect(sendStructuredCardFeishuMock).not.toHaveBeenCalled();
400400
});
401401

402+
it("skips distinct late final text after streaming card close", async () => {
403+
resolveFeishuAccountMock.mockReturnValue({
404+
accountId: "main",
405+
appId: "app_id",
406+
appSecret: "app_secret",
407+
domain: "feishu",
408+
config: {
409+
renderMode: "card",
410+
streaming: true,
411+
},
412+
});
413+
414+
const { options } = createDispatcherHarness({
415+
runtime: createRuntimeLogger(),
416+
});
417+
418+
await options.deliver({ text: "First complete answer" }, { kind: "final" });
419+
await options.onIdle?.();
420+
await options.deliver(
421+
{ text: "Late tool-result final", mediaUrl: "https://example.com/a.png" },
422+
{ kind: "final" },
423+
);
424+
await options.onIdle?.();
425+
426+
expect(streamingInstances).toHaveLength(1);
427+
expect(streamingInstances[0].close).toHaveBeenCalledTimes(1);
428+
expect(streamingInstances[0].close).toHaveBeenCalledWith("First complete answer", {
429+
note: "Agent: agent",
430+
});
431+
expect(sendMessageFeishuMock).not.toHaveBeenCalled();
432+
expect(sendMarkdownCardFeishuMock).not.toHaveBeenCalled();
433+
expect(sendStructuredCardFeishuMock).not.toHaveBeenCalled();
434+
expect(sendMediaFeishuMock).toHaveBeenCalledTimes(1);
435+
expect(sendMediaFeishuMock).toHaveBeenCalledWith(
436+
expect.objectContaining({
437+
mediaUrl: "https://example.com/a.png",
438+
}),
439+
);
440+
});
441+
402442
it("suppresses duplicate final text while still sending media", async () => {
403443
const options = setupNonStreamingAutoDispatcher();
404444
await options.deliver({ text: "plain final" }, { kind: "final" });
@@ -918,6 +958,86 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
918958
});
919959
});
920960

961+
it("does not suppress a later final after error closeout", async () => {
962+
resolveFeishuAccountMock.mockReturnValue({
963+
accountId: "main",
964+
appId: "app_id",
965+
appSecret: "app_secret",
966+
domain: "feishu",
967+
config: {
968+
renderMode: "card",
969+
streaming: true,
970+
},
971+
});
972+
sendMediaFeishuMock.mockRejectedValueOnce(new Error("media failed"));
973+
974+
const { options } = createDispatcherHarness({
975+
runtime: createRuntimeLogger(),
976+
});
977+
978+
await expect(
979+
options.deliver(
980+
{ text: "First answer", mediaUrl: "https://example.com/a.png" },
981+
{ kind: "final" },
982+
),
983+
).rejects.toThrow("media failed");
984+
await Promise.all([
985+
options.onError?.(new Error("media failed"), { kind: "final" }),
986+
options.onIdle?.(),
987+
]);
988+
await options.deliver({ text: "Second answer" }, { kind: "final" });
989+
await options.onIdle?.();
990+
991+
expect(streamingInstances).toHaveLength(2);
992+
expect(streamingInstances[0].close).toHaveBeenCalledWith("First answer", {
993+
note: "Agent: agent",
994+
});
995+
expect(streamingInstances[1].close).toHaveBeenCalledWith("Second answer", {
996+
note: "Agent: agent",
997+
});
998+
expect(sendMessageFeishuMock).not.toHaveBeenCalled();
999+
expect(sendStructuredCardFeishuMock).not.toHaveBeenCalled();
1000+
});
1001+
1002+
it("does not suppress a recovery final after late media failure", async () => {
1003+
resolveFeishuAccountMock.mockReturnValue({
1004+
accountId: "main",
1005+
appId: "app_id",
1006+
appSecret: "app_secret",
1007+
domain: "feishu",
1008+
config: {
1009+
renderMode: "card",
1010+
streaming: true,
1011+
},
1012+
});
1013+
1014+
const { options } = createDispatcherHarness({
1015+
runtime: createRuntimeLogger(),
1016+
});
1017+
1018+
await options.deliver({ text: "First answer" }, { kind: "final" });
1019+
await options.onIdle?.();
1020+
sendMediaFeishuMock.mockRejectedValueOnce(new Error("media failed"));
1021+
await expect(
1022+
options.deliver(
1023+
{ text: "Late attachment", mediaUrl: "https://example.com/a.png" },
1024+
{ kind: "final" },
1025+
),
1026+
).rejects.toThrow("media failed");
1027+
await options.onError?.(new Error("media failed"), { kind: "final" });
1028+
await options.deliver({ text: "Recovered answer" }, { kind: "final" });
1029+
await options.onIdle?.();
1030+
1031+
expect(streamingInstances).toHaveLength(2);
1032+
expect(streamingInstances[0].close).toHaveBeenCalledWith("First answer", {
1033+
note: "Agent: agent",
1034+
});
1035+
expect(streamingInstances[1].close).toHaveBeenCalledWith("Recovered answer", {
1036+
note: "Agent: agent",
1037+
});
1038+
expect(sendStructuredCardFeishuMock).not.toHaveBeenCalled();
1039+
});
1040+
9211041
it("cleans streaming state even when close throws", async () => {
9221042
const origPush = streamingInstances.push.bind(streamingInstances);
9231043
streamingInstances.push = (...args: StreamingSessionStub[]) => {

extensions/feishu/src/reply-dispatcher.ts

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,8 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
228228
const deliveredFinalTexts = new Set<string>();
229229
let partialUpdateQueue: Promise<void> = Promise.resolve();
230230
let streamingStartPromise: Promise<void> | null = null;
231+
let streamingClosedForReply = false;
232+
let streamingCloseErroredForReply = false;
231233
type StreamTextUpdateMode = "snapshot" | "delta";
232234

233235
const formatReasoningPrefix = (thinking: string): string => {
@@ -359,7 +361,7 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
359361
})();
360362
};
361363

362-
const closeStreaming = async () => {
364+
const closeStreaming = async (options?: { markClosedForReply?: boolean }) => {
363365
try {
364366
if (streamingStartPromise) {
365367
await streamingStartPromise;
@@ -378,6 +380,9 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
378380
// the streaming card.
379381
if (streamText) {
380382
deliveredFinalTexts.add(streamText);
383+
if (options?.markClosedForReply !== false && !streamingCloseErroredForReply) {
384+
streamingClosedForReply = true;
385+
}
381386
}
382387
}
383388
} finally {
@@ -451,6 +456,8 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
451456
humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, agentId),
452457
onReplyStart: async () => {
453458
deliveredFinalTexts.clear();
459+
streamingClosedForReply = false;
460+
streamingCloseErroredForReply = false;
454461
if (streamingEnabled && renderMode === "card") {
455462
startStreaming();
456463
}
@@ -461,17 +468,25 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
461468
const text = reply.text;
462469
const hasText = reply.hasText;
463470
const hasMedia = reply.hasMedia;
471+
const useCard =
472+
hasText && (renderMode === "card" || (renderMode === "auto" && shouldUseCard(text)));
464473
const skipTextForDuplicateFinal =
465474
info?.kind === "final" && hasText && deliveredFinalTexts.has(text);
466-
const shouldDeliverText = hasText && !skipTextForDuplicateFinal;
475+
const skipTextForClosedStreamingFinal =
476+
info?.kind === "final" &&
477+
hasText &&
478+
streamingClosedForReply &&
479+
!streamingCloseErroredForReply &&
480+
streamingEnabled &&
481+
useCard;
482+
const shouldDeliverText =
483+
hasText && !skipTextForDuplicateFinal && !skipTextForClosedStreamingFinal;
467484

468485
if (!shouldDeliverText && !hasMedia) {
469486
return;
470487
}
471488

472489
if (shouldDeliverText) {
473-
const useCard = renderMode === "card" || (renderMode === "auto" && shouldUseCard(text));
474-
475490
if (info?.kind === "block") {
476491
// Drop internal block chunks unless we can safely consume them as
477492
// streaming-card fallback content.
@@ -556,10 +571,12 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
556571
}
557572
},
558573
onError: async (error, info) => {
574+
streamingCloseErroredForReply = true;
575+
streamingClosedForReply = false;
559576
params.runtime.error?.(
560577
`feishu[${account.accountId}] ${info.kind} reply failed: ${String(error)}`,
561578
);
562-
await closeStreaming();
579+
await closeStreaming({ markClosedForReply: false });
563580
typingCallbacks?.onIdle?.();
564581
},
565582
onIdle: async () => {

0 commit comments

Comments
 (0)