fix(ui): deduplicate cumulative streaming text in webchat segments#47344
fix(ui): deduplicate cumulative streaming text in webchat segments#47344rocky-d wants to merge 1 commit into
Conversation
…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.
|
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. |
|
CI failure note: The failing checks are pre-existing upstream issues unrelated to this PR:
|
There was a problem hiding this comment.
💡 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".
| 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)) { |
There was a problem hiding this comment.
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 SummaryThis PR attempts to fix duplicate message bubbles in webchat streaming by storing only the delta text in each Key issues found:
Confidence Score: 2/5
Prompt To Fix All With AIThis 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 |
| 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 }]; | ||
| } |
There was a problem hiding this 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:
- Tool call 1:
chatStream = "Hello",lastText = ""→startsWith("") = true→ segment[0] ="Hello"✓ - Tool call 2:
chatStream = "Hello world",lastText = "Hello"→startsWith("Hello") = true→ segment[1] =" world"✓ - 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:
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.| // 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; | ||
| } |
There was a problem hiding this 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:
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.
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:
This caused multiple bubbles to render with overlapping, growing text.
Fix
Each segment now stores only the delta (new text since last segment):
This prevents visual duplication while preserving the correct text→tool→text rendering order.
Testing