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
This commit is contained in:
Vincent Koc
2026-04-27 13:35:57 -07:00
committed by GitHub
parent 59faa023fe
commit 013939cfc7
3 changed files with 86 additions and 22 deletions

View File

@@ -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.

View File

@@ -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 }): {

View File

@@ -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);
});
});