Skip to content

Commit 5671413

Browse files
author
caizongding.1
committed
fix(ui): deduplicate streaming chat segments to prevent growing duplicate bubbles (#47188)
The gateway sends the full accumulated assistant text in each streaming delta event. When a tool call interrupts the stream, the client saves the current chatStream as a segment. Subsequent deltas (still containing the full accumulated text) then show overlapping content: each segment contains all prior text, and the live stream bubble repeats it again. This was previously masked by a loadChatHistory() call after every tool result (removed in 0e8672a to fix a reload storm). Fix: track chatStreamSegmentOffset — the length of text already committed to segments. When saving a new segment, slice off the known prefix to store only the delta. When rendering the live stream bubble, strip the offset prefix so only new text appears. Adds test coverage for the segment dedup in chat.test.ts.
1 parent 5c5c64b commit 5671413

9 files changed

Lines changed: 59 additions & 4 deletions

File tree

68 Bytes
Loading

ui/src/ui/app-gateway.node.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,7 @@ function createHost() {
131131
chatMessages: [],
132132
chatToolMessages: [],
133133
chatStreamSegments: [],
134+
chatStreamSegmentOffset: 0,
134135
chatStream: null,
135136
chatStreamStartedAt: null,
136137
chatRunId: null,

ui/src/ui/app-render.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1360,6 +1360,7 @@ export function renderApp(state: AppViewState) {
13601360
messages: state.chatMessages,
13611361
toolMessages: state.chatToolMessages,
13621362
streamSegments: state.chatStreamSegments,
1363+
streamSegmentOffset: state.chatStreamSegmentOffset,
13631364
stream: state.chatStream,
13641365
streamStartedAt: state.chatStreamStartedAt,
13651366
draft: state.chatMessage,

ui/src/ui/app-tool-stream.node.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ function createHost(overrides?: Partial<MutableHost>): MutableHost {
1616
chatStream: null,
1717
chatStreamStartedAt: null,
1818
chatStreamSegments: [],
19+
chatStreamSegmentOffset: 0,
1920
toolStreamById: new Map<string, ToolStreamEntry>(),
2021
toolStreamOrder: [],
2122
chatToolMessages: [],

ui/src/ui/app-tool-stream.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ type ToolStreamHost = {
3131
chatStream: string | null;
3232
chatStreamStartedAt: number | null;
3333
chatStreamSegments: Array<{ text: string; ts: number }>;
34+
chatStreamSegmentOffset: number;
3435
toolStreamById: Map<string, ToolStreamEntry>;
3536
toolStreamOrder: string[];
3637
chatToolMessages: Record<string, unknown>[];
@@ -242,6 +243,7 @@ export function resetToolStream(host: ToolStreamHost) {
242243
host.toolStreamOrder = [];
243244
host.chatToolMessages = [];
244245
host.chatStreamSegments = [];
246+
host.chatStreamSegmentOffset = 0;
245247
}
246248

247249
export type CompactionStatus = {
@@ -437,8 +439,17 @@ export function handleAgentEvent(host: ToolStreamHost, payload?: AgentEventPaylo
437439
if (!entry) {
438440
// Commit any in-progress streaming text as a segment so it renders
439441
// above the tool card instead of below it.
442+
// The server sends the full accumulated assistant text in each delta,
443+
// so chatStream contains text already represented by previous segments.
444+
// We strip the known prefix to avoid showing duplicate growing bubbles.
445+
// See: https://github.com/openclaw/openclaw/issues/47188
440446
if (host.chatStream && host.chatStream.trim().length > 0) {
441-
host.chatStreamSegments = [...host.chatStreamSegments, { text: host.chatStream, ts: now }];
447+
const offset = host.chatStreamSegmentOffset ?? 0;
448+
const deltaText = host.chatStream.slice(offset);
449+
if (deltaText.trim().length > 0) {
450+
host.chatStreamSegments = [...host.chatStreamSegments, { text: deltaText, ts: now }];
451+
}
452+
host.chatStreamSegmentOffset = host.chatStream.length;
442453
host.chatStream = null;
443454
host.chatStreamStartedAt = null;
444455
}

ui/src/ui/app-view-state.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ export type AppViewState = {
6464
chatMessages: unknown[];
6565
chatToolMessages: unknown[];
6666
chatStreamSegments: Array<{ text: string; ts: number }>;
67+
chatStreamSegmentOffset: number;
6768
chatStream: string | null;
6869
chatStreamStartedAt: number | null;
6970
chatRunId: string | null;

ui/src/ui/app.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,7 @@ export class OpenClawApp extends LitElement {
151151
@state() chatMessages: unknown[] = [];
152152
@state() chatToolMessages: unknown[] = [];
153153
@state() chatStreamSegments: Array<{ text: string; ts: number }> = [];
154+
@state() chatStreamSegmentOffset = 0;
154155
@state() chatStream: string | null = null;
155156
@state() chatStreamStartedAt: number | null = null;
156157
@state() chatRunId: string | null = null;

ui/src/ui/views/chat.test.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,7 @@ function createProps(overrides: Partial<ChatProps> = {}): ChatProps {
132132
messages: [],
133133
toolMessages: [],
134134
streamSegments: [],
135+
streamSegmentOffset: 0,
135136
stream: null,
136137
streamStartedAt: null,
137138
assistantAvatarUrl: null,
@@ -768,4 +769,34 @@ describe("chat view", () => {
768769
);
769770
expect(labels).not.toContain("Subagent: cron-config-check");
770771
});
772+
773+
it("strips segment offset from stream text to prevent duplicate growing bubbles (#47188)", () => {
774+
const container = document.createElement("div");
775+
// Simulate: 1 segment with "Hello " (text before tool call),
776+
// then the current stream has full accumulated text "Hello world"
777+
// with offset = 6 (length of "Hello ").
778+
// The stream bubble should show only "world" (the delta).
779+
render(
780+
renderChat(
781+
createProps({
782+
messages: [],
783+
streamSegments: [{ text: "Hello ", ts: 1000 }],
784+
streamSegmentOffset: 6,
785+
stream: "Hello world",
786+
streamStartedAt: 2000,
787+
assistantName: "Assistant",
788+
}),
789+
),
790+
container,
791+
);
792+
793+
const streamBubbles = container.querySelectorAll(".chat-group.assistant .chat-bubble");
794+
// Should have exactly 2 bubbles: one for the segment "Hello " and one for the stream "world"
795+
expect(streamBubbles.length).toBe(2);
796+
797+
// The second bubble (current stream) should NOT contain the full accumulated text
798+
const lastBubble = streamBubbles[streamBubbles.length - 1];
799+
expect(lastBubble?.textContent).toContain("world");
800+
expect(lastBubble?.textContent).not.toContain("Hello world");
801+
});
771802
});

ui/src/ui/views/chat.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ export type ChatProps = {
6565
messages: unknown[];
6666
toolMessages: unknown[];
6767
streamSegments: Array<{ text: string; ts: number }>;
68+
streamSegmentOffset: number;
6869
stream: string | null;
6970
streamStartedAt: number | null;
7071
assistantAvatarUrl?: string | null;
@@ -1451,14 +1452,21 @@ function buildChatItems(props: ChatProps): Array<ChatItem | MessageGroup> {
14511452

14521453
if (props.stream !== null) {
14531454
const key = `stream:${props.sessionKey}:${props.streamStartedAt ?? "live"}`;
1454-
if (props.stream.trim().length > 0) {
1455+
// The server sends the full accumulated assistant text in each delta.
1456+
// Strip text already represented by committed segments to avoid
1457+
// duplicate growing bubbles. See: #47188
1458+
const offset = props.streamSegmentOffset ?? 0;
1459+
const displayText = offset > 0 ? props.stream.slice(offset) : props.stream;
1460+
if (displayText.trim().length > 0) {
14551461
items.push({
14561462
kind: "stream",
14571463
key,
1458-
text: props.stream,
1464+
text: displayText,
14591465
startedAt: props.streamStartedAt ?? Date.now(),
14601466
});
1461-
} else {
1467+
} else if (props.stream.trim().length === 0) {
1468+
// Only show reading indicator when the stream itself is empty/whitespace,
1469+
// not when display text is empty due to segment offset stripping.
14621470
items.push({ kind: "reading-indicator", key });
14631471
}
14641472
}

0 commit comments

Comments
 (0)