fix(feishu): non-blocking WS ACK and preserve full streaming card content (#29616)

* fix(feishu): non-blocking ws ack and preserve streaming card full content

* fix(feishu): preserve fragmented streaming text without newline artifacts

---------

Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
This commit is contained in:
Huaqing.Hao 2026-03-03 12:17:15 +08:00 committed by GitHub
parent a5a6952bf2
commit a5a7239182
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 52 additions and 5 deletions

View File

@ -245,6 +245,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- Feishu/Streaming card text fidelity: merge throttled/fragmented partial updates without dropping content and avoid newline injection when stitching chunk-style deltas so card-stream output matches final reply text. (#29616) Thanks @HaoHuaqing.
- Security/Feishu webhook ingress: bound unauthenticated webhook rate-limit state with stale-window pruning and a hard key cap to prevent unbounded pre-auth memory growth from rotating source keys. (#26050) Thanks @bmendonca3.
- Security/Compaction audit: remove the post-compaction audit injection message. (#28507) Thanks @fuller-stack-dev and @vincentkoc.
- Web tools/RFC2544 fake-IP compatibility: allow RFC2544 benchmark range (`198.18.0.0/15`) for trusted web-tool fetch endpoints so proxy fake-IP networking modes do not trigger false SSRF blocks. Landed from contributor PR #31176 by @sunkinux. Thanks @sunkinux.

View File

@ -0,0 +1,18 @@
import { describe, expect, it } from "vitest";
import { mergeStreamingText } from "./streaming-card.js";
describe("mergeStreamingText", () => {
it("prefers the latest full text when it already includes prior text", () => {
expect(mergeStreamingText("hello", "hello world")).toBe("hello world");
});
it("keeps previous text when the next partial is empty or redundant", () => {
expect(mergeStreamingText("hello", "")).toBe("hello");
expect(mergeStreamingText("hello world", "hello")).toBe("hello world");
});
it("appends fragmented chunks without injecting newlines", () => {
expect(mergeStreamingText("hello wor", "ld")).toBe("hello world");
expect(mergeStreamingText("line1", "line2")).toBe("line1line2");
});
});

View File

@ -85,6 +85,25 @@ function truncateSummary(text: string, max = 50): string {
return clean.length <= max ? clean : clean.slice(0, max - 3) + "...";
}
export function mergeStreamingText(
previousText: string | undefined,
nextText: string | undefined,
): string {
const previous = typeof previousText === "string" ? previousText : "";
const next = typeof nextText === "string" ? nextText : "";
if (!next) {
return previous;
}
if (!previous || next === previous || next.includes(previous)) {
return next;
}
if (previous.includes(next)) {
return previous;
}
// Fallback for fragmented partial chunks: append as-is to avoid losing tokens.
return `${previous}${next}`;
}
/** Streaming card session manager */
export class FeishuStreamingSession {
private client: Client;
@ -235,10 +254,15 @@ export class FeishuStreamingSession {
if (!this.state || this.closed) {
return;
}
const mergedInput = mergeStreamingText(this.pendingText ?? this.state.currentText, text);
if (!mergedInput || mergedInput === this.state.currentText) {
return;
}
// Throttle: skip if updated recently, but remember pending text
const now = Date.now();
if (now - this.lastUpdateTime < this.updateThrottleMs) {
this.pendingText = text;
this.pendingText = mergedInput;
return;
}
this.pendingText = null;
@ -248,8 +272,12 @@ export class FeishuStreamingSession {
if (!this.state || this.closed) {
return;
}
this.state.currentText = text;
await this.updateCardContent(text, (e) => this.log?.(`Update failed: ${String(e)}`));
const mergedText = mergeStreamingText(this.state.currentText, mergedInput);
if (!mergedText || mergedText === this.state.currentText) {
return;
}
this.state.currentText = mergedText;
await this.updateCardContent(mergedText, (e) => this.log?.(`Update failed: ${String(e)}`));
});
await this.queue;
}
@ -261,8 +289,8 @@ export class FeishuStreamingSession {
this.closed = true;
await this.queue;
// Use finalText, or pending throttled text, or current text
const text = finalText ?? this.pendingText ?? this.state.currentText;
const pendingMerged = mergeStreamingText(this.state.currentText, this.pendingText ?? undefined);
const text = finalText ? mergeStreamingText(pendingMerged, finalText) : pendingMerged;
const apiBase = resolveApiBase(this.creds.domain);
// Only send final update if content differs from what's already displayed