fix(feishu): honor block streaming config · openclaw/openclaw@03e35b1
vincentkoc
·
2026-05-04
·
via Recent Commits to openclaw:main
| Original file line number | Diff line number | Diff line change |
|---|
@@ -29,6 +29,7 @@ Docs: https://docs.openclaw.ai
|
29 | 29 | |
30 | 30 | ### Fixes |
31 | 31 | |
| 32 | +- Feishu: accept and honor `channels.feishu.blockStreaming` at the top level and per account, while keeping the legacy default off so Feishu cards no longer reject documented config or silently drop block replies. Fixes #75555. Thanks @vincentkoc. |
32 | 33 | - Google Chat: normalize custom Google auth transport headers before google-auth/gaxios interceptors run, restoring webhook token verification when certificate retrieval expects Fetch `Headers`. Fixes #76742. Thanks @donbowman. |
33 | 34 | - Doctor/plugins: reset stale `plugins.slots.memory` and `plugins.slots.contextEngine` references during `doctor --fix`, so cleanup of missing plugin config does not leave unrecoverable slot owners behind. Fixes #76550 and #76551. Thanks @vincentkoc. |
34 | 35 | - Docs/WhatsApp: merge the duplicate top-level `web` objects in the gateway channel config example so copy-pasted WhatsApp config keeps both `web.whatsapp` and reconnect settings. Fixes #76619. Thanks @WadydX. |
|
| Original file line number | Diff line number | Diff line change |
|---|
@@ -273,13 +273,13 @@ Feishu/Lark supports streaming replies via interactive cards. When enabled, the
|
273 | 273 | channels: { |
274 | 274 | feishu: { |
275 | 275 | streaming: true, // enable streaming card output (default: true) |
276 | | - blockStreaming: true, // enable block-level streaming (default: true) |
| 276 | + blockStreaming: true, // opt into completed-block streaming |
277 | 277 | }, |
278 | 278 | }, |
279 | 279 | } |
280 | 280 | ``` |
281 | 281 | |
282 | | -Set `streaming: false` to send the complete reply in one message. |
| 282 | +Set `streaming: false` to send the complete reply in one message. `blockStreaming` is off by default; enable it only when you want completed assistant blocks flushed before the final reply. |
283 | 283 | |
284 | 284 | ### Quota optimization |
285 | 285 | |
@@ -428,7 +428,7 @@ Full configuration: [Gateway configuration](/gateway/configuration)
|
428 | 428 | | `channels.feishu.textChunkLimit` | Message chunk size | `2000` | |
429 | 429 | | `channels.feishu.mediaMaxMb` | Media size limit | `30` | |
430 | 430 | | `channels.feishu.streaming` | Streaming card output | `true` | |
431 | | -| `channels.feishu.blockStreaming` | Block-level streaming | `true` | |
| 431 | +| `channels.feishu.blockStreaming` | Completed-block reply streaming | `false` | |
432 | 432 | | `channels.feishu.typingIndicator` | Send typing reactions | `true` | |
433 | 433 | | `channels.feishu.resolveSenderNames` | Resolve sender display names | `true` | |
434 | 434 | |
|
| Original file line number | Diff line number | Diff line change |
|---|
@@ -126,6 +126,7 @@
|
126 | 126 | "enum": ["auto", "raw", "card"] |
127 | 127 | }, |
128 | 128 | "streaming": { "type": "boolean" }, |
| 129 | +"blockStreaming": { "type": "boolean" }, |
129 | 130 | "replyInThread": { |
130 | 131 | "type": "string", |
131 | 132 | "enum": ["disabled", "enabled"] |
|
162 | 163 | "enum": ["auto", "raw", "card"] |
163 | 164 | }, |
164 | 165 | "streaming": { "type": "boolean" }, |
| 166 | +"blockStreaming": { "type": "boolean" }, |
165 | 167 | "replyInThread": { |
166 | 168 | "type": "string", |
167 | 169 | "enum": ["disabled", "enabled"] |
|
| Original file line number | Diff line number | Diff line change |
|---|
@@ -206,6 +206,20 @@ describe("FeishuConfigSchema optimization flags", () => {
|
206 | 206 | expect(result.resolveSenderNames).toBe(true); |
207 | 207 | }); |
208 | 208 | |
| 209 | +it("accepts top-level and account-level block streaming", () => { |
| 210 | +const result = FeishuConfigSchema.parse({ |
| 211 | +blockStreaming: true, |
| 212 | +accounts: { |
| 213 | +main: { |
| 214 | +blockStreaming: false, |
| 215 | +}, |
| 216 | +}, |
| 217 | +}); |
| 218 | + |
| 219 | +expect(result.blockStreaming).toBe(true); |
| 220 | +expect(result.accounts?.main?.blockStreaming).toBe(false); |
| 221 | +}); |
| 222 | + |
209 | 223 | it("accepts account-level optimization flags", () => { |
210 | 224 | const result = FeishuConfigSchema.parse({ |
211 | 225 | accounts: { |
|
| Original file line number | Diff line number | Diff line change |
|---|
@@ -68,6 +68,7 @@ const RenderModeSchema = z.enum(["auto", "raw", "card"]).optional();
|
68 | 68 | // Streaming card mode: when enabled, card replies use Feishu's Card Kit streaming API |
69 | 69 | // for incremental text display with a "Thinking..." placeholder |
70 | 70 | const StreamingModeSchema = z.boolean().optional(); |
| 71 | +const BlockStreamingSchema = z.boolean().optional(); |
71 | 72 | |
72 | 73 | const BlockStreamingCoalesceSchema = z |
73 | 74 | .object({ |
@@ -188,6 +189,7 @@ const FeishuSharedConfigShape = {
|
188 | 189 | dms: z.record(z.string(), DmConfigSchema).optional(), |
189 | 190 | textChunkLimit: z.number().int().positive().optional(), |
190 | 191 | chunkMode: z.enum(["length", "newline"]).optional(), |
| 192 | +blockStreaming: BlockStreamingSchema, |
191 | 193 | blockStreamingCoalesce: BlockStreamingCoalesceSchema, |
192 | 194 | mediaMaxMb: z.number().positive().optional(), |
193 | 195 | httpTimeoutMs: z.number().int().positive().max(300_000).optional(), |
|
| Original file line number | Diff line number | Diff line change |
|---|
@@ -286,7 +286,55 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
|
286 | 286 | expect(sendMediaFeishuMock).not.toHaveBeenCalled(); |
287 | 287 | }); |
288 | 288 | |
289 | | -it("sets disableBlockStreaming in replyOptions to prevent silent reply drops", async () => { |
| 289 | +it("disables block streaming by default to prevent silent reply drops", async () => { |
| 290 | +const result = createFeishuReplyDispatcher({ |
| 291 | +cfg: {} as never, |
| 292 | +agentId: "agent", |
| 293 | +runtime: {} as never, |
| 294 | +chatId: "oc_chat", |
| 295 | +}); |
| 296 | + |
| 297 | +expect(result.replyOptions).toHaveProperty("disableBlockStreaming", true); |
| 298 | +}); |
| 299 | + |
| 300 | +it("enables core block streaming when Feishu blockStreaming is explicitly true", async () => { |
| 301 | +resolveFeishuAccountMock.mockReturnValue({ |
| 302 | +accountId: "main", |
| 303 | +appId: "app_id", |
| 304 | +appSecret: "app_secret", |
| 305 | +domain: "feishu", |
| 306 | +config: { |
| 307 | +renderMode: "auto", |
| 308 | +streaming: true, |
| 309 | +blockStreaming: true, |
| 310 | +}, |
| 311 | +}); |
| 312 | + |
| 313 | +const { result, options } = createDispatcherHarness(); |
| 314 | +expect(result.replyOptions).toHaveProperty("disableBlockStreaming", false); |
| 315 | + |
| 316 | +await options.deliver({ text: "plain block" }, { kind: "block" }); |
| 317 | +await options.onIdle?.(); |
| 318 | + |
| 319 | +expect(streamingInstances).toHaveLength(1); |
| 320 | +expect(streamingInstances[0].close).toHaveBeenCalledWith("plain block", { |
| 321 | +note: "Agent: agent", |
| 322 | +}); |
| 323 | +}); |
| 324 | + |
| 325 | +it("keeps core block streaming disabled when Feishu blockStreaming is explicitly false", async () => { |
| 326 | +resolveFeishuAccountMock.mockReturnValue({ |
| 327 | +accountId: "main", |
| 328 | +appId: "app_id", |
| 329 | +appSecret: "app_secret", |
| 330 | +domain: "feishu", |
| 331 | +config: { |
| 332 | +renderMode: "auto", |
| 333 | +streaming: true, |
| 334 | +blockStreaming: false, |
| 335 | +}, |
| 336 | +}); |
| 337 | + |
290 | 338 | const result = createFeishuReplyDispatcher({ |
291 | 339 | cfg: {} as never, |
292 | 340 | agentId: "agent", |
|
| Original file line number | Diff line number | Diff line change |
|---|
@@ -222,6 +222,7 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
|
222 | 222 | const tableMode = core.channel.text.resolveMarkdownTableMode({ cfg, channel: "feishu" }); |
223 | 223 | const renderMode = account.config?.renderMode ?? "auto"; |
224 | 224 | const streamingEnabled = account.config?.streaming !== false && renderMode !== "raw"; |
| 225 | +const coreBlockStreamingEnabled = account.config?.blockStreaming === true; |
225 | 226 | const reasoningPreviewEnabled = streamingEnabled && params.allowReasoningPreview === true; |
226 | 227 | |
227 | 228 | let streaming: FeishuStreamingSession | null = null; |
@@ -530,7 +531,10 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
|
530 | 531 | }), |
531 | 532 | ); |
532 | 533 | const useCard = |
533 | | -hasText && (renderMode === "card" || (renderMode === "auto" && shouldUseCard(text))); |
| 534 | +hasText && |
| 535 | +(renderMode === "card" || |
| 536 | +(info?.kind === "block" && coreBlockStreamingEnabled && renderMode !== "raw") || |
| 537 | +(renderMode === "auto" && shouldUseCard(text))); |
534 | 538 | const skipTextForDuplicateFinal = |
535 | 539 | info?.kind === "final" && hasText && deliveredFinalTexts.has(text); |
536 | 540 | const skipTextForClosedStreamingFinal = |
@@ -660,7 +664,8 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
|
660 | 664 | replyOptions: { |
661 | 665 | ...replyOptions, |
662 | 666 | onModelSelected: prefixContext.onModelSelected, |
663 | | -disableBlockStreaming: true, |
| 667 | +disableBlockStreaming: |
| 668 | +typeof account.config?.blockStreaming === "boolean" ? !account.config.blockStreaming : true, |
664 | 669 | onPartialReply: streamingEnabled |
665 | 670 | ? (payload: ReplyPayload) => { |
666 | 671 | if (!payload.text) { |
|
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。