From e5a42c0becd681f4760d1082bddd3fef4f04bb8b Mon Sep 17 00:00:00 2001 From: Tak Hoffman <781889+Takhoffman@users.noreply.github.com> Date: Sat, 14 Mar 2026 18:47:05 -0500 Subject: [PATCH] fix(feishu): keep sender-scoped thread bootstrap across id types (#46651) --- extensions/feishu/src/bot.test.ts | 76 ++++++++++++++++++++++++++++++- extensions/feishu/src/bot.ts | 24 +++++++--- 2 files changed, 92 insertions(+), 8 deletions(-) diff --git a/extensions/feishu/src/bot.test.ts b/extensions/feishu/src/bot.test.ts index 5de21aa825b..4e0dd9d4fed 100644 --- a/extensions/feishu/src/bot.test.ts +++ b/extensions/feishu/src/bot.test.ts @@ -77,11 +77,13 @@ function createRuntimeEnv(): RuntimeEnv { } async function dispatchMessage(params: { cfg: ClawdbotConfig; event: FeishuMessageEvent }) { + const runtime = createRuntimeEnv(); await handleFeishuMessage({ cfg: params.cfg, event: params.event, - runtime: createRuntimeEnv(), + runtime, }); + return runtime; } describe("buildFeishuAgentBody", () => { @@ -147,6 +149,8 @@ describe("handleFeishuMessage command authorization", () => { beforeEach(() => { vi.clearAllMocks(); mockShouldComputeCommandAuthorized.mockReset().mockReturnValue(true); + mockGetMessageFeishu.mockReset().mockResolvedValue(null); + mockListFeishuThreadMessages.mockReset().mockResolvedValue([]); mockReadSessionUpdatedAt.mockReturnValue(undefined); mockResolveStorePath.mockReturnValue("/tmp/feishu-sessions.json"); mockResolveAgentRoute.mockReturnValue({ @@ -1841,6 +1845,76 @@ describe("handleFeishuMessage command authorization", () => { ); }); + it("keeps sender-scoped thread history when the inbound event and thread history use different sender ids", async () => { + mockShouldComputeCommandAuthorized.mockReturnValue(false); + mockGetMessageFeishu.mockResolvedValue({ + messageId: "om_topic_root", + chatId: "oc-group", + content: "root starter", + contentType: "text", + threadId: "omt_topic_1", + }); + mockListFeishuThreadMessages.mockResolvedValue([ + { + messageId: "om_bot_reply", + senderId: "app_1", + senderType: "app", + content: "assistant reply", + contentType: "text", + createTime: 1710000000000, + }, + { + messageId: "om_follow_up", + senderId: "user_topic_1", + senderType: "user", + content: "follow-up question", + contentType: "text", + createTime: 1710000001000, + }, + ]); + + const cfg: ClawdbotConfig = { + channels: { + feishu: { + groups: { + "oc-group": { + requireMention: false, + groupSessionScope: "group_topic_sender", + }, + }, + }, + }, + } as ClawdbotConfig; + + const event: FeishuMessageEvent = { + sender: { + sender_id: { + open_id: "ou-topic-user", + user_id: "user_topic_1", + }, + }, + message: { + message_id: "om_topic_followup_mixed_ids", + root_id: "om_topic_root", + chat_id: "oc-group", + chat_type: "group", + message_type: "text", + content: JSON.stringify({ text: "current turn" }), + }, + }; + + await dispatchMessage({ cfg, event }); + + expect(mockFinalizeInboundContext).toHaveBeenCalledWith( + expect.objectContaining({ + ThreadStarterBody: "root starter", + ThreadHistoryBody: "assistant reply\n\nfollow-up question", + ThreadLabel: "Feishu thread in oc-group", + MessageThreadId: "om_topic_root", + }), + ); + }); + it("does not dispatch twice for the same image message_id (concurrent dedupe)", async () => { mockShouldComputeCommandAuthorized.mockReturnValue(false); diff --git a/extensions/feishu/src/bot.ts b/extensions/feishu/src/bot.ts index 980a9769d7a..c7943eda7b1 100644 --- a/extensions/feishu/src/bot.ts +++ b/extensions/feishu/src/bot.ts @@ -1406,15 +1406,25 @@ export async function handleFeishuMessage(params: { accountId: account.accountId, }); const senderScoped = groupSession?.groupSessionScope === "group_topic_sender"; - const relevantMessages = senderScoped - ? threadMessages.filter( - (msg) => msg.senderType === "app" || msg.senderId === ctx.senderOpenId, - ) - : threadMessages; + const senderIds = new Set( + [ctx.senderOpenId, senderUserId] + .map((id) => id?.trim()) + .filter((id): id is string => id !== undefined && id.length > 0), + ); + const relevantMessages = + (senderScoped + ? threadMessages.filter( + (msg) => + msg.senderType === "app" || + (msg.senderId !== undefined && senderIds.has(msg.senderId.trim())), + ) + : threadMessages) ?? []; const threadStarterBody = rootMsg?.content ?? relevantMessages[0]?.content; - const historyMessages = - rootMsg?.content || ctx.rootId ? relevantMessages : relevantMessages.slice(1); + const includeStarterInHistory = Boolean(rootMsg?.content || ctx.rootId); + const historyMessages = includeStarterInHistory + ? relevantMessages + : relevantMessages.slice(1); const historyParts = historyMessages.map((msg) => { const role = msg.senderType === "app" ? "assistant" : "user"; return core.channel.reply.formatAgentEnvelope({