Skip to content

fix(ui): deduplicate cumulative streaming text in webchat segments#47344

Closed
rocky-d wants to merge 1 commit into
openclaw:mainfrom
rocky-d:fix/webchat-streaming-duplicates-47188
Closed

fix(ui): deduplicate cumulative streaming text in webchat segments#47344
rocky-d wants to merge 1 commit into
openclaw:mainfrom
rocky-d:fix/webchat-streaming-duplicates-47188

Conversation

@rocky-d

@rocky-d rocky-d commented Mar 15, 2026

Copy link
Copy Markdown

Fixes #47188

Problem

After upgrading to 2026.3.13, streaming responses in webchat create multiple duplicate message bubbles with growing text, instead of updating a single message in-place.

Root Cause

When streaming text is interrupted by tool calls (introduced in #39104), each segment was storing the full cumulative text up to that point. For example:

  • segment[0]: "Hello"
  • segment[1]: "Hello world"
  • segment[2]: "Hello world, how"

This caused multiple bubbles to render with overlapping, growing text.

Fix

Each segment now stores only the delta (new text since last segment):

  • segment[0]: "Hello"
  • segment[1]: " world"
  • segment[2]: ", how"

This prevents visual duplication while preserving the correct text→tool→text rendering order.

Testing

  • Verified segments store incremental text only
  • Confirmed no duplicate bubbles in streaming responses
  • Tool call interruptions still render correctly

…penclaw#47188)

Fixes openclaw#47188

When streaming text is interrupted by tool calls, each segment was storing
the full cumulative text up to that point, causing duplicate bubbles to render.

Now each segment stores only the delta (new text since last segment),
preventing visual duplication while preserving correct text→tool→text ordering.
@openclaw-barnacle openclaw-barnacle Bot added app: web-ui App: web-ui size: XS r: too-many-prs Auto-close: author has more than twenty active PRs. labels Mar 15, 2026
@openclaw-barnacle

Copy link
Copy Markdown

Closing this PR because the author has more than 10 active PRs in this repo. Please reduce the active PR queue and reopen or resubmit once it is back under the limit. You can close your own PRs to get back under the limit.

@rocky-d

rocky-d commented Mar 15, 2026

Copy link
Copy Markdown
Author

CI failure note: The failing checks are pre-existing upstream issues unrelated to this PR:

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 6c32f91e4d

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +443 to +446
const lastSegment = host.chatStreamSegments[host.chatStreamSegments.length - 1];
const lastText = lastSegment?.text ?? "";
// Only commit if we have new content beyond the last segment
if (host.chatStream.length > lastText.length && host.chatStream.startsWith(lastText)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Compute stream deltas from full prior text, not last segment

This dedup logic compares the current cumulative chatStream against only lastSegment.text, but after this change each saved segment is itself a delta. That means the third tool interruption in a run still falls into the else branch and stores the full cumulative text again (e.g. "Hello", then " world", then "Hello world, how"), so duplicate bubbles reappear whenever there are 2+ tool interruptions in one response. The comparison needs to use the concatenated prior segment text (or another cumulative cursor), not just the most recent segment.

Useful? React with 👍 / 👎.

@greptile-apps

greptile-apps Bot commented Mar 15, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR attempts to fix duplicate message bubbles in webchat streaming by storing only the delta text in each chatStreamSegments entry instead of the full cumulative text. The intent is correct, but the implementation has a logical flaw that reintroduces the bug for any response containing two or more tool-call interruptions.

Key issues found:

  • Logic bug (segment 3+): The deduplication compares host.chatStream (cumulative) against lastText, which after the first commit is a delta string, not a cumulative prefix. The host.chatStream.startsWith(lastText) check therefore fails for all segments after the second, causing the else if branch to store the full cumulative text — exactly the behaviour the PR set out to prevent. A robust fix is to sum the lengths of all previously committed segments (committedLength) and slice from that offset.
  • Missing tests: There are no unit tests for the new segment-deduplication logic. A simple three-segment (two tool-call interruption) test would have caught the edge case immediately.

Confidence Score: 2/5

  • Not safe to merge as-is — the fix partially resolves the duplicate-bubble bug but reintroduces it for responses with two or more tool-call interruptions.
  • The deduplication logic only works correctly for the first two text segments. From the third segment onward, lastText holds a delta string rather than a cumulative prefix, causing startsWith to return false and the full cumulative text to be committed again. Additionally, there are no unit tests covering the multi-segment path, so the regression would not be caught automatically.
  • ui/src/ui/app-tool-stream.ts — the startsWith-based deduplication in the segment commit block (lines 443–455) needs to be replaced with a cumulative-length approach.
Prompt To Fix All With AI
This is a comment left during a code review.
Path: ui/src/ui/app-tool-stream.ts
Line: 443-455

Comment:
**`startsWith` check breaks on third+ segments**

The deduplication logic only works correctly for the first two text segments (one tool-call interruption). For any subsequent text segment, `lastText` holds a **delta** string (e.g. `" world"`) rather than the cumulative prefix of `host.chatStream`, so the `startsWith` check always fails and the `else if` branch falls through, committing the full cumulative text instead of just the new portion.

Concrete trace with two tool-call interruptions:
1. Tool call 1: `chatStream = "Hello"`, `lastText = ""``startsWith("") = true` → segment[0] = `"Hello"`2. Tool call 2: `chatStream = "Hello world"`, `lastText = "Hello"``startsWith("Hello") = true` → segment[1] = `" world"`3. Tool call 3: `chatStream = "Hello world, how"`, `lastText = " world"` (a delta!) → `"Hello world, how".startsWith(" world") = false`**else if fires → segment[2] = `"Hello world, how"`** (full cumulative text, duplicating earlier content) ✗

The fix only holds when there is exactly one tool-call interruption. Any response with two or more text→tool→text→tool→text sequences will re-introduce the duplication bug the PR set out to fix.

A robust solution is to track the total committed character count by summing all prior segment lengths:

```typescript
const committedLength = host.chatStreamSegments.reduce(
  (sum, seg) => sum + seg.text.length,
  0,
);
const newText = host.chatStream.slice(committedLength);
if (newText.trim().length > 0) {
  host.chatStreamSegments = [...host.chatStreamSegments, { text: newText, ts: now }];
}
```

This is O(n) on the number of segments (small) but eliminates the fragile string-prefix assumption entirely.

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: ui/src/ui/app-tool-stream.ts
Line: 438-458

Comment:
**No test coverage for multi-segment deduplication**

The test file `app-tool-stream.node.test.ts` only covers fallback/lifecycle events. There are currently no tests for the segment-deduplication behaviour introduced by this fix — the very logic that was supposed to cure the duplicate-bubble regression. 

Adding a test that exercises the text→tool→text→tool→text path (3+ segments) would have caught the `startsWith` edge case described in the adjacent comment, and will guard against future regressions. Consider adding a test like:

```typescript
it("stores only the delta for each text segment across multiple tool interruptions", () => {
  const host = createHost({ chatRunId: "run-1", sessionKey: "main" });
  const toolEvent = (toolCallId: string, chatStream: string) => ({
    runId: "run-1", seq: 1, stream: "tool", ts: Date.now(), sessionKey: "main",
    data: { toolCallId, name: "tool", phase: "start", args: {} },
    // prime the stream before each tool call
    _chatStream: chatStream,
  });

  // Segment 0
  host.chatStream = "Hello";
  handleAgentEvent(host, { runId: "run-1", seq: 1, stream: "tool", ts: Date.now(), sessionKey: "main", data: { toolCallId: "t1", name: "tool", phase: "start" } });
  expect(host.chatStreamSegments[0]?.text).toBe("Hello");

  // Segment 1
  host.chatStream = "Hello world";
  handleAgentEvent(host, { runId: "run-1", seq: 2, stream: "tool", ts: Date.now(), sessionKey: "main", data: { toolCallId: "t2", name: "tool", phase: "start" } });
  expect(host.chatStreamSegments[1]?.text).toBe(" world");

  // Segment 2 – this is the case the current fix gets wrong
  host.chatStream = "Hello world, how";
  handleAgentEvent(host, { runId: "run-1", seq: 3, stream: "tool", ts: Date.now(), sessionKey: "main", data: { toolCallId: "t3", name: "tool", phase: "start" } });
  expect(host.chatStreamSegments[2]?.text).toBe(", how");
});
```

How can I resolve this? If you propose a fix, please make it concise.

Last reviewed commit: 6c32f91

Comment on lines +443 to +455
const lastSegment = host.chatStreamSegments[host.chatStreamSegments.length - 1];
const lastText = lastSegment?.text ?? "";
// Only commit if we have new content beyond the last segment
if (host.chatStream.length > lastText.length && host.chatStream.startsWith(lastText)) {
// Current stream is a continuation: only store the new portion
const newText = host.chatStream.slice(lastText.length);
if (newText.trim().length > 0) {
host.chatStreamSegments = [...host.chatStreamSegments, { text: newText, ts: now }];
}
} else if (host.chatStream !== lastText) {
// Stream is not a continuation (e.g., first segment or reset): store as-is
host.chatStreamSegments = [...host.chatStreamSegments, { text: host.chatStream, ts: now }];
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

startsWith check breaks on third+ segments

The deduplication logic only works correctly for the first two text segments (one tool-call interruption). For any subsequent text segment, lastText holds a delta string (e.g. " world") rather than the cumulative prefix of host.chatStream, so the startsWith check always fails and the else if branch falls through, committing the full cumulative text instead of just the new portion.

Concrete trace with two tool-call interruptions:

  1. Tool call 1: chatStream = "Hello", lastText = ""startsWith("") = true → segment[0] = "Hello"
  2. Tool call 2: chatStream = "Hello world", lastText = "Hello"startsWith("Hello") = true → segment[1] = " world"
  3. Tool call 3: chatStream = "Hello world, how", lastText = " world" (a delta!) → "Hello world, how".startsWith(" world") = falseelse if fires → segment[2] = "Hello world, how" (full cumulative text, duplicating earlier content) ✗

The fix only holds when there is exactly one tool-call interruption. Any response with two or more text→tool→text→tool→text sequences will re-introduce the duplication bug the PR set out to fix.

A robust solution is to track the total committed character count by summing all prior segment lengths:

const committedLength = host.chatStreamSegments.reduce(
  (sum, seg) => sum + seg.text.length,
  0,
);
const newText = host.chatStream.slice(committedLength);
if (newText.trim().length > 0) {
  host.chatStreamSegments = [...host.chatStreamSegments, { text: newText, ts: now }];
}

This is O(n) on the number of segments (small) but eliminates the fragile string-prefix assumption entirely.

Prompt To Fix With AI
This is a comment left during a code review.
Path: ui/src/ui/app-tool-stream.ts
Line: 443-455

Comment:
**`startsWith` check breaks on third+ segments**

The deduplication logic only works correctly for the first two text segments (one tool-call interruption). For any subsequent text segment, `lastText` holds a **delta** string (e.g. `" world"`) rather than the cumulative prefix of `host.chatStream`, so the `startsWith` check always fails and the `else if` branch falls through, committing the full cumulative text instead of just the new portion.

Concrete trace with two tool-call interruptions:
1. Tool call 1: `chatStream = "Hello"`, `lastText = ""``startsWith("") = true` → segment[0] = `"Hello"`2. Tool call 2: `chatStream = "Hello world"`, `lastText = "Hello"``startsWith("Hello") = true` → segment[1] = `" world"`3. Tool call 3: `chatStream = "Hello world, how"`, `lastText = " world"` (a delta!) → `"Hello world, how".startsWith(" world") = false`**else if fires → segment[2] = `"Hello world, how"`** (full cumulative text, duplicating earlier content) ✗

The fix only holds when there is exactly one tool-call interruption. Any response with two or more text→tool→text→tool→text sequences will re-introduce the duplication bug the PR set out to fix.

A robust solution is to track the total committed character count by summing all prior segment lengths:

```typescript
const committedLength = host.chatStreamSegments.reduce(
  (sum, seg) => sum + seg.text.length,
  0,
);
const newText = host.chatStream.slice(committedLength);
if (newText.trim().length > 0) {
  host.chatStreamSegments = [...host.chatStreamSegments, { text: newText, ts: now }];
}
```

This is O(n) on the number of segments (small) but eliminates the fragile string-prefix assumption entirely.

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines 438 to 458
// Commit any in-progress streaming text as a segment so it renders
// above the tool card instead of below it.
if (host.chatStream && host.chatStream.trim().length > 0) {
host.chatStreamSegments = [...host.chatStreamSegments, { text: host.chatStream, ts: now }];
// Deduplicate: each segment should contain only new text since the last segment.
// This prevents duplicate rendering when streaming text → tool → more text.
const lastSegment = host.chatStreamSegments[host.chatStreamSegments.length - 1];
const lastText = lastSegment?.text ?? "";
// Only commit if we have new content beyond the last segment
if (host.chatStream.length > lastText.length && host.chatStream.startsWith(lastText)) {
// Current stream is a continuation: only store the new portion
const newText = host.chatStream.slice(lastText.length);
if (newText.trim().length > 0) {
host.chatStreamSegments = [...host.chatStreamSegments, { text: newText, ts: now }];
}
} else if (host.chatStream !== lastText) {
// Stream is not a continuation (e.g., first segment or reset): store as-is
host.chatStreamSegments = [...host.chatStreamSegments, { text: host.chatStream, ts: now }];
}
host.chatStream = null;
host.chatStreamStartedAt = null;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No test coverage for multi-segment deduplication

The test file app-tool-stream.node.test.ts only covers fallback/lifecycle events. There are currently no tests for the segment-deduplication behaviour introduced by this fix — the very logic that was supposed to cure the duplicate-bubble regression.

Adding a test that exercises the text→tool→text→tool→text path (3+ segments) would have caught the startsWith edge case described in the adjacent comment, and will guard against future regressions. Consider adding a test like:

it("stores only the delta for each text segment across multiple tool interruptions", () => {
  const host = createHost({ chatRunId: "run-1", sessionKey: "main" });
  const toolEvent = (toolCallId: string, chatStream: string) => ({
    runId: "run-1", seq: 1, stream: "tool", ts: Date.now(), sessionKey: "main",
    data: { toolCallId, name: "tool", phase: "start", args: {} },
    // prime the stream before each tool call
    _chatStream: chatStream,
  });

  // Segment 0
  host.chatStream = "Hello";
  handleAgentEvent(host, { runId: "run-1", seq: 1, stream: "tool", ts: Date.now(), sessionKey: "main", data: { toolCallId: "t1", name: "tool", phase: "start" } });
  expect(host.chatStreamSegments[0]?.text).toBe("Hello");

  // Segment 1
  host.chatStream = "Hello world";
  handleAgentEvent(host, { runId: "run-1", seq: 2, stream: "tool", ts: Date.now(), sessionKey: "main", data: { toolCallId: "t2", name: "tool", phase: "start" } });
  expect(host.chatStreamSegments[1]?.text).toBe(" world");

  // Segment 2 – this is the case the current fix gets wrong
  host.chatStream = "Hello world, how";
  handleAgentEvent(host, { runId: "run-1", seq: 3, stream: "tool", ts: Date.now(), sessionKey: "main", data: { toolCallId: "t3", name: "tool", phase: "start" } });
  expect(host.chatStreamSegments[2]?.text).toBe(", how");
});
Prompt To Fix With AI
This is a comment left during a code review.
Path: ui/src/ui/app-tool-stream.ts
Line: 438-458

Comment:
**No test coverage for multi-segment deduplication**

The test file `app-tool-stream.node.test.ts` only covers fallback/lifecycle events. There are currently no tests for the segment-deduplication behaviour introduced by this fix — the very logic that was supposed to cure the duplicate-bubble regression. 

Adding a test that exercises the text→tool→text→tool→text path (3+ segments) would have caught the `startsWith` edge case described in the adjacent comment, and will guard against future regressions. Consider adding a test like:

```typescript
it("stores only the delta for each text segment across multiple tool interruptions", () => {
  const host = createHost({ chatRunId: "run-1", sessionKey: "main" });
  const toolEvent = (toolCallId: string, chatStream: string) => ({
    runId: "run-1", seq: 1, stream: "tool", ts: Date.now(), sessionKey: "main",
    data: { toolCallId, name: "tool", phase: "start", args: {} },
    // prime the stream before each tool call
    _chatStream: chatStream,
  });

  // Segment 0
  host.chatStream = "Hello";
  handleAgentEvent(host, { runId: "run-1", seq: 1, stream: "tool", ts: Date.now(), sessionKey: "main", data: { toolCallId: "t1", name: "tool", phase: "start" } });
  expect(host.chatStreamSegments[0]?.text).toBe("Hello");

  // Segment 1
  host.chatStream = "Hello world";
  handleAgentEvent(host, { runId: "run-1", seq: 2, stream: "tool", ts: Date.now(), sessionKey: "main", data: { toolCallId: "t2", name: "tool", phase: "start" } });
  expect(host.chatStreamSegments[1]?.text).toBe(" world");

  // Segment 2 – this is the case the current fix gets wrong
  host.chatStream = "Hello world, how";
  handleAgentEvent(host, { runId: "run-1", seq: 3, stream: "tool", ts: Date.now(), sessionKey: "main", data: { toolCallId: "t3", name: "tool", phase: "start" } });
  expect(host.chatStreamSegments[2]?.text).toBe(", how");
});
```

How can I resolve this? If you propose a fix, please make it concise.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

app: web-ui App: web-ui r: too-many-prs Auto-close: author has more than twenty active PRs. size: XS

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: Webchat streaming renders duplicate bubbles instead of updating in-place

1 participant