Skip to content

fix(feishu): prevent duplicate messages when streaming + card mode enabled#38894

Open
sesame437 wants to merge 2 commits intoopenclaw:mainfrom
sesame437:fix/feishu-streaming-card-duplicate
Open

fix(feishu): prevent duplicate messages when streaming + card mode enabled#38894
sesame437 wants to merge 2 commits intoopenclaw:mainfrom
sesame437:fix/feishu-streaming-card-duplicate

Conversation

@sesame437
Copy link
Copy Markdown

Summary

When both streaming: true and renderMode: "card" are enabled in the Feishu channel configuration, responses may produce duplicate or garbled content. This PR fixes three related issues in the reply dispatcher:

  • Cross-block text duplication: When an agent makes tool calls mid-response, cumulative partial text resets for each new generation block. Without block-boundary detection, queueStreamingUpdate appended new block partials to the full previous content, producing duplicated text inside the streaming card. Fixed by tracking blockBaseText / lastCumulativeLen to detect new block boundaries (partial length drops below 50% of last cumulative) and preserving previous blocks as a base.

  • Multiple streaming cards per response: After closeStreaming() resets all state, subsequent final payloads from dispatchReplyFromConfig would create new streaming cards. Fixed by adding a streamingWasUsed flag that persists across the dispatch cycle.

  • Non-streaming fallback duplication: When streaming was already used and closed (by onIdle/onError/prior final), the non-streaming send path would still fire for final payloads. Fixed by guarding the fallback path with streamingWasUsed.

How it was tested

  • Enabled streaming: true + renderMode: "card" on a live Feishu channel
  • Tested multi-turn conversations with tool-calling agents (which produce multi-block responses)
  • Verified: single streaming card per response, clean merged content across generation blocks, no duplicate fallback messages
  • Tested edge cases: short responses, responses with media attachments, thread replies

Changes

1 file changed: extensions/feishu/src/reply-dispatcher.ts (+53, -4)

🤖 Generated with Claude Code

…abled

When both streaming and card rendering are enabled, multiple issues cause
duplicate or garbled content to be sent to the user:

1. **Cross-block text duplication**: When an agent makes tool calls mid-response,
   the cumulative partial text resets for each new generation block. Without
   block-boundary detection, `queueStreamingUpdate` would append the new block's
   partial text to the full previous content, resulting in duplicated text inside
   the streaming card. Fixed by tracking `blockBaseText` and `lastCumulativeLen`
   to detect when a new generation block starts (partial length drops below 50%
   of the last cumulative length) and preserving previous blocks as a base.

2. **Multiple streaming cards per response**: After `closeStreaming()` resets all
   state, subsequent `final` payloads from `dispatchReplyFromConfig` would create
   brand new streaming cards. Fixed by adding a `streamingWasUsed` flag that
   persists across the dispatch cycle to prevent re-creating streaming sessions.

3. **Non-streaming fallback duplication**: When streaming was already used and
   closed (by onIdle/onError/prior final), the non-streaming send path would
   still fire for `final` payloads, sending a duplicate card/message. Fixed by
   guarding the fallback path with `streamingWasUsed`.

Fixes openclaw#38900

Co-Authored-By: Claude Opus 4.6 <[email protected]>
@openclaw-barnacle openclaw-barnacle bot added channel: feishu Channel integration: feishu size: S labels Mar 7, 2026
@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps bot commented Mar 7, 2026

Greptile Summary

This PR fixes three related duplicate-message bugs that surface when both streaming: true and renderMode: "card" are configured on the Feishu channel:

  1. Cross-block text duplication — Tracks block boundaries using a 50% length-drop heuristic to preserve previous blocks and prevent duplication when tool calls interrupt generation.
  2. Multiple streaming cards — Uses a streamingWasUsed flag to prevent creating new streaming cards after the first stream is closed.
  3. Non-streaming fallback duplication — Guards the fallback path to skip text delivery when streaming was already used.

However, there is a logic bug: streamingWasUsed is never reset in onReplyStart, despite deliveredFinalTexts being cleared there. This indicates the dispatcher is designed for reuse across reply cycles. On a second cycle with streamingWasUsed = true, all block payloads are discarded and the non-streaming fallback is suppressed for final payloads, resulting in completely empty responses for subsequent messages on the same dispatcher instance. This is a regression worse than the original duplication.

Confidence Score: 2/5

  • The PR introduces a critical logic bug that causes empty responses on subsequent reply cycles when the dispatcher is reused.
  • The PR correctly identifies and fixes three duplicate-message bugs with solid technical logic. However, streamingWasUsed is never reset in onReplyStart(), despite the dispatcher being designed for multi-cycle reuse (shown by deliveredFinalTexts.clear()). On the second reply cycle, this causes all block payloads to be silently dropped and the non-streaming fallback to be suppressed, resulting in completely empty responses. This is a regression that breaks the core functionality for any multi-turn conversation using the same dispatcher instance.
  • extensions/feishu/src/reply-dispatcher.ts — the onReplyStart callback must reset streamingWasUsed (along with blockBaseText and lastCumulativeLen) to fix the multi-cycle reuse regression.

Comments Outside Diff (1)

  1. extensions/feishu/src/reply-dispatcher.ts, line 262-268 (link)

    streamingWasUsed is never reset in onReplyStart, but deliveredFinalTexts is cleared there, which indicates the dispatcher is designed for multi-cycle reuse. When the second reply cycle begins with streamingWasUsed = true:

    1. All block payloads are dropped (line 293: if (!(streamingEnabled && useCard) || streamingWasUsed))
    2. The non-streaming fallback is suppressed for final payloads (line 339: if (streamingWasUsed && info?.kind === "final"))
    3. Result: no text is delivered on the second reply cycle, only media (if any) are sent

    This is a regression — the response becomes empty or stale. The fix is to reset streamingWasUsed along with the other streaming-related state in onReplyStart:

Last reviewed commit: e693c3a

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e693c3a892

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

// Guard: if streaming was active earlier in this dispatch cycle but is
// now closed (by onIdle, onError, or a prior final), skip the
// non-streaming fallback to avoid sending a duplicate card/message.
if (streamingWasUsed && info?.kind === "final") {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve distinct final replies after first streamed card

This guard skips all later final payloads once any streaming card has been closed, which drops legitimate multi-message outputs in a single dispatch cycle. dispatchReplyFromConfig can enqueue multiple final payloads (it iterates replies and calls sendFinalReply for each), so with this condition only the first final text is delivered in streaming/card flows and subsequent distinct finals are silently lost.

Useful? React with 👍 / 👎.

Comment on lines +293 to 294
if (!(streamingEnabled && useCard) || streamingWasUsed) {
return;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep media delivery when suppressing late block text

Including streamingWasUsed in this early return causes block payloads to exit before the shared media-send path runs, so late block callbacks that include attachments (for example block-level TTS/media arriving after a streamed final) will have their media silently dropped. The text suppression avoids duplicates, but returning here also suppresses non-duplicate media content.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor

@gambletan gambletan left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: prevent duplicate messages in Feishu streaming + card mode

Overall: Addresses a real duplicate message issue, but the snapshot boundary detection heuristic is fragile. ⚠️

What works well

  • The streamingWasUsed flag is a clean guard: once streaming has been used and closed, non-streaming fallback sends are blocked. This directly prevents the reported duplicate.
  • Media-only sends are still allowed even when streaming was used (line 336-345). Good — media should always be delivered.
  • The state reset in closeStreaming (blockBaseText = "", lastCumulativeLen = 0) ensures clean state between dispatch cycles.

Concerns

  1. The 0.5 threshold heuristic (line 186: nextText.length < lastCumulativeLen * 0.5) is fragile:

    • What if the model generates a short second block that happens to be > 50% of the last cumulative length? It would be incorrectly merged into the current block.
    • What if network latency causes a snapshot to be temporarily shorter than expected? The !currentBlock.includes(nextText) check helps, but includes on large strings is O(n*m).
    • Consider documenting this heuristic with a comment explaining WHY 0.5 was chosen and what behavior it approximates.
  2. The blockBaseText accumulation: If multiple tool calls happen in sequence (agent calls tool A, gets result, generates text, calls tool B, etc.), blockBaseText keeps growing. Each new block boundary prepends all previous text. This could lead to very large streamText values for multi-step tool use sessions. Is this bounded?

  3. No test coverage: This is a meaningful behavioral change in the streaming dispatcher but has no tests. The existing test infrastructure may not support streaming simulation easily, but consider at minimum a unit test for the new block boundary detection logic.

  4. Thread safety of streamingWasUsed: Multiple concurrent dispatches on the same dispatcher instance could race on this flag. If createFeishuReplyDispatcher creates a new instance per dispatch (likely), this is fine. Worth verifying.

Recommendation

The streamingWasUsed guard is solid and should fix the duplicate. The snapshot boundary detection heuristic is more risky — consider whether it can be simplified or tested separately.

Address PR review feedback:

1. Add minimum length guard (lastCumulativeLen >= 20) to prevent false
   block-boundary triggers on naturally short first blocks. Previously
   the check only required > 0, which could misfire on short responses.

2. Document the 0.5 threshold heuristic with detailed comments explaining
   why it was chosen and what conditions must hold for detection.

3. Extract `isNewBlock` boolean for readability.

4. Add comprehensive unit tests for the block-boundary detection logic,
   covering: single-block merging, multi-block tool-call scenarios,
   three consecutive blocks, short-block false-positive prevention,
   and substring edge cases.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 38b7b34d26

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

// Guard: if streaming was active earlier in this dispatch cycle but is
// now closed (by onIdle, onError, or a prior final), skip the
// non-streaming fallback to avoid sending a duplicate card/message.
if (streamingWasUsed && info?.kind === "final") {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Allow subsequent final payloads after streamed close

This guard drops every later final text once one streaming card has been closed, so multi-part final outputs lose content after the first message. The dispatcher can receive multiple final payloads in one run (src/auto-reply/reply/dispatch-from-config.ts loops replies and calls sendFinalReply for each), so scenarios like header/body or other distinct finals now silently suppress later text and only keep optional media.

Useful? React with 👍 / 👎.

// Drop internal block chunks unless we can safely consume them as
// streaming-card fallback content.
if (!(streamingEnabled && useCard)) {
if (!(streamingEnabled && useCard) || streamingWasUsed) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve media sending for late block payloads

Returning here when streamingWasUsed is true skips the shared media-send path, so any block payload that includes attachments is discarded. Block callbacks can carry media (for example after maybeApplyTtsToPayload in block mode), and if such a payload arrives after streaming has already been closed in the same dispatch cycle, its media is now dropped instead of delivered.

Useful? React with 👍 / 👎.

@vincentkoc
Copy link
Copy Markdown
Member

Triage note for the duplicate cluster:

The Feishu streaming/card PRs in this area are not independent bug families.

Strong overlap set:

Shared symptom:

  • duplicate or repeated Feishu streaming cards during one logical reply turn
  • especially around multi-final / multi-block / tool-call-heavy responses in extensions/feishu/src/reply-dispatcher.ts

Current read:

I’m not auto-closing that trio yet because the canonical winner needs maintainer judgment on scope vs risk, but they should be deduped as one cluster.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

channel: feishu Channel integration: feishu size: S

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants