mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-26 04:15:48 -06:00
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:
committed by
GitHub
parent
73e2489a5e
commit
8ace19a071
@@ -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;
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 [];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user