fix(feishu): prevent duplicate messages when streaming + card mode enabled#38894
fix(feishu): prevent duplicate messages when streaming + card mode enabled#38894sesame437 wants to merge 2 commits intoopenclaw:mainfrom
Conversation
…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]>
Greptile SummaryThis PR fixes three related duplicate-message bugs that surface when both
However, there is a logic bug: Confidence Score: 2/5
|
There was a problem hiding this comment.
💡 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") { |
There was a problem hiding this comment.
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 👍 / 👎.
| if (!(streamingEnabled && useCard) || streamingWasUsed) { | ||
| return; |
There was a problem hiding this comment.
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 👍 / 👎.
gambletan
left a comment
There was a problem hiding this comment.
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
streamingWasUsedflag 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
-
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, butincludeson large strings is O(n*m). - Consider documenting this heuristic with a comment explaining WHY 0.5 was chosen and what behavior it approximates.
-
The
blockBaseTextaccumulation: If multiple tool calls happen in sequence (agent calls tool A, gets result, generates text, calls tool B, etc.),blockBaseTextkeeps growing. Each new block boundary prepends all previous text. This could lead to very largestreamTextvalues for multi-step tool use sessions. Is this bounded? -
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.
-
Thread safety of
streamingWasUsed: Multiple concurrent dispatches on the same dispatcher instance could race on this flag. IfcreateFeishuReplyDispatchercreates 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]>
There was a problem hiding this comment.
💡 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") { |
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
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 👍 / 👎.
|
Triage note for the duplicate cluster: The Feishu streaming/card PRs in this area are not independent bug families. Strong overlap set:
Shared symptom:
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. |
Summary
When both
streaming: trueandrenderMode: "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,
queueStreamingUpdateappended new block partials to the full previous content, producing duplicated text inside the streaming card. Fixed by trackingblockBaseText/lastCumulativeLento 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, subsequentfinalpayloads fromdispatchReplyFromConfigwould create new streaming cards. Fixed by adding astreamingWasUsedflag 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
finalpayloads. Fixed by guarding the fallback path withstreamingWasUsed.How it was tested
streaming: true+renderMode: "card"on a live Feishu channelChanges
1 file changed:
extensions/feishu/src/reply-dispatcher.ts(+53, -4)🤖 Generated with Claude Code