fix(ui): unkeyed preamble segments corrupted the accumulated-stream prefix tracker (#123515)

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.
This commit is contained in:
Peter Steinberger
2026-08-14 00:26:24 -07:00
committed by GitHub
parent 73e2489a5e
commit 8ace19a071
4 changed files with 53 additions and 12 deletions
+15
View File
@@ -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;
+6 -2
View File
@@ -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<ChatItem | Mes
const visibleText = usesAccumulatedText
? trimAccumulatedStreamPrefix(text, previousAccumulatedStreamText)
: text;
if (usesAccumulatedText && text.length > 0) {
previousAccumulatedStreamText = text;
if (usesAccumulatedText) {
previousAccumulatedStreamText = advanceAccumulatedStreamText(
previousAccumulatedStreamText,
text,
);
}
if (visibleText.length > 0) {
const streamKey = `stream-seg:${props.sessionKey}:${i}`;
+25
View File
@@ -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({
+7 -10
View File
@@ -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 [];
}