Skip to content

fix(feishu): improve streaming card update performance#48382

Closed
Pengxiao-Wang wants to merge 1 commit intoopenclaw:mainfrom
Pengxiao-Wang:fix/feishu-streaming-performance
Closed

fix(feishu): improve streaming card update performance#48382
Pengxiao-Wang wants to merge 1 commit intoopenclaw:mainfrom
Pengxiao-Wang:fix/feishu-streaming-performance

Conversation

@Pengxiao-Wang
Copy link
Copy Markdown

Summary

  • Remove double serial await in streaming card update path — update() no longer blocks on HTTP round-trip, and flushStreamingCardUpdate in reply-dispatcher uses fire-and-forget (void instead of await)
  • Add pendingTimer so throttled text is never silently dropped — previously pendingText was stored but had no re-send mechanism
  • Replace mergeStreamingText calls in streaming-card.ts with direct snapshot assignment — the reply dispatcher already sends complete combined text via buildCombinedStreamText(), so re-merging caused tool status lines to accumulate
  • Reduce updateThrottleMs from 100 ms → 50 ms (matching CardKit print_frequency_ms advisory)
  • Increase print_step from 1 → 3 for smoother visual rendering (~60 chars/sec)

Why

The Feishu streaming card path had three bottlenecks causing visible stutter:

  1. update() awaited the HTTP queue (~100-200 ms per PUT), so back-to-back partial-reply callbacks stacked up
  2. Throttled content was silently lost if no follow-up update() arrived
  3. Redundant mergeStreamingText in the card layer concatenated old+new content instead of replacing, causing tool status lines like 🔧 Using: read...🔧 Using: exec... to accumulate

Test plan

  • Send a message requiring multiple tool calls in Feishu — verify streaming text appears smoothly without stutter
  • Verify tool status lines (if using the tool-status PR) replace each other instead of accumulating
  • Verify throttled partial text is not lost (send a long response and check final card content matches)
  • Run pnpm test -- extensions/feishu/src/streaming-card.test.ts

🤖 Generated with Claude Code

The streaming card update path had two sequential bottlenecks:

1. `update()` awaited the HTTP queue, blocking the caller until each PUT
   completed (~100-200 ms per round-trip). Back-to-back partial-reply
   callbacks stacked up and produced visible stutter.

2. Throttled (skipped) text was stored in `pendingText` but never
   re-scheduled — if no further `update()` arrived within the throttle
   window the content was silently dropped.

3. `mergeStreamingText` was called twice per update inside the streaming
   card, even though the reply dispatcher already sends a complete
   snapshot via `buildCombinedStreamText()`. The redundant merge caused
   tool-status lines to accumulate instead of being replaced.

Changes:
- Split `update()` into a non-blocking entry point and a private
  `flushUpdate()` that enqueues the HTTP PUT without awaiting it.
- Add a `pendingTimer` that fires after the throttle window so
  throttled content is never lost.
- Replace `mergeStreamingText` calls with direct snapshot assignment.
- Reduce `updateThrottleMs` from 100 ms → 50 ms (20 updates/sec,
  matching the CardKit `print_frequency_ms` advisory).
- Increase `print_step` from 1 → 3 so the Feishu client renders
  3 characters per tick (~60 chars/sec) for smoother visual output.
- Change `flushStreamingCardUpdate` in reply-dispatcher to
  fire-and-forget (`void` instead of `await`).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@openclaw-barnacle openclaw-barnacle Bot added channel: feishu Channel integration: feishu size: S labels Mar 16, 2026
@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented Mar 16, 2026

Greptile Summary

This PR improves Feishu streaming card update performance by removing blocking await calls from the hot path (both in reply-dispatcher.ts and inside FeishuStreamingSession.update()), adding a pendingTimer so throttled text is never silently dropped, replacing the incremental mergeStreamingText logic in the card layer with a direct snapshot assignment (since buildCombinedStreamText in the dispatcher already produces the full text), and tuning updateThrottleMs to 50 ms and print_step to 3.

Key observations:

  • The two-level fire-and-forget design (dispatcher enqueues → session queues HTTP) is sound: closeStreaming() drains partialUpdateQueue before calling streaming.close(), which in turn drains this.queue, so all in-flight HTTP calls complete before the card is sealed.
  • close() sets this.closed = true before await this.queue, causing any in-flight flushUpdate queue tasks to bail early. Text that was already handed off to flushUpdate (sync phase ran, pendingText = null) but whose queue task hasn't executed yet will be invisible to close(). This is safe today because closeStreaming() always passes the complete buildCombinedStreamText(...) as finalText, but the close() API is fragile for callers who omit finalText.
  • flushUpdate is declared async but never awaits anything — the async keyword is misleading and can be removed without changing behavior.

Confidence Score: 3/5

  • Safe to merge for the happy path, but the close() timing contract around in-flight flushUpdate tasks is fragile and should be hardened.
  • The performance improvements are sound and the fire-and-forget design is architecturally correct. However, close() setting this.closed = true before await this.queue creates a subtle window where in-flight flushUpdate tasks are discarded. The current call site always supplies finalText to cover this, but the public API allows calling close() without it, risking silently stale card content. The async on flushUpdate is a minor misleading pattern but not a runtime issue.
  • extensions/feishu/src/streaming-card.ts — specifically the close() / flushUpdate interaction and the ordering of this.closed = true vs await this.queue.

Comments Outside Diff (1)

  1. extensions/feishu/src/streaming-card.ts, line 403-415 (link)

    close() can silently discard in-flight pending text

    When flushUpdate has been called (either directly or by the timer firing) but its queued task hasn't yet executed, close() can lose that text. Here's the exact race:

    1. flushUpdate("X") runs its synchronous setup — this.pendingText = null, this.lastUpdateTime = now, a task is chained on this.queue.
    2. close() is called: sets this.closed = true, cancels the already-null pendingTimer, then does await this.queue.
    3. The chained task checks if (!this.state || this.closed) returnexits early without updating state.currentText.
    4. Back in close(): this.pendingText is already null (cleared in step 1's sync phase), and this.state.currentText still doesn't contain "X".
    5. pendingMerged = mergeStreamingText(state.currentText, undefined) = stale state.currentText — "X" is gone.

    This is harmless today because closeStreaming() in reply-dispatcher.ts always supplies a complete finalText, which acts as the authoritative override. However the public close() signature allows callers to omit finalText, at which point stale/incomplete content is silently shown.

    To make this robust, you could await any in-flight flush before marking closed, or document the requirement that finalText must always be supplied when there could be in-flight updates:

    // Option A: flush before closing
    async close(finalText?: string, options?: { note?: string }): Promise<void> {
      if (!this.state || this.closed) {
        return;
      }
      // Drain any pending flush before marking closed, so queue tasks see closed=false.
      await this.queue;
      this.closed = true;
      if (this.pendingTimer) {
        clearTimeout(this.pendingTimer);
        this.pendingTimer = null;
      }
      // ... rest of close
    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: extensions/feishu/src/streaming-card.ts
    Line: 403-415
    
    Comment:
    **`close()` can silently discard in-flight pending text**
    
    When `flushUpdate` has been called (either directly or by the timer firing) but its queued task hasn't yet executed, `close()` can lose that text. Here's the exact race:
    
    1. `flushUpdate("X")` runs its synchronous setup — `this.pendingText = null`, `this.lastUpdateTime = now`, a task is chained on `this.queue`.
    2. `close()` is called: sets `this.closed = true`, cancels the already-null `pendingTimer`, then does `await this.queue`.
    3. The chained task checks `if (!this.state || this.closed) return`**exits early without updating `state.currentText`**.
    4. Back in `close()`: `this.pendingText` is already `null` (cleared in step 1's sync phase), and `this.state.currentText` still doesn't contain "X".
    5. `pendingMerged = mergeStreamingText(state.currentText, undefined)` = stale `state.currentText` — "X" is gone.
    
    This is harmless today because `closeStreaming()` in `reply-dispatcher.ts` always supplies a complete `finalText`, which acts as the authoritative override. However the public `close()` signature allows callers to omit `finalText`, at which point stale/incomplete content is silently shown.
    
    To make this robust, you could await any in-flight flush before marking `closed`, or document the requirement that `finalText` must always be supplied when there could be in-flight updates:
    
    ```typescript
    // Option A: flush before closing
    async close(finalText?: string, options?: { note?: string }): Promise<void> {
      if (!this.state || this.closed) {
        return;
      }
      // Drain any pending flush before marking closed, so queue tasks see closed=false.
      await this.queue;
      this.closed = true;
      if (this.pendingTimer) {
        clearTimeout(this.pendingTimer);
        this.pendingTimer = null;
      }
      // ... rest of close
    ```
    
    How can I resolve this? If you propose a fix, please make it concise.
Prompt To Fix All With AI
This is a comment left during a code review.
Path: extensions/feishu/src/streaming-card.ts
Line: 403-415

Comment:
**`close()` can silently discard in-flight pending text**

When `flushUpdate` has been called (either directly or by the timer firing) but its queued task hasn't yet executed, `close()` can lose that text. Here's the exact race:

1. `flushUpdate("X")` runs its synchronous setup — `this.pendingText = null`, `this.lastUpdateTime = now`, a task is chained on `this.queue`.
2. `close()` is called: sets `this.closed = true`, cancels the already-null `pendingTimer`, then does `await this.queue`.
3. The chained task checks `if (!this.state || this.closed) return`**exits early without updating `state.currentText`**.
4. Back in `close()`: `this.pendingText` is already `null` (cleared in step 1's sync phase), and `this.state.currentText` still doesn't contain "X".
5. `pendingMerged = mergeStreamingText(state.currentText, undefined)` = stale `state.currentText` — "X" is gone.

This is harmless today because `closeStreaming()` in `reply-dispatcher.ts` always supplies a complete `finalText`, which acts as the authoritative override. However the public `close()` signature allows callers to omit `finalText`, at which point stale/incomplete content is silently shown.

To make this robust, you could await any in-flight flush before marking `closed`, or document the requirement that `finalText` must always be supplied when there could be in-flight updates:

```typescript
// Option A: flush before closing
async close(finalText?: string, options?: { note?: string }): Promise<void> {
  if (!this.state || this.closed) {
    return;
  }
  // Drain any pending flush before marking closed, so queue tasks see closed=false.
  await this.queue;
  this.closed = true;
  if (this.pendingTimer) {
    clearTimeout(this.pendingTimer);
    this.pendingTimer = null;
  }
  // ... rest of close
```

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: extensions/feishu/src/streaming-card.ts
Line: 352

Comment:
**`flushUpdate` declared `async` but never awaits**

`flushUpdate` is marked `async` but its body only does synchronous work (state mutations + chaining onto `this.queue`). It never `await`s anything directly. The `async` keyword makes the function return a `Promise<void>` that resolves immediately, which is then discarded via `void` at every call site. This could mislead readers into thinking there is asynchronous work happening inside the function body itself.

```suggestion
  private flushUpdate(text: string): void {
```

How can I resolve this? If you propose a fix, please make it concise.

Last reviewed commit: c1197d8

void this.flushUpdate(text);
}

private async flushUpdate(text: string): Promise<void> {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

flushUpdate declared async but never awaits

flushUpdate is marked async but its body only does synchronous work (state mutations + chaining onto this.queue). It never awaits anything directly. The async keyword makes the function return a Promise<void> that resolves immediately, which is then discarded via void at every call site. This could mislead readers into thinking there is asynchronous work happening inside the function body itself.

Suggested change
private async flushUpdate(text: string): Promise<void> {
private flushUpdate(text: string): void {
Prompt To Fix With AI
This is a comment left during a code review.
Path: extensions/feishu/src/streaming-card.ts
Line: 352

Comment:
**`flushUpdate` declared `async` but never awaits**

`flushUpdate` is marked `async` but its body only does synchronous work (state mutations + chaining onto `this.queue`). It never `await`s anything directly. The `async` keyword makes the function return a `Promise<void>` that resolves immediately, which is then discarded via `void` at every call site. This could mislead readers into thinking there is asynchronous work happening inside the function body itself.

```suggestion
  private flushUpdate(text: string): void {
```

How can I resolve this? If you propose a fix, please make it concise.

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

ℹ️ About Codex in GitHub

Codex has been enabled to automatically 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 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

}
return;
}
void this.flushUpdate(text);
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 Wait for queued card updates before returning from update

FeishuStreamingSession.update() now returns immediately after void this.flushUpdate(text), so callers cannot observe whether the queued PUT has actually run; when partial snapshots arrive faster than Feishu accepts updates (the commit itself notes ~100–200ms RTT), this.queue can lag behind and close() then marks closed=true, causing queued callbacks to short-circuit and leaving state.currentText stale before final merge. In streams where snapshots replace prior content (for example tool-status line changes), that stale state can make the final mergeStreamingText(...) concatenate old and new snapshots instead of replacing, reintroducing duplicated/garbled final card text.

Useful? React with 👍 / 👎.

@vincentkoc vincentkoc added dedupe:child Duplicate issue/PR child in dedupe cluster close:superseded PR close reason labels Apr 25, 2026
@vincentkoc vincentkoc self-assigned this Apr 25, 2026
@vincentkoc
Copy link
Copy Markdown
Member

Thanks for the performance pass. The landed path in #71523 keeps the low-risk part: throttled card edits now schedule a pending flush, and meaningful or natural-boundary edits push immediately.

I’m closing this as superseded because this branch also changes the hot path more broadly. Reopen if there is a specific Feishu card update performance issue still visible after the canonical fix.

@vincentkoc vincentkoc closed this Apr 25, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

channel: feishu Channel integration: feishu close:superseded PR close reason dedupe:child Duplicate issue/PR child in dedupe cluster size: S

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants