Skip to content

Commit 84a22a6

Browse files
authored
fix(feishu): finish streaming card closeout
1 parent 935cd34 commit 84a22a6

3 files changed

Lines changed: 278 additions & 32 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ Docs: https://docs.openclaw.ai
3333
- Cron: tolerate malformed legacy job rows in startup, main-session system-event payloads, and human-readable `cron list` output so missing `state`, `payload.text`, or display fields no longer crash the scheduler or CLI. Fixes #66016, #65916, #64137, #57872, #59968, #63813, #52804, and #43163. (#71509) Thanks @vincentkoc.
3434
- CLI/models: make `openclaw models scan` fall back to public OpenRouter free-model metadata when no `OPENROUTER_API_KEY` is configured, avoid config secret resolution for explicit `--no-probe` scans, and apply the scan timeout to the OpenRouter catalog request.
3535
- Feishu: keep streaming cards to one live card per turn, flush throttled card edits after meaningful text boundaries, and skip exact block/partial repeats so tool-heavy replies do not duplicate card output. Thanks @allan0509.
36+
- Feishu: finish the streaming-card duplicate closeout by stripping leaked reasoning tags, preserving cross-block partial snapshots, enabling topic-thread streaming cards, omitting the generic `main` card header, surfacing transient tool/compaction status, and cleaning streaming state after close failures. Thanks @sesame437, @Vicky-v7, @maoku-family, @Pengxiao-Wang, and @Maple778.
3637
- Heartbeat: clamp oversized scheduler delays through the shared safe timer helper, preventing `every` values over Node's timeout cap from becoming a 1 ms crash loop. Fixes #71414. (#71478) Thanks @hclsys.
3738
- Control UI/chat: collapse assistant token/model context details behind an explicit Context disclosure and show full dates in message footers, making historical transcript timing clear without noisy default metadata. (#71337) Thanks @BunsDev.
3839
- OpenAI/Codex OAuth: explain `unsupported_country_region_territory` token-exchange failures with a proxy/region hint instead of surfacing a generic OAuth error. Fixes #51175. (#71501) Thanks @vincentkoc and @wulala-xjj.

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

Lines changed: 175 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -491,6 +491,64 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
491491
});
492492
});
493493

494+
it("preserves previous generation blocks when partial snapshots reset after tools", async () => {
495+
resolveFeishuAccountMock.mockReturnValue({
496+
accountId: "main",
497+
appId: "app_id",
498+
appSecret: "app_secret",
499+
domain: "feishu",
500+
config: {
501+
renderMode: "card",
502+
streaming: true,
503+
},
504+
});
505+
506+
const { result, options } = createDispatcherHarness({
507+
runtime: createRuntimeLogger(),
508+
});
509+
await options.onReplyStart?.();
510+
result.replyOptions.onPartialReply?.({
511+
text: "Preparing the lookup plan with enough text to count as one block.",
512+
});
513+
result.replyOptions.onPartialReply?.({ text: "Found" });
514+
result.replyOptions.onPartialReply?.({ text: "Found the answer." });
515+
await options.onIdle?.();
516+
517+
expect(streamingInstances).toHaveLength(1);
518+
expect(streamingInstances[0].close).toHaveBeenCalledWith(
519+
"Preparing the lookup plan with enough text to count as one block.Found the answer.",
520+
{
521+
note: "Agent: agent",
522+
},
523+
);
524+
});
525+
526+
it("strips reasoning tags from streamed partial snapshots", async () => {
527+
resolveFeishuAccountMock.mockReturnValue({
528+
accountId: "main",
529+
appId: "app_id",
530+
appSecret: "app_secret",
531+
domain: "feishu",
532+
config: {
533+
renderMode: "card",
534+
streaming: true,
535+
},
536+
});
537+
538+
const { result, options } = createDispatcherHarness({
539+
runtime: createRuntimeLogger(),
540+
});
541+
await options.onReplyStart?.();
542+
result.replyOptions.onPartialReply?.({
543+
text: "<thinking>private chain of thought</thinking>\nvisible answer",
544+
});
545+
await options.onIdle?.();
546+
547+
expect(streamingInstances[0].close).toHaveBeenCalledWith("visible answer", {
548+
note: "Agent: agent",
549+
});
550+
});
551+
494552
it("sends media-only payloads as attachments", async () => {
495553
const { options } = createDispatcherHarness();
496554
await options.deliver({ mediaUrl: "https://example.com/a.png" }, { kind: "final" });
@@ -757,7 +815,7 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
757815
);
758816
});
759817

760-
it("disables streaming for thread replies and keeps reply metadata", async () => {
818+
it("uses streaming cards for thread replies and keeps topic metadata", async () => {
761819
const { options } = createDispatcherHarness({
762820
runtime: createRuntimeLogger(),
763821
replyToMessageId: "om_msg",
@@ -767,13 +825,127 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
767825
});
768826
await options.deliver({ text: "```ts\nconst x = 1\n```" }, { kind: "final" });
769827

770-
expect(streamingInstances).toHaveLength(0);
771-
expect(sendStructuredCardFeishuMock).toHaveBeenCalledWith(
828+
expect(streamingInstances).toHaveLength(1);
829+
expect(streamingInstances[0].start).toHaveBeenCalledWith(
830+
"oc_chat",
831+
"chat_id",
772832
expect.objectContaining({
773833
replyToMessageId: "om_msg",
774834
replyInThread: true,
835+
rootId: "om_root_topic",
775836
}),
776837
);
838+
expect(sendStructuredCardFeishuMock).not.toHaveBeenCalled();
839+
});
840+
841+
it("omits the generic main header from streaming and static cards", async () => {
842+
resolveFeishuAccountMock.mockReturnValue({
843+
accountId: "main",
844+
appId: "app_id",
845+
appSecret: "app_secret",
846+
domain: "feishu",
847+
config: {
848+
renderMode: "card",
849+
streaming: true,
850+
},
851+
});
852+
853+
const { options } = createDispatcherHarness({
854+
agentId: "main",
855+
runtime: createRuntimeLogger(),
856+
});
857+
await options.deliver({ text: "streamed card" }, { kind: "final" });
858+
await options.onIdle?.();
859+
860+
expect(streamingInstances[0].start).toHaveBeenCalledWith(
861+
"oc_chat",
862+
"chat_id",
863+
expect.objectContaining({
864+
header: undefined,
865+
}),
866+
);
867+
868+
resolveFeishuAccountMock.mockReturnValue({
869+
accountId: "main",
870+
appId: "app_id",
871+
appSecret: "app_secret",
872+
domain: "feishu",
873+
config: {
874+
renderMode: "card",
875+
streaming: false,
876+
},
877+
});
878+
879+
const { options: staticOptions } = createDispatcherHarness({
880+
agentId: "main",
881+
runtime: createRuntimeLogger(),
882+
});
883+
await staticOptions.deliver({ text: "static card" }, { kind: "final" });
884+
885+
expect(sendStructuredCardFeishuMock).toHaveBeenCalledWith(
886+
expect.objectContaining({
887+
header: undefined,
888+
}),
889+
);
890+
});
891+
892+
it("shows transient tool status on streaming cards but omits it from the final close", async () => {
893+
resolveFeishuAccountMock.mockReturnValue({
894+
accountId: "main",
895+
appId: "app_id",
896+
appSecret: "app_secret",
897+
domain: "feishu",
898+
config: {
899+
renderMode: "card",
900+
streaming: true,
901+
},
902+
});
903+
904+
const { result, options } = createDispatcherHarness({
905+
runtime: createRuntimeLogger(),
906+
});
907+
await options.onReplyStart?.();
908+
result.replyOptions.onToolStart?.({ name: "web_search" });
909+
result.replyOptions.onPartialReply?.({ text: "final answer" });
910+
await options.onIdle?.();
911+
912+
const updateTexts = streamingInstances[0].update.mock.calls.map((call: unknown[]) =>
913+
typeof call[0] === "string" ? call[0] : "",
914+
);
915+
expect(updateTexts.some((text) => text.includes("Using: web_search"))).toBe(true);
916+
expect(streamingInstances[0].close).toHaveBeenCalledWith("final answer", {
917+
note: "Agent: agent",
918+
});
919+
});
920+
921+
it("cleans streaming state even when close throws", async () => {
922+
const origPush = streamingInstances.push.bind(streamingInstances);
923+
streamingInstances.push = (...args: StreamingSessionStub[]) => {
924+
if (args.length > 0 && streamingInstances.length === 0) {
925+
args[0].close = vi.fn(async () => {
926+
args[0].active = false;
927+
throw new Error("close failed");
928+
});
929+
}
930+
return origPush(...args);
931+
};
932+
933+
try {
934+
const { options } = createDispatcherHarness({
935+
runtime: createRuntimeLogger(),
936+
});
937+
await options.deliver({ text: "```md\nfirst\n```" }, { kind: "final" });
938+
await expect(options.onIdle?.()).rejects.toThrow("close failed");
939+
await options.deliver({ text: "```md\nsecond\n```" }, { kind: "final" });
940+
await options.onIdle?.();
941+
942+
expect(streamingInstances).toHaveLength(2);
943+
expect(streamingInstances[1].close).toHaveBeenCalledWith("```md\nsecond\n```", {
944+
note: "Agent: agent",
945+
});
946+
} finally {
947+
streamingInstances.push = origPush;
948+
}
777949
});
778950

779951
it("passes replyInThread to media attachments", async () => {

0 commit comments

Comments
 (0)