diff --git a/CHANGELOG.md b/CHANGELOG.md index 5cfeb39d456..5679ebbcbe3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/extensions/feishu/src/streaming-card.test.ts b/extensions/feishu/src/streaming-card.test.ts new file mode 100644 index 00000000000..913a4633ada --- /dev/null +++ b/extensions/feishu/src/streaming-card.test.ts @@ -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"); + }); +}); diff --git a/extensions/feishu/src/streaming-card.ts b/extensions/feishu/src/streaming-card.ts index f67926f4eb4..615636467a9 100644 --- a/extensions/feishu/src/streaming-card.ts +++ b/extensions/feishu/src/streaming-card.ts @@ -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