From 013939cfc7c2f76bc3c03b5392161c1426224b74 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Mon, 27 Apr 2026 13:35:57 -0700 Subject: [PATCH] fix(gateway): preserve repeated characters in chat stream merge (#72400) * fix(gateway): preserve repeated characters in chat stream merge * fix(gateway): cap live chat stream buffers --- CHANGELOG.md | 1 + src/gateway/live-chat-projector.ts | 34 +++------ .../server-chat.stream-text-merge.test.ts | 73 +++++++++++++++++++ 3 files changed, 86 insertions(+), 22 deletions(-) create mode 100644 src/gateway/server-chat.stream-text-merge.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 8571d8d616e..88c92c16bf5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -60,6 +60,7 @@ Docs: https://docs.openclaw.ai - WebChat/Control UI: support non-video file attachments in chat uploads while preserving the existing image attachment path and MIME-sniff fallback for generic image uploads. (#70947) Thanks @IAMSamuelRodda. - Skills/memory: restore Chokidar v5 hot reloads by watching concrete skill and memory roots with filters, including SKILL.md removals and deleted skill folders without broad workspace recursion. Fixes #27404, #33585, and #41606. Thanks @shelvenzhou, @08820048, and @rocke2020. - Gateway/chat: keep duplicate attachment-backed `chat.send` retries with the same idempotency key on the documented in-flight path so aborts still target the real active run. Fixes #70139. Thanks @Feelw00. +- Gateway/chat: preserve repeated boundary characters while merging assistant chat stream deltas, including repeated digits, CJK characters, and markdown/table tokens. Fixes #63769; carries forward #63994 and #65457. Thanks @yon950905 and @mohuaxiao. - Plugins: share package entrypoint resolution between install and discovery, reject mismatched `runtimeExtensions`, and cache bundled runtime-dependency manifest reads during scans. Thanks @vincentkoc. - WhatsApp/Web: keep quiet but healthy linked-device sessions connected by basing the watchdog on WhatsApp Web transport activity, while retaining a longer app-silence cap so frame activity cannot mask a stuck session forever. Fixes #70678; carries forward the focused #71466 approach and keeps #63939 as related configurable-timeout follow-up. Thanks @vincentkoc and @oromeis. - Discord/gateway: count failed health-monitor restart attempts toward cooldown and hourly caps, and evict stale account lifecycle state during channel reloads so repeated Discord gateway recovery cannot loop on old status. Fixes #38596. (#40413) Thanks @jellyAI-dev and @vashquez. diff --git a/src/gateway/live-chat-projector.ts b/src/gateway/live-chat-projector.ts index 4a8e63ef887..9aa2f2936d0 100644 --- a/src/gateway/live-chat-projector.ts +++ b/src/gateway/live-chat-projector.ts @@ -13,23 +13,13 @@ import { export { resolveAssistantEventPhase } from "../shared/chat-message-content.js"; -function appendUniqueSuffix(base: string, suffix: string): string { - if (!suffix) { - return base; +export const MAX_LIVE_CHAT_BUFFER_CHARS = 500_000; + +function capLiveAssistantBuffer(text: string): string { + if (text.length <= MAX_LIVE_CHAT_BUFFER_CHARS) { + return text; } - if (!base) { - return suffix; - } - if (base.endsWith(suffix)) { - return base; - } - const maxOverlap = Math.min(base.length, suffix.length); - for (let overlap = maxOverlap; overlap > 0; overlap -= 1) { - if (base.slice(-overlap) === suffix.slice(0, overlap)) { - return base + suffix.slice(overlap); - } - } - return base + suffix; + return text.slice(-MAX_LIVE_CHAT_BUFFER_CHARS); } export function resolveMergedAssistantText(params: { @@ -39,20 +29,20 @@ export function resolveMergedAssistantText(params: { }): string { const { previousText, nextText, nextDelta } = params; if (nextText && previousText) { - if (nextText.startsWith(previousText)) { - return nextText; + if (nextText.startsWith(previousText) && nextText.length > previousText.length) { + return capLiveAssistantBuffer(nextText); } if (previousText.startsWith(nextText) && !nextDelta) { - return previousText; + return capLiveAssistantBuffer(previousText); } } if (nextDelta) { - return appendUniqueSuffix(previousText, nextDelta); + return capLiveAssistantBuffer(previousText + nextDelta); } if (nextText) { - return nextText; + return capLiveAssistantBuffer(nextText); } - return previousText; + return capLiveAssistantBuffer(previousText); } export function normalizeLiveAssistantEventText(params: { text: string; delta?: unknown }): { diff --git a/src/gateway/server-chat.stream-text-merge.test.ts b/src/gateway/server-chat.stream-text-merge.test.ts new file mode 100644 index 00000000000..0bd2dd33f4b --- /dev/null +++ b/src/gateway/server-chat.stream-text-merge.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from "vitest"; +import { + MAX_LIVE_CHAT_BUFFER_CHARS, + resolveMergedAssistantText, +} from "./live-chat-projector.js"; + +describe("server chat stream text merge", () => { + it.each([ + { + name: "repeated digits", + chunks: ["1", "1", "1"], + expected: "111", + }, + { + name: "repeated CJK punctuation", + chunks: ["。", "。", "。"], + expected: "。。。", + }, + { + name: "repeated markdown emphasis tokens", + chunks: ["**", "**"], + expected: "****", + }, + { + name: "repeated markdown table separators", + chunks: ["|", "|", "|"], + expected: "|||", + }, + ])("appends incremental deltas without collapsing $name", ({ chunks, expected }) => { + const merged = chunks.reduce( + (previousText, nextDelta) => + resolveMergedAssistantText({ + previousText, + nextText: nextDelta, + nextDelta, + }), + "", + ); + + expect(merged).toBe(expected); + }); + + it("keeps cumulative snapshots from duplicating already-buffered text", () => { + expect( + resolveMergedAssistantText({ + previousText: "Hello", + nextText: "Hello world", + nextDelta: " world", + }), + ).toBe("Hello world"); + }); + + it("keeps non-prefix incremental segments after tool calls", () => { + expect( + resolveMergedAssistantText({ + previousText: "Before tool call", + nextText: "After tool call", + nextDelta: "\nAfter tool call", + }), + ).toBe("Before tool call\nAfter tool call"); + }); + + it("caps merged live text while preserving the newest assistant output", () => { + const result = resolveMergedAssistantText({ + previousText: "a".repeat(MAX_LIVE_CHAT_BUFFER_CHARS - 2), + nextText: "", + nextDelta: "bbbb", + }); + + expect(result).toHaveLength(MAX_LIVE_CHAT_BUFFER_CHARS); + expect(result.endsWith("bbbb")).toBe(true); + }); +});