From 8ace19a071403dfdc6adfb8901af09d2177fcc96 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 14 Aug 2026 00:26:24 -0700 Subject: [PATCH] fix(ui): unkeyed preamble segments corrupted the accumulated-stream prefix tracker (#123515) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit itemId-less preamble segments carry standalone text, but the tracker treated any itemId-less segment as cumulative run text. A preamble made the tracker diverge from the real cumulative prefix, the next snapshot failed startsWith, and every earlier segment's text rendered twice — persisted at terminal materialization. advanceAccumulatedStreamText now owns the continuity rule at all four advance sites: only text that extends the current baseline becomes the new baseline. --- ui/src/lib/chat/chat-types.ts | 15 +++++++++++++ ui/src/pages/chat/chat-thread-build.ts | 8 +++++-- ui/src/pages/chat/chat-thread.test.ts | 25 ++++++++++++++++++++++ ui/src/pages/chat/stream-reconciliation.ts | 17 ++++++--------- 4 files changed, 53 insertions(+), 12 deletions(-) diff --git a/ui/src/lib/chat/chat-types.ts b/ui/src/lib/chat/chat-types.ts index 4643a84ad46f..ff753b14de74 100644 --- a/ui/src/lib/chat/chat-types.ts +++ b/ui/src/lib/chat/chat-types.ts @@ -125,6 +125,21 @@ export function streamSegmentUsesAccumulatedText(segment: { itemId?: unknown }): return !streamSegmentHasItemId(segment); } +/** Advance the accumulated-text tracker only when the segment genuinely + extends it. A standalone (itemId-less) preamble whose text is not part of + the cumulative run text must not become the prefix baseline: the next + cumulative snapshot would fail the startsWith check and re-render every + earlier segment's text. */ +export function advanceAccumulatedStreamText( + previousText: string | null, + text: string, +): string | null { + if (!text.trim()) { + return previousText; + } + return previousText === null || text.startsWith(previousText) ? text : previousText; +} + export function trimAccumulatedStreamPrefix(text: string, previousText: string | null): string { if (!previousText || !text.startsWith(previousText)) { return text; diff --git a/ui/src/pages/chat/chat-thread-build.ts b/ui/src/pages/chat/chat-thread-build.ts index addb23fe0d39..2c196502b44f 100644 --- a/ui/src/pages/chat/chat-thread-build.ts +++ b/ui/src/pages/chat/chat-thread-build.ts @@ -9,6 +9,7 @@ import type { QuestionPrompt } from "../../app/question-prompt.ts"; import { t } from "../../i18n/index.ts"; import type { ChatItem, ChatQueueItem, MessageGroup } from "../../lib/chat/chat-types.ts"; import { + advanceAccumulatedStreamText, streamSegmentHasItemId, streamSegmentUsesAccumulatedText, trimAccumulatedStreamPrefix, @@ -478,8 +479,11 @@ export function buildChatItems(props: BuildChatItemsProps): Array 0) { - previousAccumulatedStreamText = text; + if (usesAccumulatedText) { + previousAccumulatedStreamText = advanceAccumulatedStreamText( + previousAccumulatedStreamText, + text, + ); } if (visibleText.length > 0) { const streamKey = `stream-seg:${props.sessionKey}:${i}`; diff --git a/ui/src/pages/chat/chat-thread.test.ts b/ui/src/pages/chat/chat-thread.test.ts index dad6fa00a15c..4972d01bc570 100644 --- a/ui/src/pages/chat/chat-thread.test.ts +++ b/ui/src/pages/chat/chat-thread.test.ts @@ -2586,6 +2586,31 @@ describe("buildCachedChatItems", () => { ]); }); + it("keeps an unkeyed preamble from corrupting the accumulated prefix tracker", () => { + // A standalone (itemId-less) preamble whose text is not part of the + // cumulative run text must not become the prefix baseline — pre-fix the + // next cumulative snapshot re-rendered every earlier segment's text. + const items = buildCachedChatItems( + createProps({ + streamSegments: [ + { text: "First thought.", ts: 1, toolCallId: "call-1" }, + { text: "Standalone preamble", ts: 2 }, + { text: "First thought. After tool.", ts: 3, toolCallId: "call-2" }, + ], + toolMessages: [ + chatMessage("toolResult", "Tool one", 2), + chatMessage("toolResult", "Tool two", 4), + ], + }), + ); + + expect(items.filter((item) => item.kind === "stream")).toMatchObject([ + { text: "First thought." }, + { text: "Standalone preamble" }, + { text: "After tool." }, + ]); + }); + it("deduplicates accumulated stream snapshots around tool cards", () => { const items = buildCachedChatItems( createProps({ diff --git a/ui/src/pages/chat/stream-reconciliation.ts b/ui/src/pages/chat/stream-reconciliation.ts index 4f0a88100c19..ee248ac1dc99 100644 --- a/ui/src/pages/chat/stream-reconciliation.ts +++ b/ui/src/pages/chat/stream-reconciliation.ts @@ -5,6 +5,7 @@ import { normalizeOptionalString, } from "@openclaw/normalization-core/string-coerce"; import { + advanceAccumulatedStreamText, streamSegmentHasItemId, streamSegmentUsesAccumulatedText, trimAccumulatedStreamPrefix, @@ -304,8 +305,8 @@ function visibleAssistantStreamParts( toolCallId: explicitToolCallId ?? indexedToolRef?.id, }); } - if (usesAccumulatedText && segment.text.trim()) { - previousText = segment.text; + if (usesAccumulatedText) { + previousText = advanceAccumulatedStreamText(previousText, segment.text); } } if (opts.includeCurrent !== false && typeof state.chatStream === "string") { @@ -338,12 +339,8 @@ export function visibleCurrentAssistantStreamTail( : []; let previousText: string | null = null; for (const segment of segments) { - if ( - streamSegmentUsesAccumulatedText(segment) && - typeof segment.text === "string" && - segment.text.trim() - ) { - previousText = segment.text; + if (streamSegmentUsesAccumulatedText(segment) && typeof segment.text === "string") { + previousText = advanceAccumulatedStreamText(previousText, segment.text); } } return visibleAssistantStreamText( @@ -651,8 +648,8 @@ export function prunePersistedToolStreamMessages( : indexedToolRef?.identity; const text = typeof segment.text === "string" ? segment.text : ""; if (toolIdentity && persistedToolIds.has(toolIdentity)) { - if (streamSegmentUsesAccumulatedText(segment) && text.trim()) { - lastPrunedAccumulatedText = text; + if (streamSegmentUsesAccumulatedText(segment)) { + lastPrunedAccumulatedText = advanceAccumulatedStreamText(lastPrunedAccumulatedText, text); } return []; }