Skip to content

fix(feishu): coalesce streaming card final delivery#71523

Merged
vincentkoc merged 1 commit intomainfrom
dedupe/feishu-streaming-card-c143848
Apr 25, 2026
Merged

fix(feishu): coalesce streaming card final delivery#71523
vincentkoc merged 1 commit intomainfrom
dedupe/feishu-streaming-card-c143848

Conversation

@vincentkoc
Copy link
Copy Markdown
Member

Summary

  • keep Feishu streaming-card turns on one live card until idle instead of closing on every final payload
  • flush throttled pending card text after meaningful deltas or natural text boundaries
  • skip exact block/partial repeats while preserving media delivery and non-streaming fallbacks
  • add focused dispatcher and streaming-card regression coverage plus an Unreleased changelog entry

Canonical for Feishu streaming-card delivery cluster C143848.

Supersedes #53534 and covers the duplicate-card/root streaming overlap in #39085, #42940, #48382, #48384, and #50822.

Credit: Thanks @allan0509 for the representative streaming-card delivery fix path.

Test plan

  • pnpm test:serial extensions/feishu/src/reply-dispatcher.test.ts extensions/feishu/src/streaming-card.test.ts
  • pnpm check:changed reached clean changed lanes after expanding the sparse worktree: conflict markers, extension prod/test typecheck, extension lint, import cycles, and 58 Feishu test files / 634 tests. The non-tty local wrapper intermittently exited before printing a final status, so the targeted serial test above was rerun after rebase.

@vincentkoc vincentkoc marked this pull request as ready for review April 25, 2026 10:00
@vincentkoc vincentkoc self-assigned this Apr 25, 2026
@openclaw-barnacle openclaw-barnacle Bot added the channel: feishu Channel integration: feishu label Apr 25, 2026
@aisle-research-bot
Copy link
Copy Markdown

aisle-research-bot Bot commented Apr 25, 2026

🔒 Aisle Security Analysis

We found 1 potential security issue(s) in this PR:

# Severity Title
1 🟡 Medium Throttle bypass enables high-frequency Feishu Card API updates (application-layer DoS)
1. 🟡 Throttle bypass enables high-frequency Feishu Card API updates (application-layer DoS)
Property Value
Severity Medium
CWE CWE-400
Location extensions/feishu/src/streaming-card.ts:118-383

Description

FeishuStreamingSession.update() is intended to throttle outbound Feishu Card Kit update calls, but the new shouldPushStreamingUpdate() logic can bypass the throttle window.

  • Input text passed to update() ultimately comes from streamed model output (see reply dispatcher calling queueStreamingUpdate(...) / streaming card updates), which is typically user-influenced/untrusted.
  • When shouldPushStreamingUpdate(...) returns true (e.g., nextText ends with punctuation like : ; ! or newline, or grows by >= 18 chars), the function sends an update even if the previous update happened less than STREAMING_UPDATE_THROTTLE_MS ago.
  • This allows an attacker to craft output that ends every tiny delta with :/;/! etc. and trigger very high-frequency PUT /cardkit/v1/cards/.../elements/... requests, potentially exhausting Feishu rate limits, causing account/app lockouts, or increasing costs.

Vulnerable code:

function hasNaturalStreamingBoundary(text: string): boolean {
  return /[\n!?;:]$/.test(text);
}

function shouldPushStreamingUpdate(previousText: string, nextText: string): boolean {
  if (!previousText) return true;
  if (hasNaturalStreamingBoundary(nextText)) return true;
  return nextText.length - previousText.length >= STREAMING_SIGNIFICANT_DELTA_CHARS;
}

if (!shouldForceUpdate && now - this.lastUpdateTime < this.updateThrottleMs) {
  this.schedulePendingFlush();
  return;
}

Recommendation

Enforce a hard rate limit for outbound update calls regardless of text boundaries.

Options:

  1. Never bypass the throttle (still allow “natural boundaries” to influence what gets flushed, but not when):
const now = Date.now();
if (now - this.lastUpdateTime < this.updateThrottleMs) {
  this.pendingText = mergedInput;
  this.schedulePendingFlush();
  return;
}
this.lastUpdateTime = now;
  1. If you must support “boundary fast-path”, cap it with a secondary limiter (e.g., max updates/sec and/or token bucket), e.g.:
const MAX_UPDATES_PER_SEC = 5;// track a rolling window and refuse/schedule if exceeded

Also consider defending-in-depth upstream by limiting how often streaming callbacks can enqueue updates.


Analyzed PR: #71523 at commit e12c148

Last updated on: 2026-04-25T10:02:10Z

@openclaw-barnacle openclaw-barnacle Bot added size: M maintainer Maintainer-authored PR labels Apr 25, 2026
@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented Apr 25, 2026

Greptile Summary

This PR fixes Feishu streaming-card delivery so that multiple final payloads within a single agent turn are coalesced onto one live card (closed only when onIdle fires) rather than opening a new card for each final payload. It also adds a smarter throttle that flushes pending updates immediately on natural text boundaries or significant character deltas, and deduplicates block payloads that exactly match the last partial snapshot. All findings are P2 style observations.

Confidence Score: 4/5

Safe to merge; all findings are non-blocking style suggestions.

No P0 or P1 bugs were identified. The coalescing logic, throttle refactor, and deduplication are all sound. Two P2 observations: the flush-timer callback uses void without a .catch (errors silently swallowed), and lastUpdateTime is stamped before the queued network call completes (throttle accuracy). A third P2 flags the private-field casting in tests. None of these affect production correctness.

streaming-card.ts (flush timer error handling and throttle timestamp accuracy), streaming-card.test.ts (private-field cast fragility)

Prompt To Fix All With AI
This is a comment left during a code review.
Path: extensions/feishu/src/streaming-card.ts
Line: 356-363

Comment:
**Unhandled promise rejection in flush timer**

`void this.update(pending)` discards the returned promise. While `updateCardContent` logs its own errors, any unexpected throw inside `update()` (e.g., from `mergeStreamingText` or the queue chain) would produce a silent unhandled rejection — no log, no retry. Consider adding a `.catch` so failures are at least observable.

```suggestion
      void this.update(pending).catch((e) => this.log?.(`Flush failed: ${String(e)}`));
```

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: 377-383

Comment:
**`lastUpdateTime` is stamped before the queue callback executes**

`this.lastUpdateTime = now` is set synchronously, before the queued `updateCardContent` call actually completes. If a prior item in the queue is still in-flight (e.g., a slow network round-trip), the throttle window starts ticking from the *queue-entry* time rather than the *send* time. Under sustained backlog this means the effective burst rate can exceed `1/updateThrottleMs`. Not a correctness issue for normal usage, but worth a comment if the 160 ms rate is a Feishu API constraint rather than a UX hint.

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.test.ts
Line: 17-36

Comment:
**Private-field access via `as unknown as` is fragile**

`setStreamingSessionInternals` casts `FeishuStreamingSession` to a plain object shape and directly mutates `state` and `lastUpdateTime`. Because these fields are TypeScript `private`, any rename or layout change in the source class will silently break the tests at runtime with no compile-time warning. Consider exporting a dedicated `_forTest` seeding method (behind a `/* @internal */` comment) or using `vi.spyOn` on prototype methods to avoid the fragile cast.

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

Reviews (1): Last reviewed commit: "fix(feishu): coalesce streaming card fin..." | Re-trigger Greptile

Comment on lines +356 to +363
this.flushTimer = setTimeout(() => {
this.flushTimer = null;
const pending = this.pendingText;
if (!pending || this.closed) {
return;
}
void this.update(pending);
}, delayMs);
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.

P2 Unhandled promise rejection in flush timer

void this.update(pending) discards the returned promise. While updateCardContent logs its own errors, any unexpected throw inside update() (e.g., from mergeStreamingText or the queue chain) would produce a silent unhandled rejection — no log, no retry. Consider adding a .catch so failures are at least observable.

Suggested change
this.flushTimer = setTimeout(() => {
this.flushTimer = null;
const pending = this.pendingText;
if (!pending || this.closed) {
return;
}
void this.update(pending);
}, delayMs);
void this.update(pending).catch((e) => this.log?.(`Flush failed: ${String(e)}`));
Prompt To Fix With AI
This is a comment left during a code review.
Path: extensions/feishu/src/streaming-card.ts
Line: 356-363

Comment:
**Unhandled promise rejection in flush timer**

`void this.update(pending)` discards the returned promise. While `updateCardContent` logs its own errors, any unexpected throw inside `update()` (e.g., from `mergeStreamingText` or the queue chain) would produce a silent unhandled rejection — no log, no retry. Consider adding a `.catch` so failures are at least observable.

```suggestion
      void this.update(pending).catch((e) => this.log?.(`Flush failed: ${String(e)}`));
```

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

Comment on lines +377 to 383
const shouldForceUpdate = shouldPushStreamingUpdate(this.state.currentText, mergedInput);
const now = Date.now();
if (now - this.lastUpdateTime < this.updateThrottleMs) {
this.pendingText = mergedInput;
if (!shouldForceUpdate && now - this.lastUpdateTime < this.updateThrottleMs) {
this.schedulePendingFlush();
return;
}
this.pendingText = null;
this.lastUpdateTime = now;
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.

P2 lastUpdateTime is stamped before the queue callback executes

this.lastUpdateTime = now is set synchronously, before the queued updateCardContent call actually completes. If a prior item in the queue is still in-flight (e.g., a slow network round-trip), the throttle window starts ticking from the queue-entry time rather than the send time. Under sustained backlog this means the effective burst rate can exceed 1/updateThrottleMs. Not a correctness issue for normal usage, but worth a comment if the 160 ms rate is a Feishu API constraint rather than a UX hint.

Prompt To Fix With AI
This is a comment left during a code review.
Path: extensions/feishu/src/streaming-card.ts
Line: 377-383

Comment:
**`lastUpdateTime` is stamped before the queue callback executes**

`this.lastUpdateTime = now` is set synchronously, before the queued `updateCardContent` call actually completes. If a prior item in the queue is still in-flight (e.g., a slow network round-trip), the throttle window starts ticking from the *queue-entry* time rather than the *send* time. Under sustained backlog this means the effective burst rate can exceed `1/updateThrottleMs`. Not a correctness issue for normal usage, but worth a comment if the 160 ms rate is a Feishu API constraint rather than a UX hint.

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

Comment on lines +17 to +36
messageId: string;
sequence: number;
currentText: string;
hasNote: boolean;
};

function setStreamingSessionInternals(
session: FeishuStreamingSession,
values: {
state: StreamingSessionState;
lastUpdateTime?: number;
},
) {
const internals = session as unknown as {
state: StreamingSessionState;
lastUpdateTime: number;
};
internals.state = values.state;
if (values.lastUpdateTime !== undefined) {
internals.lastUpdateTime = values.lastUpdateTime;
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.

P2 Private-field access via as unknown as is fragile

setStreamingSessionInternals casts FeishuStreamingSession to a plain object shape and directly mutates state and lastUpdateTime. Because these fields are TypeScript private, any rename or layout change in the source class will silently break the tests at runtime with no compile-time warning. Consider exporting a dedicated _forTest seeding method (behind a /* @internal */ comment) or using vi.spyOn on prototype methods to avoid the fragile cast.

Prompt To Fix With AI
This is a comment left during a code review.
Path: extensions/feishu/src/streaming-card.test.ts
Line: 17-36

Comment:
**Private-field access via `as unknown as` is fragile**

`setStreamingSessionInternals` casts `FeishuStreamingSession` to a plain object shape and directly mutates `state` and `lastUpdateTime`. Because these fields are TypeScript `private`, any rename or layout change in the source class will silently break the tests at runtime with no compile-time warning. Consider exporting a dedicated `_forTest` seeding method (behind a `/* @internal */` comment) or using `vi.spyOn` on prototype methods to avoid the fragile cast.

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

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

Labels

channel: feishu Channel integration: feishu dedupe:parent Primary canonical item in dedupe cluster maintainer Maintainer-authored PR size: M

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant