Skip to content

fix(feishu): finish streaming card closeout#71542

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

fix(feishu): finish streaming card closeout#71542
vincentkoc merged 1 commit intomainfrom
dedupe/feishu-streaming-followups-c143848

Conversation

@vincentkoc
Copy link
Copy Markdown
Member

Summary

  • finishes the Feishu streaming-card closeout after fix(feishu): coalesce streaming card final delivery #71523
  • strips reasoning/thinking tags from visible partial streaming snapshots
  • preserves cross-block partial snapshots after tool calls instead of re-merging each reset into prior text
  • enables streaming cards for Feishu topic/thread replies while preserving reply metadata
  • omits the generic main card header, shows transient tool/compaction status, and resets streaming state even when close fails

Canonical closeout

Supersedes:

Thanks @sesame437, @Vicky-v7, @maoku-family, @Pengxiao-Wang, and @Maple778.

Test plan

  • pnpm test:serial extensions/feishu/src/reply-dispatcher.test.ts extensions/feishu/src/streaming-card.test.ts
  • OPENCLAW_LOCAL_CHECK=0 pnpm check:changed

Note: check:changed passed before the final rebase. After rebasing again onto moving origin/main, the focused Feishu tests were blocked behind another worktree heavy-check lock for several minutes; the branch is now clean and GitHub CI is the final gate.

@openclaw-barnacle openclaw-barnacle Bot added the channel: feishu Channel integration: feishu label Apr 25, 2026
@vincentkoc vincentkoc self-assigned this Apr 25, 2026
@openclaw-barnacle openclaw-barnacle Bot added size: M maintainer Maintainer-authored PR labels Apr 25, 2026
@vincentkoc vincentkoc added the dedupe:parent Primary canonical item in dedupe cluster label Apr 25, 2026
@vincentkoc vincentkoc marked this pull request as ready for review April 25, 2026 10:52
@chatgpt-codex-connector
Copy link
Copy Markdown

Codex usage limits have been reached for code reviews. Please check with the admins of this repo to increase the limits by adding credits.
Repo admins can enable using credits for code reviews in their settings.

@aisle-research-bot
Copy link
Copy Markdown

aisle-research-bot Bot commented Apr 25, 2026

🔒 Aisle Security Analysis

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

# Severity Title
1 🟡 Medium Reasoning/ tags may leak via final or block streaming card updates (Feishu)
2 🟡 Medium Feishu streaming card markdown/mention injection via unescaped tool status line
3 🟡 Medium Unbounded streaming buffer growth in Feishu reply dispatcher (snapshotBaseText) can cause memory/CPU DoS
1. 🟡 Reasoning/ tags may leak via final or block streaming card updates (Feishu)
Property Value
Severity Medium
CWE CWE-200
Location extensions/feishu/src/reply-dispatcher.ts:494-505

Description

In the Feishu reply dispatcher, partial snapshot updates are cleaned with stripReasoningTagsFromText(), but other streaming paths still forward raw model text into the user-visible streaming card.

Affected flows:

  • Final streaming update: when a streamed reply receives info.kind === "final", the dispatcher sets streamText = text without stripping reasoning tags.
  • Block-to-streaming fallback: when info.kind === "block" and streaming is active, the dispatcher calls queueStreamingUpdate(text, { mode: "delta" ... }) without stripping reasoning tags.

If an upstream runtime/model emits <thinking>...</thinking> / <think> / antml:thinking tags inside the final/block text (even accidentally), those tags (and their contents) can be rendered to end users through the streaming card update/close path.

Vulnerable code (final path):

if (info?.kind === "final") {
  streamText = text;
  snapshotBaseText = "";
  lastSnapshotTextLength = text.length;
  flushStreamingCardUpdate(buildCombinedStreamText(reasoningText, streamText));
}

Recommendation

Apply stripReasoningTagsFromText() consistently to all text that can reach streaming cards (partial snapshots, block deltas, and final texts) before it is assigned to streamText / passed into queueStreamingUpdate().

Example fix:

const cleaned = stripReasoningTagsFromText(text, { mode: "strict", trim: "both" });
if (!cleaned) return;

if (info?.kind === "block") {
  queueStreamingUpdate(cleaned, { mode: "delta", dedupeWithLastPartial: true });
}

if (info?.kind === "final") {
  streamText = cleaned;
  snapshotBaseText = "";
  lastSnapshotTextLength = cleaned.length;
  flushStreamingCardUpdate(buildCombinedStreamText(reasoningText, streamText));
}

Alternatively, enforce (and document) a strong upstream invariant that reply.text for block/final never contains reasoning tags, and add a defense-in-depth strip here anyway.

2. 🟡 Feishu streaming card markdown/mention injection via unescaped tool status line
Property Value
Severity Medium
CWE CWE-116
Location extensions/feishu/src/reply-dispatcher.ts:608-613

Description

The streaming status line appended to Feishu card markdown is built from payload.name / payload.phase without any escaping/sanitization.

  • Input: payload.name or payload.phase provided to replyOptions.onToolStart.
  • Transform: Interpolated directly into a markdown string: 🔧 **Using: ${...}...**.
  • Sink: Included in the combined streaming text and sent to Feishu streaming card updates (streaming.update(...)), which updates a tag: "markdown" card element.
  • Impact: If tool names/phases can be influenced by an attacker (e.g., user-configurable tool/plugin definitions, forwarded tool metadata, or untrusted integrations), they can inject Feishu card markdown/"lark_md" constructs such as <at id=all></at> (mention everyone), <at id=...></at> (mention specific users), or malicious links/formatting into the card.

Vulnerable code:

updateStreamingStatusLine(
  `🔧 **Using: ${payload.name ?? payload.phase ?? "tool"}...**`,
);

Recommendation

Treat tool/phase labels as untrusted when rendering into Feishu card markdown.

Options:

  1. Escape/strip Feishu mention tags and markdown-special characters before interpolation.
function escapeLarkMdInline(input: string): string {
  return input
    .replace(/&/g, "&amp;")
    .replace(/</g, "&lt;")
    .replace(/>/g, "&gt;")
    .replace(/[\r\n]+/g, " ")
    .trim();
}

const toolLabel = escapeLarkMdInline(payload.name ?? payload.phase ?? "tool");
updateStreamingStatusLine(`🔧 **Using: ${toolLabel}...**`);
  1. Prefer a plain_text field/element for status (no markdown parsing), if supported by your card schema.

Also consider an allowlist for tool labels (e.g., /^[a-zA-Z0-9_\-.: ]{1,50}$/) to avoid unexpected rendering primitives.

3. 🟡 Unbounded streaming buffer growth in Feishu reply dispatcher (snapshotBaseText) can cause memory/CPU DoS
Property Value
Severity Medium
CWE CWE-400
Location extensions/feishu/src/reply-dispatcher.ts:287-305

Description

The Feishu streaming reply path maintains streamText across partial updates. Newly added snapshot-merging logic (snapshotBaseText/lastSnapshotTextLength) can preserve prior content and append new snapshot blocks when it detects a snapshot "reset".

  • Input: payload.text from onPartialReply (model/runtime-provided partial content) is passed into queueStreamingUpdate().
  • Issue: When startsNewSnapshotBlock triggers, the previous streamText is copied into snapshotBaseText and then concatenated with the new snapshot (streamText = ${snapshotBaseText}${nextText}``). Repeated resets can keep appending additional blocks, causing streamText to grow without bound.
  • Impact: Large, repeated string concatenations (slice, template literal concatenations) and repeated flushStreamingCardUpdate() calls can create high CPU usage (O(n) copies) and increased memory pressure, enabling a denial-of-service when an attacker can induce many partial updates / pathological snapshot patterns (e.g., via crafted prompts causing streaming output patterns).

Vulnerable code:

const currentSnapshotText = snapshotBaseText
  ? streamText.slice(snapshotBaseText.length)
  : streamText;
...
if (startsNewSnapshotBlock) {
  snapshotBaseText = streamText;
  streamText = `${snapshotBaseText}${nextText}`;
} else {
  streamText = `${snapshotBaseText}${mergeStreamingText(currentSnapshotText, nextText)}`;
}

Recommendation

Introduce hard limits and/or trimming on streaming buffers, and avoid repeated full-string concatenations.

Suggested mitigations:

  • Cap total streamed characters (e.g., 20k–100k) and truncate older content or stop streaming updates beyond the cap.
  • Limit the number of snapshot blocks/resets preserved (e.g., keep only the most recent block).
  • Consider building text via an array of chunks and joining only when sending updates (or use a rope/buffer strategy), to reduce O(n) copying.

Example (simple cap):

const MAX_STREAM_CHARS = 50_000;

function clampStream(text: string): string {
  if (text.length <= MAX_STREAM_CHARS) return text;// Keep tail to preserve most recent output
  return text.slice(text.length - MAX_STREAM_CHARS);
}// After updating streamText:
streamText = clampStream(streamText);// If you clamp, also adjust snapshotBaseText accordingly or reset it:
if (snapshotBaseText.length > streamText.length) snapshotBaseText = "";

Also consider dropping snapshotBaseText preservation when the combined length exceeds the cap (reset to empty) to prevent unbounded growth.


Analyzed PR: #71542 at commit 7d4942c

Last updated on: 2026-04-25T10:54:12Z

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented Apr 25, 2026

Greptile Summary

This PR completes the Feishu streaming-card closeout by stripping leaked <thinking> tags from visible partial snapshots, preserving cross-block snapshot state across tool calls, enabling streaming cards for topic/thread replies (with rootId forwarded), omitting the generic main-agent card header, surfacing transient tool and compaction status lines, and wrapping streaming cleanup in a finally block so state is always reset even when close() fails. All six behaviors are covered by new unit tests.

Confidence Score: 4/5

Safe to merge; logic is well-tested and the two findings are narrow edge-case quality concerns.

No P0 or P1 issues found. Two P2 items: the typing-indicator reaction can persist if streaming.close() throws (pre-existing pattern, now slightly more reachable), and the multi-reset snapshot-base guard checks only the current slice. Both are unlikely to affect normal usage and the overall change is a clear improvement over the previous state.

No files require special attention beyond the two P2 notes in reply-dispatcher.ts.

Comments Outside Diff (1)

  1. extensions/feishu/src/reply-dispatcher.ts, line 565-567 (link)

    P2 onIdle/onError skip typing cleanup when closeStreaming throws

    If streaming.close() rejects, closeStreaming re-throws (the finally block doesn't suppress it). Both onIdle and onError have typingCallbacks?.onIdle?.() sequenced after the await closeStreaming() call, so the typing-indicator emoji reaction is never removed on a close failure. The streaming state itself is now correctly cleaned up by the finally block, but the reaction persists until the next turn's typing.stop runs.

    Consider wrapping the closeStreaming call in its own try/finally inside onIdle/onError so the typing callback is always reached.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: extensions/feishu/src/reply-dispatcher.ts
    Line: 565-567
    
    Comment:
    **`onIdle`/`onError` skip typing cleanup when `closeStreaming` throws**
    
    If `streaming.close()` rejects, `closeStreaming` re-throws (the `finally` block doesn't suppress it). Both `onIdle` and `onError` have `typingCallbacks?.onIdle?.()` sequenced *after* the `await closeStreaming()` call, so the typing-indicator emoji reaction is never removed on a close failure. The streaming state itself is now correctly cleaned up by the `finally` block, but the reaction persists until the next turn's `typing.stop` runs.
    
    Consider wrapping the `closeStreaming` call in its own `try`/`finally` inside `onIdle`/`onError` so the typing callback is always reached.
    
    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/reply-dispatcher.ts
Line: 565-567

Comment:
**`onIdle`/`onError` skip typing cleanup when `closeStreaming` throws**

If `streaming.close()` rejects, `closeStreaming` re-throws (the `finally` block doesn't suppress it). Both `onIdle` and `onError` have `typingCallbacks?.onIdle?.()` sequenced *after* the `await closeStreaming()` call, so the typing-indicator emoji reaction is never removed on a close failure. The streaming state itself is now correctly cleaned up by the `finally` block, but the reaction persists until the next turn's `typing.stop` runs.

Consider wrapping the `closeStreaming` call in its own `try`/`finally` inside `onIdle`/`onError` so the typing callback is always reached.

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/reply-dispatcher.ts
Line: 290-305

Comment:
**Snapshot-block heuristic only checks `currentSnapshotText`, not the accumulated base**

The `!currentSnapshotText.includes(nextText)` guard looks only at the *current block's* slice (`streamText.slice(snapshotBaseText.length)`). If there have already been multiple block resets, `snapshotBaseText` can itself be several blocks long. A new snapshot that happens to duplicate a phrase that appears only in `snapshotBaseText` (not the current block) will not be detected as a repeat, which is fine — but if `mergeStreamingText` on the current block returns an inflated string that *does* include it, the base grows incorrectly.

This is a narrow edge case (requires a repeated phrase spanning multiple inter-tool resets), but worth noting if longer multi-tool conversations exhibit duplicated content in streaming cards.

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

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

Comment on lines +290 to +305
} else {
const currentSnapshotText = snapshotBaseText
? streamText.slice(snapshotBaseText.length)
: streamText;
const startsNewSnapshotBlock =
lastSnapshotTextLength >= 20 &&
nextText.length < lastSnapshotTextLength * 0.5 &&
!currentSnapshotText.includes(nextText);
if (startsNewSnapshotBlock) {
snapshotBaseText = streamText;
streamText = `${snapshotBaseText}${nextText}`;
} else {
streamText = `${snapshotBaseText}${mergeStreamingText(currentSnapshotText, nextText)}`;
}
lastSnapshotTextLength = nextText.length;
}
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 Snapshot-block heuristic only checks currentSnapshotText, not the accumulated base

The !currentSnapshotText.includes(nextText) guard looks only at the current block's slice (streamText.slice(snapshotBaseText.length)). If there have already been multiple block resets, snapshotBaseText can itself be several blocks long. A new snapshot that happens to duplicate a phrase that appears only in snapshotBaseText (not the current block) will not be detected as a repeat, which is fine — but if mergeStreamingText on the current block returns an inflated string that does include it, the base grows incorrectly.

This is a narrow edge case (requires a repeated phrase spanning multiple inter-tool resets), but worth noting if longer multi-tool conversations exhibit duplicated content in streaming cards.

Prompt To Fix With AI
This is a comment left during a code review.
Path: extensions/feishu/src/reply-dispatcher.ts
Line: 290-305

Comment:
**Snapshot-block heuristic only checks `currentSnapshotText`, not the accumulated base**

The `!currentSnapshotText.includes(nextText)` guard looks only at the *current block's* slice (`streamText.slice(snapshotBaseText.length)`). If there have already been multiple block resets, `snapshotBaseText` can itself be several blocks long. A new snapshot that happens to duplicate a phrase that appears only in `snapshotBaseText` (not the current block) will not be detected as a repeat, which is fine — but if `mergeStreamingText` on the current block returns an inflated string that *does* include it, the base grows incorrectly.

This is a narrow edge case (requires a repeated phrase spanning multiple inter-tool resets), but worth noting if longer multi-tool conversations exhibit duplicated content in streaming cards.

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