mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-30 08:12:21 +00:00
Feishu: close duplicate final gap and cover routing precedence
This commit is contained in:
@@ -11,6 +11,7 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
### Fixes
|
||||
|
||||
- Feishu/streaming card delivery synthesis: unify snapshot and delta streaming merge semantics, apply overlap-aware final merge, suppress duplicate final text delivery (including text+media final packets), prefer topic-thread `message.reply` routing when a reply target exists, and tune card print cadence to avoid duplicate incremental rendering. (from #33245, #32896, #33840) Thanks @rexl2018, @kcinzgg, and @aerelune.
|
||||
- Docs/tool-loop detection config keys: align `docs/tools/loop-detection.md` examples and field names with the current `tools.loopDetection` schema to prevent copy-paste validation failures from outdated keys. (#33182) Thanks @Mylszd.
|
||||
- Telegram/DM draft finalization reliability: require verified final-text draft emission before treating preview finalization as delivered, and fall back to normal payload send when final draft delivery is not confirmed (preventing missing final responses and preserving media/button delivery). (#32118) Thanks @OpenCils.
|
||||
- Discord/audit wildcard warnings: ignore "\*" wildcard keys when counting unresolved guild channels so doctor/status no longer warns on allow-all configs. (#33125) Thanks @thewilloftheshadow.
|
||||
|
||||
@@ -280,6 +280,46 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
|
||||
expect(sendMarkdownCardFeishuMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("suppresses duplicate final text while still sending media", async () => {
|
||||
resolveFeishuAccountMock.mockReturnValue({
|
||||
accountId: "main",
|
||||
appId: "app_id",
|
||||
appSecret: "app_secret",
|
||||
domain: "feishu",
|
||||
config: {
|
||||
renderMode: "auto",
|
||||
streaming: false,
|
||||
},
|
||||
});
|
||||
|
||||
createFeishuReplyDispatcher({
|
||||
cfg: {} as never,
|
||||
agentId: "agent",
|
||||
runtime: { log: vi.fn(), error: vi.fn() } as never,
|
||||
chatId: "oc_chat",
|
||||
});
|
||||
|
||||
const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0];
|
||||
await options.deliver({ text: "plain final" }, { kind: "final" });
|
||||
await options.deliver(
|
||||
{ text: "plain final", mediaUrl: "https://example.com/a.png" },
|
||||
{ kind: "final" },
|
||||
);
|
||||
|
||||
expect(sendMessageFeishuMock).toHaveBeenCalledTimes(1);
|
||||
expect(sendMessageFeishuMock).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
text: "plain final",
|
||||
}),
|
||||
);
|
||||
expect(sendMediaFeishuMock).toHaveBeenCalledTimes(1);
|
||||
expect(sendMediaFeishuMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
mediaUrl: "https://example.com/a.png",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("treats block updates as delta chunks", async () => {
|
||||
resolveFeishuAccountMock.mockReturnValue({
|
||||
accountId: "main",
|
||||
|
||||
@@ -165,7 +165,8 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
|
||||
lastPartial = nextText;
|
||||
}
|
||||
const mode = options?.mode ?? "snapshot";
|
||||
streamText = mode === "delta" ? `${streamText}${nextText}` : mergeStreamingText(streamText, nextText);
|
||||
streamText =
|
||||
mode === "delta" ? `${streamText}${nextText}` : mergeStreamingText(streamText, nextText);
|
||||
partialUpdateQueue = partialUpdateQueue.then(async () => {
|
||||
if (streamingStartPromise) {
|
||||
await streamingStartPromise;
|
||||
@@ -245,18 +246,14 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
|
||||
: [];
|
||||
const hasText = Boolean(text.trim());
|
||||
const hasMedia = mediaList.length > 0;
|
||||
const skipTextForDuplicateFinal = info?.kind === "final" && hasText && finalTextDelivered;
|
||||
const shouldDeliverText = hasText && !skipTextForDuplicateFinal;
|
||||
|
||||
if (info?.kind === "final" && hasText && finalTextDelivered) {
|
||||
if (!hasMedia) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasText && !hasMedia) {
|
||||
if (!shouldDeliverText && !hasMedia) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasText) {
|
||||
if (shouldDeliverText) {
|
||||
const useCard = renderMode === "card" || (renderMode === "auto" && shouldUseCard(text));
|
||||
|
||||
if (info?.kind === "block") {
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { mergeStreamingText } from "./streaming-card.js";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const fetchWithSsrFGuardMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("openclaw/plugin-sdk", () => ({
|
||||
fetchWithSsrFGuard: fetchWithSsrFGuardMock,
|
||||
}));
|
||||
|
||||
import { FeishuStreamingSession, mergeStreamingText } from "./streaming-card.js";
|
||||
|
||||
describe("mergeStreamingText", () => {
|
||||
it("prefers the latest full text when it already includes prior text", () => {
|
||||
@@ -23,3 +30,57 @@ describe("mergeStreamingText", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("FeishuStreamingSession routing", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
fetchWithSsrFGuardMock.mockReset();
|
||||
});
|
||||
|
||||
it("prefers message.reply when reply target and root id both exist", async () => {
|
||||
fetchWithSsrFGuardMock
|
||||
.mockResolvedValueOnce({
|
||||
response: { json: async () => ({ code: 0, msg: "ok", tenant_access_token: "token" }) },
|
||||
release: async () => {},
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
response: { json: async () => ({ code: 0, msg: "ok", data: { card_id: "card_1" } }) },
|
||||
release: async () => {},
|
||||
});
|
||||
|
||||
const replyMock = vi.fn(async () => ({ code: 0, data: { message_id: "msg_reply" } }));
|
||||
const createMock = vi.fn(async () => ({ code: 0, data: { message_id: "msg_create" } }));
|
||||
|
||||
const session = new FeishuStreamingSession(
|
||||
{
|
||||
im: {
|
||||
message: {
|
||||
reply: replyMock,
|
||||
create: createMock,
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
{
|
||||
appId: "app",
|
||||
appSecret: "secret",
|
||||
domain: "feishu",
|
||||
},
|
||||
);
|
||||
|
||||
await session.start("oc_chat", "chat_id", {
|
||||
replyToMessageId: "om_parent",
|
||||
replyInThread: true,
|
||||
rootId: "om_topic_root",
|
||||
});
|
||||
|
||||
expect(replyMock).toHaveBeenCalledTimes(1);
|
||||
expect(replyMock).toHaveBeenCalledWith({
|
||||
path: { message_id: "om_parent" },
|
||||
data: expect.objectContaining({
|
||||
msg_type: "interactive",
|
||||
reply_in_thread: true,
|
||||
}),
|
||||
});
|
||||
expect(createMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user