Skip to content

Commit d609f71

Browse files
authored
fix(feishu): gate reasoning previews to stream sessions (#61271)
1 parent 64cf52c commit d609f71

9 files changed

Lines changed: 146 additions & 3 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,7 @@ Docs: https://docs.openclaw.ai
138138
- Plugin SDK/context engines: export the missing context-engine result and subagent lifecycle types from `openclaw/plugin-sdk` so context engine plugins can type `ContextEngine` implementations without local workarounds. (#61251) Thanks @DaevMithran.
139139
- Agents/errors: surface an explicit disk-full message when local session or transcript writes fail with `ENOSPC`/`disk full`, so those runs stop degrading into opaque `NO_REPLY`-style failures. Thanks @vincentkoc.
140140
- Telegram/reasoning: only create a Telegram reasoning preview lane when the session is explicitly `reasoning:stream`, so hidden `<think>` traces from streamed replies stop surfacing as chat previews on normal sessions. Thanks @vincentkoc.
141+
- Feishu/reasoning: only expose streamed reasoning previews when the session is explicitly `reasoning:stream`, so hidden reasoning traces do not surface on normal streaming sessions. Thanks @vincentkoc.
141142

142143
## 2026.4.2
143144

extensions/feishu/runtime-api.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ export {
3939
filterSupplementalContextItems,
4040
resolveChannelContextVisibilityMode,
4141
} from "openclaw/plugin-sdk/config-runtime";
42+
export { loadSessionStore, resolveSessionStoreEntry } from "openclaw/plugin-sdk/config-runtime";
4243
export { readJsonFileWithFallback } from "openclaw/plugin-sdk/json-store";
4344
export { createPersistentDedupe } from "openclaw/plugin-sdk/persistent-dedupe";
4445
export { normalizeAgentId } from "openclaw/plugin-sdk/routing";

extensions/feishu/src/bot-runtime-api.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,4 @@ export {
99
filterSupplementalContextItems,
1010
normalizeAgentId,
1111
} from "../runtime-api.js";
12+
export { loadSessionStore, resolveSessionStoreEntry } from "../runtime-api.js";

extensions/feishu/src/bot.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,7 @@ const {
236236
mockEnsureConfiguredBindingRouteReady,
237237
mockResolveBoundConversation,
238238
mockTouchBinding,
239+
mockResolveFeishuReasoningPreviewEnabled,
239240
} = vi.hoisted(() => ({
240241
mockCreateFeishuReplyDispatcher: vi.fn(() => ({
241242
dispatcher: createReplyDispatcher(),
@@ -269,12 +270,17 @@ const {
269270
),
270271
mockResolveBoundConversation: vi.fn(() => null as BoundConversation),
271272
mockTouchBinding: vi.fn(),
273+
mockResolveFeishuReasoningPreviewEnabled: vi.fn(() => false),
272274
}));
273275

274276
vi.mock("./reply-dispatcher.js", () => ({
275277
createFeishuReplyDispatcher: mockCreateFeishuReplyDispatcher,
276278
}));
277279

280+
vi.mock("./reasoning-preview.js", () => ({
281+
resolveFeishuReasoningPreviewEnabled: mockResolveFeishuReasoningPreviewEnabled,
282+
}));
283+
278284
vi.mock("./send.js", () => ({
279285
sendMessageFeishu: mockSendMessageFeishu,
280286
getMessageFeishu: mockGetMessageFeishu,
@@ -332,6 +338,7 @@ describe("handleFeishuMessage ACP routing", () => {
332338
mockEnsureConfiguredBindingRouteReady.mockReset().mockResolvedValue({ ok: true });
333339
mockResolveBoundConversation.mockReset().mockReturnValue(null);
334340
mockTouchBinding.mockReset();
341+
mockResolveFeishuReasoningPreviewEnabled.mockReset().mockReturnValue(false);
335342
mockResolveAgentRoute.mockReset().mockReturnValue({
336343
...buildDefaultResolveRoute(),
337344
sessionKey: "agent:main:feishu:direct:ou_sender_1",
@@ -444,6 +451,31 @@ describe("handleFeishuMessage ACP routing", () => {
444451
);
445452
expect(mockTouchBinding).toHaveBeenCalledWith("default:oc_group_chat:topic:om_topic_root");
446453
});
454+
455+
it("passes reasoning preview permission from session state into the dispatcher", async () => {
456+
mockResolveFeishuReasoningPreviewEnabled.mockReturnValue(true);
457+
458+
await dispatchMessage({
459+
cfg: {
460+
session: { mainKey: "main", scope: "per-sender" },
461+
channels: { feishu: { enabled: true, allowFrom: ["ou_sender_1"], dmPolicy: "open" } },
462+
},
463+
event: {
464+
sender: { sender_id: { open_id: "ou_sender_1" } },
465+
message: {
466+
message_id: "msg-reasoning",
467+
chat_id: "oc_dm",
468+
chat_type: "p2p",
469+
message_type: "text",
470+
content: JSON.stringify({ text: "hello" }),
471+
},
472+
},
473+
});
474+
475+
expect(mockCreateFeishuReplyDispatcher).toHaveBeenCalledWith(
476+
expect.objectContaining({ allowReasoningPreview: true }),
477+
);
478+
});
447479
});
448480

449481
describe("handleFeishuMessage command authorization", () => {

extensions/feishu/src/bot.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ import {
4949
resolveFeishuAllowlistMatch,
5050
isFeishuGroupAllowed,
5151
} from "./policy.js";
52+
import { resolveFeishuReasoningPreviewEnabled } from "./reasoning-preview.js";
5253
import { createFeishuReplyDispatcher } from "./reply-dispatcher.js";
5354
import { getFeishuRuntime } from "./runtime.js";
5455
import { getMessageFeishu, listFeishuThreadMessages, sendMessageFeishu } from "./send.js";
@@ -1112,6 +1113,10 @@ export async function handleFeishuMessage(params: {
11121113
}
11131114

11141115
const agentSessionKey = buildBroadcastSessionKey(route.sessionKey, route.agentId, agentId);
1116+
const allowReasoningPreview = resolveFeishuReasoningPreviewEnabled({
1117+
storePath: core.channel.session.resolveStorePath(cfg.session?.store, { agentId }),
1118+
sessionKey: agentSessionKey,
1119+
});
11151120
const agentCtx = await buildCtxPayloadForAgent(
11161121
agentId,
11171122
agentSessionKey,
@@ -1127,6 +1132,7 @@ export async function handleFeishuMessage(params: {
11271132
agentId,
11281133
runtime: runtime as RuntimeEnv,
11291134
chatId: ctx.chatId,
1135+
allowReasoningPreview,
11301136
replyToMessageId: replyTargetMessageId,
11311137
skipReplyToInMessages: !isGroup,
11321138
replyInThread,
@@ -1224,11 +1230,18 @@ export async function handleFeishuMessage(params: {
12241230
);
12251231

12261232
const identity = resolveAgentOutboundIdentity(cfg, route.agentId);
1233+
const allowReasoningPreview = resolveFeishuReasoningPreviewEnabled({
1234+
storePath: core.channel.session.resolveStorePath(cfg.session?.store, {
1235+
agentId: route.agentId,
1236+
}),
1237+
sessionKey: route.sessionKey,
1238+
});
12271239
const { dispatcher, replyOptions, markDispatchIdle } = createFeishuReplyDispatcher({
12281240
cfg,
12291241
agentId: route.agentId,
12301242
runtime: runtime as RuntimeEnv,
12311243
chatId: ctx.chatId,
1244+
allowReasoningPreview,
12321245
replyToMessageId: replyTargetMessageId,
12331246
skipReplyToInMessages: !isGroup,
12341247
replyInThread,
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { beforeEach, describe, expect, it, vi } from "vitest";
2+
import { resolveFeishuReasoningPreviewEnabled } from "./reasoning-preview.js";
3+
4+
const { loadSessionStoreMock } = vi.hoisted(() => ({
5+
loadSessionStoreMock: vi.fn(),
6+
}));
7+
8+
vi.mock("./bot-runtime-api.js", async () => {
9+
const actual =
10+
await vi.importActual<typeof import("./bot-runtime-api.js")>("./bot-runtime-api.js");
11+
return {
12+
...actual,
13+
loadSessionStore: loadSessionStoreMock,
14+
};
15+
});
16+
17+
describe("resolveFeishuReasoningPreviewEnabled", () => {
18+
beforeEach(() => {
19+
vi.clearAllMocks();
20+
});
21+
22+
it("enables previews only for stream reasoning sessions", () => {
23+
loadSessionStoreMock.mockReturnValue({
24+
"agent:main:feishu:dm:ou_sender_1": { reasoningLevel: "stream" },
25+
"agent:main:feishu:dm:ou_sender_2": { reasoningLevel: "on" },
26+
});
27+
28+
expect(
29+
resolveFeishuReasoningPreviewEnabled({
30+
storePath: "/tmp/feishu-sessions.json",
31+
sessionKey: "agent:main:feishu:dm:ou_sender_1",
32+
}),
33+
).toBe(true);
34+
expect(
35+
resolveFeishuReasoningPreviewEnabled({
36+
storePath: "/tmp/feishu-sessions.json",
37+
sessionKey: "agent:main:feishu:dm:ou_sender_2",
38+
}),
39+
).toBe(false);
40+
});
41+
42+
it("returns false for missing sessions or load failures", () => {
43+
loadSessionStoreMock.mockImplementationOnce(() => {
44+
throw new Error("disk unavailable");
45+
});
46+
47+
expect(
48+
resolveFeishuReasoningPreviewEnabled({
49+
storePath: "/tmp/feishu-sessions.json",
50+
sessionKey: "agent:main:feishu:dm:ou_sender_1",
51+
}),
52+
).toBe(false);
53+
expect(
54+
resolveFeishuReasoningPreviewEnabled({
55+
storePath: "/tmp/feishu-sessions.json",
56+
}),
57+
).toBe(false);
58+
});
59+
});
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { loadSessionStore, resolveSessionStoreEntry } from "./bot-runtime-api.js";
2+
3+
export function resolveFeishuReasoningPreviewEnabled(params: {
4+
storePath: string;
5+
sessionKey?: string;
6+
}): boolean {
7+
if (!params.sessionKey) {
8+
return false;
9+
}
10+
11+
try {
12+
const store = loadSessionStore(params.storePath, { skipCache: true });
13+
return (
14+
resolveSessionStoreEntry({ store, sessionKey: params.sessionKey }).existing
15+
?.reasoningLevel === "stream"
16+
);
17+
} catch {
18+
return false;
19+
}
20+
}

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

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -524,6 +524,7 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
524524
it("streams reasoning content as blockquote before answer", async () => {
525525
const { result, options } = createDispatcherHarness({
526526
runtime: createRuntimeLogger(),
527+
allowReasoningPreview: true,
527528
});
528529

529530
await options.onReplyStart?.();
@@ -557,15 +558,25 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
557558
expect(closeArg).toContain("answer part final");
558559
});
559560

560-
it("provides onReasoningStream and onReasoningEnd when streaming is enabled", () => {
561+
it("provides onReasoningStream and onReasoningEnd when reasoning previews are allowed", () => {
561562
const { result } = createDispatcherHarness({
562563
runtime: createRuntimeLogger(),
564+
allowReasoningPreview: true,
563565
});
564566

565567
expect(result.replyOptions.onReasoningStream).toBeTypeOf("function");
566568
expect(result.replyOptions.onReasoningEnd).toBeTypeOf("function");
567569
});
568570

571+
it("omits reasoning callbacks unless reasoning previews are allowed", () => {
572+
const { result } = createDispatcherHarness({
573+
runtime: createRuntimeLogger(),
574+
});
575+
576+
expect(result.replyOptions.onReasoningStream).toBeUndefined();
577+
expect(result.replyOptions.onReasoningEnd).toBeUndefined();
578+
});
579+
569580
it("omits reasoning callbacks when streaming is disabled", () => {
570581
resolveFeishuAccountMock.mockReturnValue({
571582
accountId: "main",
@@ -589,6 +600,7 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
589600
it("renders reasoning-only card when no answer text arrives", async () => {
590601
const { result, options } = createDispatcherHarness({
591602
runtime: createRuntimeLogger(),
603+
allowReasoningPreview: true,
592604
});
593605

594606
await options.onReplyStart?.();
@@ -608,6 +620,7 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
608620
it("ignores empty reasoning payloads", async () => {
609621
const { result, options } = createDispatcherHarness({
610622
runtime: createRuntimeLogger(),
623+
allowReasoningPreview: true,
611624
});
612625

613626
await options.onReplyStart?.();
@@ -624,6 +637,7 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
624637
it("deduplicates final text by raw answer payload, not combined card text", async () => {
625638
const { result, options } = createDispatcherHarness({
626639
runtime: createRuntimeLogger(),
640+
allowReasoningPreview: true,
627641
});
628642

629643
await options.onReplyStart?.();

extensions/feishu/src/reply-dispatcher.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ export type CreateFeishuReplyDispatcherParams = {
7777
agentId: string;
7878
runtime: RuntimeEnv;
7979
chatId: string;
80+
allowReasoningPreview?: boolean;
8081
replyToMessageId?: string;
8182
/** When true, preserve typing indicator on reply target but send messages without reply metadata */
8283
skipReplyToInMessages?: boolean;
@@ -188,6 +189,7 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
188189
// Card streaming may miss thread affinity in topic contexts; use direct replies there.
189190
const streamingEnabled =
190191
!threadReplyMode && account.config?.streaming !== false && renderMode !== "raw";
192+
const reasoningPreviewEnabled = streamingEnabled && params.allowReasoningPreview === true;
191193

192194
let streaming: FeishuStreamingSession | null = null;
193195
let streamText = "";
@@ -491,7 +493,7 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
491493
});
492494
}
493495
: undefined,
494-
onReasoningStream: streamingEnabled
496+
onReasoningStream: reasoningPreviewEnabled
495497
? (payload: ReplyPayload) => {
496498
if (!payload.text) {
497499
return;
@@ -500,7 +502,7 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
500502
queueReasoningUpdate(payload.text);
501503
}
502504
: undefined,
503-
onReasoningEnd: streamingEnabled ? () => {} : undefined,
505+
onReasoningEnd: reasoningPreviewEnabled ? () => {} : undefined,
504506
},
505507
markDispatchIdle,
506508
};

0 commit comments

Comments
 (0)