openclaw - 💡(How to fix) Fix [Bug]: Feishu streaming card concatenates tool progress labels with answer text (mergeStreamingText misalignment in flushStreamingCardUpdate)

Official PRs (…)
ON THIS PAGE

Recommended Tools

×6

Utilities matched from this issue’s tags and category — try them while you read without losing context.

GitHub issue graph ai analysis

Paste a GitHub issue URL. We fetch that issue, discover linked issues from bodies/comments/timeline, collect linked pull requests, and produce a structured English report.

The report is written in English Markdown for sharing and archival.

Helpful · Quick feedback

Loading…

When flushStreamingCardUpdate delivers combined card content (tool progress + answer text) via streaming.update(combined), the mergeStreamingText() merge function inside FeishuStreamingSession.update() fails to align the new complete text with the previous card state. This causes direct concatenation without separators, producing garbled card content.

Root Cause

The issue is in the interaction between flushStreamingCardUpdate in reply-dispatcher.ts and FeishuStreamingSession.update() in streaming-card.ts.

Fix Action

Fix / Workaround

The issue is in the interaction between flushStreamingCardUpdate in reply-dispatcher.ts and FeishuStreamingSession.update() in streaming-card.ts.

This is the same underlying mechanism described in #77685 Bug 3 ("streaming.state.currentText not reset before final text merge") and Bug 6 ("stale pendingText overwrites final card content"). The fix proposed in #77685 (resetting currentText to "" before calling update()) works as a workaround but is fragile — it depends on the caller remembering to clear internal state of the streaming session before every call. The proper fix is to give the caller a dedicated API for full-replacement updates.

This is also related to #84486 (text before tool calls lost) and #85439 (tool progress stripped after v2026.5.19), as all three stem from the streaming card dispatcher conflating incremental streaming updates with full-content replacement updates.

Code Example

🔧 print text (agent)
📄 Read: from file
📊 Session Status: current

已完成3个工具调用:
1. 打印了 "hello world"
2. 读取了 /etc/hosts 的前33. 查看了会话状态

---

🔧 print text (agent)📄 Read: from file📊 Session Status: current🔧 Exec已完成3个工具调用:1. 打印了 "hello world"2. 读取了 /etc/hosts 的前33. 查看了会话状态

---

// mergeStreamingText fallback when no overlap found:
return `${previous}${next}`;

---

/**
 * Replace the entire card text without merging.
 * Used by flushStreamingCardUpdate which always passes
 * the complete intended card content from buildCombinedStreamText().
 */
async replace(text: string) {
    if (!this.state || this.closed || !text) return;
    this.pendingText = null;
    this.clearFlushTimer();
    this.lastUpdateTime = Date.now();
    this.queue = this.queue.then(async () => {
        if (!this.state || this.closed) return;
        this.state.currentText = text;
        this.pendingText = null;
        await this.updateCardContent(text, (e) =>
            this.log?.(`Replace failed: ${String(e)}`)
        );
    });
    await this.queue;
}

---

// Before:
if (streaming?.isActive()) await streaming.update(combined);

// After:
if (streaming?.isActive()) await streaming.replace(combined);
RAW_BUFFERClick to expand / collapse

Summary

When flushStreamingCardUpdate delivers combined card content (tool progress + answer text) via streaming.update(combined), the mergeStreamingText() merge function inside FeishuStreamingSession.update() fails to align the new complete text with the previous card state. This causes direct concatenation without separators, producing garbled card content.

Environment

  • OpenClaw version: 2026.5.24 (installed via npm)
  • Channel: Feishu (streaming card reply mode)
  • Model: any (reproducible with gpt-5.5 and claude-sonnet-4-5)
  • Reply mode: streaming (default for Feishu p2p)

Steps to Reproduce

  1. Send the agent a prompt that triggers multiple tool calls followed by a text answer, e.g.: "Read the first 3 lines of /etc/hosts, print 'hello world', then summarize what you did"
  2. The agent executes tools, updating the streaming card with tool progress labels (e.g., 🔧 Exec, 📄 Read: /etc/hosts)
  3. After tools complete, the agent produces the final answer text
  4. Observe the streaming card content during and after delivery

Expected Behavior

Card content should show tool progress labels on separate lines followed by the answer text, e.g.:

🔧 print text (agent)
📄 Read: from file
📊 Session Status: current

已完成3个工具调用:
1. 打印了 "hello world"
2. 读取了 /etc/hosts 的前3行
3. 查看了会话状态

Actual Behavior

Tool progress labels and answer text are concatenated without newlines or separators:

🔧 print text (agent)📄 Read: from file📊 Session Status: current🔧 Exec已完成3个工具调用:1. 打印了 "hello world"2. 读取了 /etc/hosts 的前3行3. 查看了会话状态

Specific symptoms:

  1. Multiple tool progress labels concatenated without newlines: 🔧 print text (agent)📄 Read: from file📊 Session Status: current
  2. Tool progress labels concatenated with answer text: 🔧 Exec已完成3个工具调用
  3. Repeated/duplicated text blocks after each tool call, as mergeStreamingText falls back to appending

Root Cause Analysis

The issue is in the interaction between flushStreamingCardUpdate in reply-dispatcher.ts and FeishuStreamingSession.update() in streaming-card.ts.

The call chain

  1. flushStreamingCardUpdate calls buildCombinedStreamText() which constructs the complete intended card text (tool progress lines + answer text, properly formatted with newlines)
  2. It then calls streaming.update(combined) to push this to the card
  3. Inside FeishuStreamingSession.update(), mergeStreamingText(this.state.currentText, combined) is called to merge the new text with the previous card state

Why mergeStreamingText fails here

mergeStreamingText() is designed for incremental streaming — it assumes each new text is a superset of the previous text (the model is still generating, appending characters). It tries to find a suffix of the old text that matches a prefix of the new text, then produces only the new portion.

But flushStreamingCardUpdate passes complete replacement text, not incremental deltas. When the card content changes structurally (e.g., from tool status "🔧 Exec" to answer text "已完成3个工具调用:..."), mergeStreamingText cannot find any overlap between the old currentText and the new combined text. It falls back to direct concatenation:

// mergeStreamingText fallback when no overlap found:
return `${previous}${next}`;

No separator, no newline — just raw concatenation.

Why this affects all tool-call responses

Every time buildCombinedStreamText() produces a new combined string (which happens after each tool progress update and when answer text starts), the result is structurally different from what currentText holds. The merge function cannot align them, so it concatenates every time.

Relationship to prior issues

This is the same underlying mechanism described in #77685 Bug 3 ("streaming.state.currentText not reset before final text merge") and Bug 6 ("stale pendingText overwrites final card content"). The fix proposed in #77685 (resetting currentText to "" before calling update()) works as a workaround but is fragile — it depends on the caller remembering to clear internal state of the streaming session before every call. The proper fix is to give the caller a dedicated API for full-replacement updates.

This is also related to #84486 (text before tool calls lost) and #85439 (tool progress stripped after v2026.5.19), as all three stem from the streaming card dispatcher conflating incremental streaming updates with full-content replacement updates.

Proposed Fix

Add a replace(text) method to FeishuStreamingSession that sets currentText directly and pushes to the Feishu API without going through mergeStreamingText. Then change flushStreamingCardUpdate to use replace() instead of update().

1. extensions/feishu/src/streaming-card.ts — Add replace() method

/**
 * Replace the entire card text without merging.
 * Used by flushStreamingCardUpdate which always passes
 * the complete intended card content from buildCombinedStreamText().
 */
async replace(text: string) {
    if (!this.state || this.closed || !text) return;
    this.pendingText = null;
    this.clearFlushTimer();
    this.lastUpdateTime = Date.now();
    this.queue = this.queue.then(async () => {
        if (!this.state || this.closed) return;
        this.state.currentText = text;
        this.pendingText = null;
        await this.updateCardContent(text, (e) =>
            this.log?.(`Replace failed: ${String(e)}`)
        );
    });
    await this.queue;
}

2. extensions/feishu/src/reply-dispatcher.ts — Use replace() in flushStreamingCardUpdate

// Before:
if (streaming?.isActive()) await streaming.update(combined);

// After:
if (streaming?.isActive()) await streaming.replace(combined);

Why replace() instead of resetting currentText

The approach in #77685 Fix 4 (resetting currentText = "" before update()) achieves a similar result but has drawbacks:

  1. Caller reaches into session internals: flushStreamingCardUpdate would need to know about and manipulate streaming.state.currentText and streaming.pendingText directly
  2. Race conditions: Between resetting currentText and calling update(), another queued update could read the empty currentText
  3. Fragile: Every new callsite that needs full-replacement semantics must remember to do the reset dance

A dedicated replace() method encapsulates the full-replacement logic atomically within the session's queue, making the intent clear and the operation safe.

What stays unchanged

The existing update() method with mergeStreamingText remains untouched. It is still used by onPartialReply for genuine incremental streaming snapshots (where the model is appending text character by character), which is exactly what mergeStreamingText was designed for.

Verified Locally

Tested by patching the compiled monitor.account-*.js on a live instance:

  • Tool progress labels now appear on separate lines
  • Answer text starts on a new line after tool progress
  • No duplicate or concatenated text blocks
  • Incremental streaming (character-by-character model output) still works correctly via update()

Vote matrix · Quick signals

Works
Did the solution work? Tap to confirm.
Easy Fix
Was it a quick fix?
Time Saver
Did it save you time?
Blocking
Was it severely blocking?
Common Issue
Are others likely hitting this too?
Flaky / Intermittent
Is it intermittent?
Verified / Reproducible
Can you reproduce it reliably?
Loading…

Still need to ship something?

×6

Another batch ranked right after the header list — different links, same matching logic.

Back to top recommendations

TRENDING