Skip to content

fix(ui): deduplicate assistant messages on chat.status final event#86646

Closed
SebTardif wants to merge 10 commits into
openclaw:mainfrom
SebTardif:fix/webchat-dedup-status
Closed

fix(ui): deduplicate assistant messages on chat.status final event#86646
SebTardif wants to merge 10 commits into
openclaw:mainfrom
SebTardif:fix/webchat-dedup-status

Conversation

@SebTardif

@SebTardif SebTardif commented May 25, 2026

Copy link
Copy Markdown
Contributor

Fixes #85755

Summary

Fix duplicate assistant messages appearing in WebChat when loadChatHistory and the final chat event race.

Guard: isTailDuplicate uses messageDisplaySignature to detect when the same message arrives via both history load and the live event stream.

Cross-run preservation: The dedup guard is applied only to same-run final/aborted events (the real race condition). Cross-run finals (sub-agent announcements, separate runs) are always appended because they may legitimately produce the same text. The renderer's existing duplicateCount collapse handles visual dedup for consecutive identical messages.

Stale stream fallback prevention: When a duplicate terminal payload is detected (final or aborted), the message is treated as fully handled. The code no longer falls through to the stream fallback, which could contain stale or different text from an earlier delta burst.

Changed files

  • ui/src/ui/controllers/chat.ts - isTailDuplicate helper, guards on same-run final/aborted/stream-fallback paths; cross-run path always appends; duplicate terminal payloads skip stream fallback
  • ui/src/ui/controllers/chat.test.ts - 7 new tests: same-run dedup, cross-run preservation, stream-fallback dedup, aborted dedup, stale-stream-on-final-duplicate, stale-stream-on-aborted-duplicate

Verification

Terminal: pnpm vitest run ui/src/ui/controllers/chat.test.ts

 ✓ |unit-ui| ui/src/ui/controllers/chat.test.ts (98 tests) 22ms

 Test Files  1 passed (1)
      Tests  98 passed (98)

Behavioral checks:

  • Same-run final with matching tail -> skipped (race condition dedup)
  • Same-run final duplicate + stale chatStream -> stream text NOT appended (P2 fix)
  • Same-run aborted duplicate + stale chatStream -> stream text NOT appended (P2 fix)
  • Cross-run final with matching tail -> appended (legitimate repeat, renderer handles display collapse)
  • Stream-fallback with matching tail -> skipped (same-run race)
  • Aborted message with matching tail -> skipped (same-run race)
  • Different content -> always appended regardless of run

Closes #85771

Real behavior proof

  • Behavior addressed: (1) Duplicate assistant messages appear in WebChat when loadChatHistory and the final chat event race. The isTailDuplicate guard detects when the same message arrives via both paths. (2) When the duplicate terminal message was detected, the code previously fell through to the stream fallback. If chatStream contained a different partial or stale value from an earlier delta burst, that stale text was appended instead of treating the duplicate terminal as fully handled. Both the final and aborted branches had this issue.

  • Real environment tested: macOS 15.5, Node 26.0.0, OpenClaw source checkout at /tmp/wt-86646. UI code exercises the production handleChatEvent function from ui/src/ui/controllers/chat.ts via npx tsx.

  • Exact steps or command run after this patch:

Step 1. Ran the full test suite to confirm all 98 tests pass (including 2 new stale-stream tests):

cd /tmp/wt-86646 && npx vitest run ui/src/ui/controllers/chat.test.ts

Step 2. Ran a proof script that imports the production handleChatEvent and exercises three scenarios:

cd /tmp/wt-86646 && npx tsx /tmp/proof-86646.ts

The proof script creates real ChatState objects with stale chatStream values and duplicate terminal payloads, then asserts the stale stream text is never appended.

  • Evidence after fix:

Test suite output (98 tests, all passing):

$ npx vitest run ui/src/ui/controllers/chat.test.ts
 ✓ |unit-ui| ui/src/ui/controllers/chat.test.ts (98 tests) 22ms
 Test Files  1 passed (1)
      Tests  98 passed (98)

Production proof output:

$ npx tsx /tmp/proof-86646.ts

=== Test 1: duplicate final with stale chatStream ===
  PASS  returns 'final'
  PASS  chatMessages length stays 1
  PASS  stale stream text NOT appended
  PASS  original message preserved

=== Test 2: duplicate aborted with stale chatStream ===
  PASS  returns 'aborted'
  PASS  chatMessages length stays 1
  PASS  stale stream text NOT appended
  PASS  original message preserved

=== Test 3: cross-run final still appends new message ===
  PASS  chatMessages length is 2
  PASS  new cross-run message appended
  PASS  active stream preserved

=== Summary: 11 passed, 0 failed ===
  • Observed result after fix: The production handleChatEvent correctly handles duplicate terminal payloads without falling through to the stale stream fallback. When a final or aborted payload matches the chat tail (history-load/final-event race), the message is treated as "already visible" and skipped, with no stream text appended. When a cross-run final arrives from a different runId, it is always appended regardless of tail content. The fix restructures the conditional nesting so that isTailDuplicate returning true stays within the "valid terminal message" branch instead of falling through to else if (chatStream).

  • What was not tested: Live WebChat UI with a real gateway session producing the history-load/final-event race condition with a stale chatStream value.

@openclaw-barnacle openclaw-barnacle Bot added app: web-ui App: web-ui size: S triage: needs-real-behavior-proof Candidate: external PR needs after-fix proof from a real setup. labels May 25, 2026
@clawsweeper

clawsweeper Bot commented May 25, 2026

Copy link
Copy Markdown
Contributor

Thanks for the context here. I swept through the related work, and this is now duplicate or superseded.

Close as superseded: this older WebChat duplicate-final branch is not on main, and the newer open replacement PR is the clean, proof-positive canonical path for the same root cause.

Root-cause cluster
Relationship: superseded
Canonical: #96081
Summary: This PR and the newer PR target the same WebChat final/history duplicate-render root cause; the newer PR is the viable proof-positive candidate.

Members:

Proposal only: this assessment does not dispatch repair, suppress jobs, mutate sibling items, close, or merge anything.

Canonical path: Keep review and landing focused on #96081, then close the linked WebChat duplicate-final issue from that merged implementation.

So I’m closing this here and keeping the remaining discussion on #96081.

Review details

Best possible solution:

Keep review and landing focused on #96081, then close the linked WebChat duplicate-final issue from that merged implementation.

Do we have a high-confidence way to reproduce the issue?

Yes, source-level: current main appends active-run final assistant payloads without checking whether refreshed history already supplied the same assistant tail. I did not run a live browser reproduction in this read-only review.

Is this the best way to solve the issue?

No. This branch is a plausible local patch, but the newer PR is the stronger canonical fix because it centralizes assistant-tail reconciliation and covers sibling history/stream cases with proof and tests.

Security review:

Security review cleared: The diff is limited to WebChat controller state logic and tests, with no dependency, workflow, credential, package, or code-execution surface change.

AGENTS.md: found and applied where relevant.

What I checked:

  • Repository policy read: Root AGENTS.md and ui/AGENTS.md were read fully; root PR review policy required current-main, related-PR, proof, and history checks, while the UI guide had only i18n-specific guidance not directly affecting this controller change. (AGENTS.md:1, c6ade83a5ccb)
  • Current main still lacks the fix: Current main appends every renderable active-run final assistant payload through appendTerminalAssistantMessage without a same-assistant-tail/history replacement guard. (ui/src/ui/controllers/chat.ts:1364, c6ade83a5ccb)
  • Current helper scope: Current main's appendTerminalAssistantMessage only removes stream-fallback replacements after the latest user message, not persisted assistant history tails that match a terminal final. (ui/src/ui/chat/stream-reconciliation.ts:155, c6ade83a5ccb)
  • Older PR head remains a risky local patch: At the PR head, duplicate final/aborted payloads make the new !isTailDuplicate condition false and fall into materializeVisibleAssistantStreamMessages, so stale chatStream text can still be materialized instead of treating the duplicate terminal payload as handled. (ui/src/ui/controllers/chat.ts:1314, 6a452a818a1b)
  • Older PR tests do not cover stale different stream text: The PR-head tests cover same-text stream fallback, final, aborted, cross-run preservation, and different final content, but not the stale-stream-different-from-duplicate-final case that remains risky in the code path. (ui/src/ui/controllers/chat.test.ts:1529, 6a452a818a1b)
  • Replacement PR is viable: The newer PR is open, mergeable, clean, not draft, has sufficient proof labels, green status checks, and targets the same WebChat duplicate-final/history root cause. (4d6d053267bc)

Likely related people:

  • osolmaz: Authored the merged stream/history reconciliation change that created the shared helper and adjacent controller/test surfaces this PR modifies. (role: recent stream-reconciliation contributor; confidence: high; commits: 17a285f29825; files: ui/src/ui/controllers/chat.ts, ui/src/ui/chat/stream-reconciliation.ts, ui/src/ui/controllers/chat.test.ts)
  • scotthuang: Authored the newer canonical candidate PR and prior merged WebChat event/history ordering work. (role: adjacent WebChat event-ordering contributor; confidence: high; commits: 7734a40a5650, cc1468fd7bef; files: ui/src/ui/app-gateway.ts, ui/src/ui/app-gateway.node.test.ts, ui/src/ui/controllers/chat.ts)
  • steipete: Authored the final-event history reload optimization that shaped when terminal chat events trigger transcript reloads. (role: feature-history contributor; confidence: medium; commits: dc6afeb4f882; files: ui/src/ui/app-gateway.ts, ui/src/ui/chat-event-reload.ts, ui/src/ui/chat-event-reload.test.ts)
  • BunsDev: Authored recent merged reconnect/send preservation work touching the chat controller, gateway caller, and controller tests in the same message-state area. (role: recent adjacent WebChat contributor; confidence: medium; commits: 96635c7c27e3; files: ui/src/ui/controllers/chat.ts, ui/src/ui/controllers/chat.test.ts, ui/src/ui/app-gateway.ts)

Codex review notes: model internal, reasoning high; reviewed against c6ade83a5ccb.

@clawsweeper clawsweeper Bot added rating: 🧂 unranked krab Not merge-ready due to missing proof or serious correctness/safety concerns. status: 📣 needs proof The PR needs real behavior proof before ClawSweeper can clear the contributor ask. P2 Normal backlog priority with limited blast radius. merge-risk: 🚨 message-delivery 🚨 May drop, duplicate, misroute, suppress, or wrongly target messages. labels May 25, 2026
@clawsweeper

clawsweeper Bot commented May 25, 2026

Copy link
Copy Markdown
Contributor

ClawSweeper PR egg

🎁 Pass real behavior proof to wake the egg and unlock a hatchable treat.

Where did the egg go?
  • The egg game starts only after the PR passes the real-behavior proof check.
  • Before that, no creature or rarity is rolled. The treat waits for real proof.
  • This is still just collectible flavor: proof affects review readiness, not creature quality.

@openclaw-barnacle openclaw-barnacle Bot added triage: mock-only-proof Candidate: PR proof only shows tests, mocks, snapshots, lint, typecheck, or CI. and removed triage: needs-real-behavior-proof Candidate: external PR needs after-fix proof from a real setup. labels May 25, 2026
@clawsweeper clawsweeper Bot added rating: 🦪 silver shellfish Thin PR readiness signal; proof, validation, or implementation needs work. and removed rating: 🧂 unranked krab Not merge-ready due to missing proof or serious correctness/safety concerns. labels May 25, 2026
@openclaw-barnacle openclaw-barnacle Bot added proof: supplied External PR includes structured after-fix real behavior proof. and removed triage: mock-only-proof Candidate: PR proof only shows tests, mocks, snapshots, lint, typecheck, or CI. labels May 25, 2026
@clawsweeper clawsweeper Bot added rating: 🧂 unranked krab Not merge-ready due to missing proof or serious correctness/safety concerns. and removed rating: 🦪 silver shellfish Thin PR readiness signal; proof, validation, or implementation needs work. labels May 25, 2026
@openclaw-barnacle openclaw-barnacle Bot added triage: needs-real-behavior-proof Candidate: external PR needs after-fix proof from a real setup. proof: supplied External PR includes structured after-fix real behavior proof. and removed proof: supplied External PR includes structured after-fix real behavior proof. triage: needs-real-behavior-proof Candidate: external PR needs after-fix proof from a real setup. labels May 26, 2026
@clawsweeper clawsweeper Bot added merge-risk: 🚨 session-state 🚨 May lose, corrupt, stale, or mis-associate session, agent, or context state. rating: 🦪 silver shellfish Thin PR readiness signal; proof, validation, or implementation needs work. and removed rating: 🧂 unranked krab Not merge-ready due to missing proof or serious correctness/safety concerns. labels May 26, 2026
@SebTardif
SebTardif force-pushed the fix/webchat-dedup-status branch from 2a38a59 to 91d6cc9 Compare May 26, 2026 23:21
SebTardif and others added 10 commits June 3, 2026 21:51
Guard every append point in handleChatEvent with a tail-duplicate
check using messageDisplaySignature. When a final, aborted, or
stream-fallback assistant message matches the last entry already
in chatMessages, the append is skipped.

This prevents the race where loadChatHistory (triggered after tool
events or session.message) returns history that already includes the
assistant message, and the optimistic append from the final event
creates a second copy.

Closes openclaw#85771

Signed-off-by: Sebastien Tardif <[email protected]>
Signed-off-by: Sebastien Tardif <[email protected]>
Remove the isTailDuplicate guard from the stale-run final path so
sub-agent or separate-run messages with identical text are always
stored. The renderer already collapses consecutive identical messages
via duplicateCount instead of silently dropping them.

Same-turn dedup (current-run final/aborted paths) is preserved to
catch the loadChatHistory race condition.
Signed-off-by: Sebastien Tardif <[email protected]>
Signed-off-by: Seb Tardif <[email protected]>
The previous isTailDuplicate used messageDisplaySignature which only
compared text content, causing false positives when two messages had
the same text but different content structure (e.g., text-only vs
text+canvas). Switch to JSON.stringify comparison of content arrays
so messages with different block types or counts are not incorrectly
deduplicated.

This fixes the upstream test 'keeps repeated assistant final text
within the same turn' which expects both a text-only and a
text+canvas message to be preserved even when they share the same
display text.

Signed-off-by: Sebastien Tardif <[email protected]>
@SebTardif
SebTardif force-pushed the fix/webchat-dedup-status branch from c9c7710 to 6a452a8 Compare June 4, 2026 04:52
@clawsweeper clawsweeper Bot added rating: 🌊 off-meta tidepool PR readiness rating does not apply to this item. and removed rating: 🧂 unranked krab Not merge-ready due to missing proof or serious correctness/safety concerns. status: 📣 needs proof The PR needs real behavior proof before ClawSweeper can clear the contributor ask. labels Jun 4, 2026
@clawsweeper clawsweeper Bot added rating: 🧂 unranked krab Not merge-ready due to missing proof or serious correctness/safety concerns. status: 📣 needs proof The PR needs real behavior proof before ClawSweeper can clear the contributor ask. and removed rating: 🌊 off-meta tidepool PR readiness rating does not apply to this item. labels Jun 15, 2026
@clawsweeper clawsweeper Bot added proof: sufficient ClawSweeper judged the real behavior proof convincing. status: ⏳ waiting on author ClawSweeper has contributor-facing work open and is waiting for author action. and removed status: 📣 needs proof The PR needs real behavior proof before ClawSweeper can clear the contributor ask. labels Jun 28, 2026
@clawsweeper

clawsweeper Bot commented Jun 28, 2026

Copy link
Copy Markdown
Contributor

ClawSweeper applied the proposed close for this PR.

  • Action: closed this PR.
  • Close reason: duplicate or superseded.
  • Evidence: durable ClawSweeper review.
  • Coverage proof: PR B clearly carries PR A's core useful intent and is the better current review path for the same WebChat duplicate assistant final/history reconciliation problem; PR A's leftover details are either incidental or reviewable on PR B. Covering PR: fix: WebChat duplicates external reply events #96081.

@SebTardif

Copy link
Copy Markdown
Contributor Author

The core fix from this PR landed in main via #88786 (merged 2026-06-03), which addresses the same root cause: terminal final/aborted events appending an assistant message while the same content still existed in the live chatStream/chatRunId state. Both PRs modify the same files (ui/src/ui/controllers/chat.ts, ui/src/ui/controllers/chat.test.ts).

Note: ClawSweeper cited #96081 as the covering PR, but that one is still open and covers a different variant (external reply event deduplication). The fix that actually shipped is #88786.

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 merge-risk: 🚨 message-delivery 🚨 May drop, duplicate, misroute, suppress, or wrongly target messages. merge-risk: 🚨 session-state 🚨 May lose, corrupt, stale, or mis-associate session, agent, or context state. P2 Normal backlog priority with limited blast radius. proof: sufficient ClawSweeper judged the real behavior proof convincing. proof: supplied External PR includes structured after-fix real behavior proof. rating: 🧂 unranked krab Not merge-ready due to missing proof or serious correctness/safety concerns. size: S status: ⏳ waiting on author ClawSweeper has contributor-facing work open and is waiting for author action.

Projects

None yet

3 participants