Skip to content

Commit 8a607d7

Browse files
CoobiwclaudeTakhoffman
authored
fix(feishu): fetch thread context so AI can see bot replies in topic threads (openclaw#45254)
* fix(feishu): fetch thread context so AI can see bot replies in topic threads When a user replies in a Feishu topic thread, the AI previously could only see the quoted parent message but not the bot's own prior replies in the thread. This made multi-turn conversations in threads feel broken. - Add `threadId` (omt_xxx) to `FeishuMessageInfo` and `getMessageFeishu` - Add `listFeishuThreadMessages()` using `container_id_type=thread` API to fetch all messages in a thread including bot replies - In `handleFeishuMessage`, fetch ThreadStarterBody and ThreadHistoryBody for topic session modes and pass them to the AI context - Reuse quoted message result when rootId === parentId to avoid redundant API calls; exclude root message from thread history to prevent duplication - Fall back to inbound ctx.threadId when rootId is absent or API fails - Fetch newest messages first (ByCreateTimeDesc + reverse) so long threads keep the most recent turns instead of the oldest Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix(feishu): skip redundant thread context injection on subsequent turns Only inject ThreadHistoryBody on the first turn of a thread session. On subsequent turns the session already contains prior context, so re-injecting thread history (and starter) would waste tokens. The heuristic checks whether the current user has already sent a non-root message in the thread — if so, the session has prior turns and thread context injection is skipped entirely. Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix(feishu): handle thread_id-only events in prior-turn detection When ctx.rootId is undefined (thread_id-only events), the starter message exclusion check `msg.messageId !== ctx.rootId` was always true, causing the first follow-up to be misclassified as a prior turn. Fall back to the first message in the chronologically-sorted thread history as the starter. Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix(feishu): bootstrap topic thread context via session state * test(memory): pin remote embedding hostnames in offline suites * fix(feishu): use plugin-safe session runtime for thread bootstrap --------- Co-authored-by: Claude Opus 4.6 <[email protected]> Co-authored-by: Tak Hoffman <[email protected]>
1 parent 3704293 commit 8a607d7

File tree

7 files changed

+483
-37
lines changed

7 files changed

+483
-37
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ Docs: https://docs.openclaw.ai
2222
- Agents/usage tracking: stop forcing `supportsUsageInStreaming: false` on non-native openai-completions endpoints so providers like DashScope, DeepSeek, and other OpenAI-compatible backends report token usage and cost instead of showing all zeros. (#46142)
2323
- Node/startup: remove leftover debug `console.log("node host PATH: ...")` that printed the resolved PATH on every `openclaw node run` invocation. (#46411)
2424
- Control UI/dashboard: preserve structured gateway shutdown reasons across restart disconnects so config-triggered restarts no longer fall back to `disconnected (1006): no reason`. (#46532) Thanks @vincentkoc.
25+
- Feishu/topic threads: fetch full thread context, including prior bot replies, when starting a topic-thread session so follow-up turns in Feishu topics keep the right conversation state. Thanks @Coobiw.
2526
- Browser/profiles: drop the auto-created `chrome-relay` browser profile; users who need the Chrome extension relay must now create their own profile via `openclaw browser create-profile`. (#45777) Thanks @odysseus0.
2627

2728
## 2026.3.13

extensions/feishu/src/bot.test.ts

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,12 @@ const {
1515
mockCreateFeishuReplyDispatcher,
1616
mockSendMessageFeishu,
1717
mockGetMessageFeishu,
18+
mockListFeishuThreadMessages,
1819
mockDownloadMessageResourceFeishu,
1920
mockCreateFeishuClient,
2021
mockResolveAgentRoute,
22+
mockReadSessionUpdatedAt,
23+
mockResolveStorePath,
2124
} = vi.hoisted(() => ({
2225
mockCreateFeishuReplyDispatcher: vi.fn(() => ({
2326
dispatcher: vi.fn(),
@@ -26,6 +29,7 @@ const {
2629
})),
2730
mockSendMessageFeishu: vi.fn().mockResolvedValue({ messageId: "pairing-msg", chatId: "oc-dm" }),
2831
mockGetMessageFeishu: vi.fn().mockResolvedValue(null),
32+
mockListFeishuThreadMessages: vi.fn().mockResolvedValue([]),
2933
mockDownloadMessageResourceFeishu: vi.fn().mockResolvedValue({
3034
buffer: Buffer.from("video"),
3135
contentType: "video/mp4",
@@ -40,6 +44,8 @@ const {
4044
mainSessionKey: "agent:main:main",
4145
matchedBy: "default",
4246
})),
47+
mockReadSessionUpdatedAt: vi.fn(),
48+
mockResolveStorePath: vi.fn(() => "/tmp/feishu-sessions.json"),
4349
}));
4450

4551
vi.mock("./reply-dispatcher.js", () => ({
@@ -49,6 +55,7 @@ vi.mock("./reply-dispatcher.js", () => ({
4955
vi.mock("./send.js", () => ({
5056
sendMessageFeishu: mockSendMessageFeishu,
5157
getMessageFeishu: mockGetMessageFeishu,
58+
listFeishuThreadMessages: mockListFeishuThreadMessages,
5259
}));
5360

5461
vi.mock("./media.js", () => ({
@@ -140,6 +147,8 @@ describe("handleFeishuMessage command authorization", () => {
140147
beforeEach(() => {
141148
vi.clearAllMocks();
142149
mockShouldComputeCommandAuthorized.mockReset().mockReturnValue(true);
150+
mockReadSessionUpdatedAt.mockReturnValue(undefined);
151+
mockResolveStorePath.mockReturnValue("/tmp/feishu-sessions.json");
143152
mockResolveAgentRoute.mockReturnValue({
144153
agentId: "main",
145154
channel: "feishu",
@@ -166,6 +175,12 @@ describe("handleFeishuMessage command authorization", () => {
166175
resolveAgentRoute:
167176
mockResolveAgentRoute as unknown as PluginRuntime["channel"]["routing"]["resolveAgentRoute"],
168177
},
178+
session: {
179+
readSessionUpdatedAt:
180+
mockReadSessionUpdatedAt as unknown as PluginRuntime["channel"]["session"]["readSessionUpdatedAt"],
181+
resolveStorePath:
182+
mockResolveStorePath as unknown as PluginRuntime["channel"]["session"]["resolveStorePath"],
183+
},
169184
reply: {
170185
resolveEnvelopeFormatOptions: vi.fn(
171186
() => ({}),
@@ -1709,6 +1724,123 @@ describe("handleFeishuMessage command authorization", () => {
17091724
);
17101725
});
17111726

1727+
it("bootstraps topic thread context only for a new thread session", async () => {
1728+
mockShouldComputeCommandAuthorized.mockReturnValue(false);
1729+
mockGetMessageFeishu.mockResolvedValue({
1730+
messageId: "om_topic_root",
1731+
chatId: "oc-group",
1732+
content: "root starter",
1733+
contentType: "text",
1734+
threadId: "omt_topic_1",
1735+
});
1736+
mockListFeishuThreadMessages.mockResolvedValue([
1737+
{
1738+
messageId: "om_bot_reply",
1739+
senderId: "app_1",
1740+
senderType: "app",
1741+
content: "assistant reply",
1742+
contentType: "text",
1743+
createTime: 1710000000000,
1744+
},
1745+
{
1746+
messageId: "om_follow_up",
1747+
senderId: "ou-topic-user",
1748+
senderType: "user",
1749+
content: "follow-up question",
1750+
contentType: "text",
1751+
createTime: 1710000001000,
1752+
},
1753+
]);
1754+
1755+
const cfg: ClawdbotConfig = {
1756+
channels: {
1757+
feishu: {
1758+
groups: {
1759+
"oc-group": {
1760+
requireMention: false,
1761+
groupSessionScope: "group_topic",
1762+
},
1763+
},
1764+
},
1765+
},
1766+
} as ClawdbotConfig;
1767+
1768+
const event: FeishuMessageEvent = {
1769+
sender: { sender_id: { open_id: "ou-topic-user" } },
1770+
message: {
1771+
message_id: "om_topic_followup_existing_session",
1772+
root_id: "om_topic_root",
1773+
chat_id: "oc-group",
1774+
chat_type: "group",
1775+
message_type: "text",
1776+
content: JSON.stringify({ text: "current turn" }),
1777+
},
1778+
};
1779+
1780+
await dispatchMessage({ cfg, event });
1781+
1782+
expect(mockReadSessionUpdatedAt).toHaveBeenCalledWith({
1783+
storePath: "/tmp/feishu-sessions.json",
1784+
sessionKey: "agent:main:feishu:dm:ou-attacker",
1785+
});
1786+
expect(mockListFeishuThreadMessages).toHaveBeenCalledWith(
1787+
expect.objectContaining({
1788+
rootMessageId: "om_topic_root",
1789+
}),
1790+
);
1791+
expect(mockFinalizeInboundContext).toHaveBeenCalledWith(
1792+
expect.objectContaining({
1793+
ThreadStarterBody: "root starter",
1794+
ThreadHistoryBody: "assistant reply\n\nfollow-up question",
1795+
ThreadLabel: "Feishu thread in oc-group",
1796+
MessageThreadId: "om_topic_root",
1797+
}),
1798+
);
1799+
});
1800+
1801+
it("skips topic thread bootstrap when the thread session already exists", async () => {
1802+
mockShouldComputeCommandAuthorized.mockReturnValue(false);
1803+
mockReadSessionUpdatedAt.mockReturnValue(1710000000000);
1804+
1805+
const cfg: ClawdbotConfig = {
1806+
channels: {
1807+
feishu: {
1808+
groups: {
1809+
"oc-group": {
1810+
requireMention: false,
1811+
groupSessionScope: "group_topic",
1812+
},
1813+
},
1814+
},
1815+
},
1816+
} as ClawdbotConfig;
1817+
1818+
const event: FeishuMessageEvent = {
1819+
sender: { sender_id: { open_id: "ou-topic-user" } },
1820+
message: {
1821+
message_id: "om_topic_followup",
1822+
root_id: "om_topic_root",
1823+
chat_id: "oc-group",
1824+
chat_type: "group",
1825+
message_type: "text",
1826+
content: JSON.stringify({ text: "current turn" }),
1827+
},
1828+
};
1829+
1830+
await dispatchMessage({ cfg, event });
1831+
1832+
expect(mockGetMessageFeishu).not.toHaveBeenCalled();
1833+
expect(mockListFeishuThreadMessages).not.toHaveBeenCalled();
1834+
expect(mockFinalizeInboundContext).toHaveBeenCalledWith(
1835+
expect.objectContaining({
1836+
ThreadStarterBody: undefined,
1837+
ThreadHistoryBody: undefined,
1838+
ThreadLabel: "Feishu thread in oc-group",
1839+
MessageThreadId: "om_topic_root",
1840+
}),
1841+
);
1842+
});
1843+
17121844
it("does not dispatch twice for the same image message_id (concurrent dedupe)", async () => {
17131845
mockShouldComputeCommandAuthorized.mockReturnValue(false);
17141846

0 commit comments

Comments
 (0)