Skip to content

fix(feishu): prevent streaming card duplication on multi-final replies#42940

Open
NeooChen wants to merge 1 commit intoopenclaw:mainfrom
NeooChen:main
Open

fix(feishu): prevent streaming card duplication on multi-final replies#42940
NeooChen wants to merge 1 commit intoopenclaw:mainfrom
NeooChen:main

Conversation

@NeooChen
Copy link
Copy Markdown

Summary

  • Problem: When renderMode: "card" and streaming: true, each final reply in a single agent turn creates a new streaming card via CardKit API. Turns with N tool calls produce N duplicate cards.
  • Why it matters: Users see the same content repeated multiple times as separate card messages, degrading chat UX significantly in agentic workflows.
  • What changed: deliver(final) no longer calls closeStreaming(). Instead it uses queueStreamingUpdate(text, { mode: "snapshot" }) to merge text into the existing card. onIdle handles card finalization.
  • What did NOT change: Block streaming, non-card render modes, media handling, typing indicators, and all other dispatch logic remain untouched.

Change Type (select all)

  • Bug fix
  • Feature
  • Refactor
  • Docs
  • Security hardening
  • Chore/infra

Scope (select all touched areas)

  • Gateway / orchestration
  • Skills / tool execution
  • Auth / tokens
  • Memory / storage
  • Integrations
  • API / contracts
  • UI / DX
  • CI/CD / infra

Linked Issue/PR

None

User-visible / Behavior Changes

Multi-final turns now produce a single streaming card instead of N duplicate cards. No config changes required.

Security Impact (required)

  • New permissions/capabilities? No
  • Secrets/tokens handling changed? No
  • New/changed network calls? No (fewer calls — eliminates redundant CardKit create calls)
  • Command/tool execution surface changed? No
  • Data access scope changed? No

Repro + Verification

Environment

  • OS: Windows Server 2022
  • Runtime/container: OpenClaw 2026.3.7 (42a1394)
  • Model/provider: claude-opus-4-6
  • Integration/channel: Feishu (websocket mode)
  • Relevant config: renderMode: "card", streaming: true, cardkit:card:write permission granted

Steps

  1. Configure Feishu account with renderMode: "card" and streaming: true
  2. Send a message that triggers multiple tool calls (e.g. agent reads files, runs commands, then responds)
  3. Agent responds with text between tool calls, producing multiple final replies
  4. Observe Feishu chat output

Expected

  • Single streaming card with all content merged progressively

Actual

  • N separate streaming cards, each with progressively accumulated text. Gateway log shows Started streamingClosed streaming repeated N times per turn.

Evidence

Gateway log before fix:

06:54:01 feishu[ops] Started streaming: cardId=...329
06:54:01 feishu[ops] Closed streaming: cardId=...329
06:54:02 feishu[ops] Started streaming: cardId=...465
06:54:03 feishu[ops] Closed streaming: cardId=...465
... (repeated 8 times)
06:54:12 feishu[ops]: dispatch complete (queuedFinal=true, replies=8)

Gateway log after fix:

07:10:37 feishu[pipi] Started streaming: cardId=...312
07:10:57 feishu[pipi]: dispatch complete (queuedFinal=true, replies=1)
07:10:57 feishu[pipi] Closed streaming: cardId=...312

Human Verification (required)

  • Verified scenarios: Single-final turns (1 card ✅), multi-final turns with 3+ tool calls (1 card ✅), no duplicated content within card ✅
  • Edge cases checked: First version used mode: "delta" which caused content duplication inside the card; fixed by switching to mode: "snapshot" for proper dedup via mergeStreamingText()
  • What you did not verify: Thread reply mode (disabled for streaming cards by design), Lark (non-Feishu) domain

Review Conversations

  • I replied to or resolved every bot review conversation I addressed in this PR.
  • I left unresolved only the conversations that still need reviewer or maintainer judgment.

Compatibility / Migration

  • Backward compatible? Yes
  • Config/env changes? No
  • Migration needed? No

Failure Recovery (if this breaks)

  • How to disable/revert this change quickly: Revert this single file change, or set renderMode: "auto" to avoid card streaming path entirely.
  • Files/config to restore: extensions/feishu/src/reply-dispatcher.ts
  • Known bad symptoms reviewers should watch for: Streaming card never closing (stuck "Generating..." state) — would indicate onIdle not firing.

Risks and Mitigations

  • Risk: If onIdle fails to fire, the streaming card stays open indefinitely with "Generating..." state.
    • Mitigation: onError also calls closeStreaming() as fallback. This is the same pattern used by block streaming.

fix(feishu): prevent streaming card duplication on multi-final replies
@openclaw-barnacle openclaw-barnacle bot added channel: feishu Channel integration: feishu size: XS labels Mar 11, 2026
@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps bot commented Mar 11, 2026

Greptile Summary

This PR fixes a streaming card duplication bug in the Feishu integration where N tool calls in one agent turn previously produced N separate streaming cards. The root cause was that each deliver(final) called closeStreaming(), which closed the current card and reset the session — causing the next final reply's startStreaming() to create a brand-new card. The fix is minimal and surgical: deliver(final) now calls queueStreamingUpdate(text, { mode: "snapshot" }) to merge text into the live card without closing it, and delegates final card closure to onIdle (the same pattern already used by block streaming). The mergeStreamingText deduplication via "snapshot" mode correctly handles the case where the final text overlaps with content already pushed by onPartialReply.

Key points:

  • streamText is still updated synchronously inside queueStreamingUpdate, so closeStreaming() in onIdle receives the fully-accumulated text when it calls streaming.close(streamText).
  • partialUpdateQueue is awaited by closeStreaming() before closing, ensuring all queued streaming updates are flushed before the card is finalized.
  • The deliveredFinalTexts dedup set is cleared on onReplyStart and populated per-final as before — no change in semantics.
  • The fallback risk (card stuck in "Generating…" state if onIdle never fires) already existed for block streaming and is equally mitigated here by the onError → closeStreaming() path.

Confidence Score: 4/5

  • Safe to merge — single-file, focused bug fix with correct deduplication logic and no regressions in tested paths.
  • The change is minimal (6 lines replaced), the root cause is correctly identified, and the fix aligns with the existing streaming patterns in the codebase. streamText accumulation, partialUpdateQueue ordering, and closeStreaming finalization all behave correctly under the new flow. The only remaining risk (card stuck in "Generating…" if onIdle never fires) was pre-existing and is covered by the onError fallback. Score is 4 rather than 5 because thread-reply mode and Lark domain were not verified by the author.
  • No files require special attention.

Last reviewed commit: 714248b

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: 714248b48d

ℹ️ 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".

// onIdle will close the card when the entire turn is done.
// Use "snapshot" mode so mergeStreamingText deduplicates against
// content already delivered via onPartialReply.
queueStreamingUpdate(text, { mode: "snapshot" });
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 streaming close in the awaited delivery path

Replacing await closeStreaming() with queueStreamingUpdate(..., { mode: "snapshot" }) moves card finalization from deliver() to onIdle, but the dispatcher infrastructure does not await onIdle callbacks before considering replies idle (withReplyDispatcher only waits dispatcher.waitForIdle(), and createReplyDispatcher invokes onIdle fire-and-forget). In flows that trigger restart/reload right after dispatch settles, the process can proceed while the CardKit close call is still in flight, which can leave Feishu cards stuck in streaming mode ([Generating...]) if shutdown interrupts that request.

Useful? React with 👍 / 👎.

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: XS

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant