Skip to content

fix(chat): dedupe optimistic user message against Gateway-prefixed echo#887

Merged
hazeone merged 3 commits intomainfrom
cursor/fix-duplicate-user-message-render-5c84
Apr 23, 2026
Merged

fix(chat): dedupe optimistic user message against Gateway-prefixed echo#887
hazeone merged 3 commits intomainfrom
cursor/fix-duplicate-user-message-render-5c84

Conversation

@hazeone
Copy link
Copy Markdown
Contributor

@hazeone hazeone commented Apr 22, 2026

Summary

Fix a regression where asking one question made the same user bubble render twice in the chat stream.

Root cause (lifecycle walkthrough)

  1. ChatInput.handleSend calls useChatStore.sendMessage(text, attachments, targetAgentId).
  2. runtime-send-actions.ts::sendMessage appends an optimistic user RawMessage (plain trimmed text, timestamp = Date.now()/1000, random id) to state.messages, sets sending: true, and records lastUserMessageAt.
  3. It then kicks off the history poll (POLL_START_DELAY = 3s, POLL_INTERVAL = 4s). Each tick calls loadHistory(true) while sending === true.
  4. In history-actions.ts::loadHistory (and the legacy chat.ts::loadHistory), the server transcript is re-filtered and reduced to enrichedMessages. When sending is still true, it tries to preserve the optimistic entry by checking matchesOptimisticUserMessage(candidate, optimistic, userMsMs) against the server echo:
    • If a match is found, the optimistic entry is dropped and state.messages becomes the server view only.
    • If no match is found, finalMessages = [...enrichedMessages, optimistic] — the optimistic entry is appended on top of the server echo, and the chat stream renders two user bubbles.
  5. matchesOptimisticUserMessage normalizes text via normalizeComparableUserText, which only collapses whitespace. The OpenClaw Gateway, however, rewrites every persisted user message with metadata that does not exist on the optimistic copy:
    • a leading timestamp prefix like [Wed 2026-04-22 10:30 GMT+8]
    • [message_id: uuid] tags
    • [media attached: path (mime) | path] refs injected by chat:sendWithMedia
    • a Conversation info (untrusted metadata): ... block
  6. With the prefix attached, sameText is false, neither sameAttachments branch covers the plain-text case, so the matcher returns false — exactly the bug state in the screenshot.

The existing test mock in tests/unit/chat-history-actions.test.ts already stripped the weekday prefix before comparing (lines 60–63 of that file), so the history-actions tests continued to pass while production drifted away from the intended behavior.

The fix

Introduce stripGatewayUserMetadata and plug it into normalizeComparableUserText in both the modular src/stores/chat/helpers.ts and the legacy src/stores/chat.ts. The stripping rules mirror cleanUserText in src/pages/Chat/message-utils.ts, so the comparator now operates on the same cleaned text the user sees in the bubble — the echo now consistently matches the optimistic copy regardless of which metadata the Gateway prepends.

Related Issue(s)

Fixes the duplicate user-message render shown in the reporter's screenshot.

Type of Change

  • Bug fix
  • New feature
  • Documentation
  • Refactor
  • Other

Validation

  • Added tests/unit/chat-optimistic-match.test.ts covering identical text, weekday/timestamp prefix, appended [media attached: ...], inline [message_id: ...], and a negative case. Tests fail on main and pass with the fix.
  • pnpm exec vitest run tests/unit/chat-*.test.* → all 42 chat-scoped tests pass.
  • pnpm run typecheck → clean after running node scripts/generate-ext-bridge.mjs (the generated stub file is required before typecheck in a fresh clone, unrelated to this change).
  • pnpm run lint → clean (one pre-existing unrelated warning in src/pages/Chat/index.tsx).
  • The 22 remaining test failures in pnpm test (gateway-manager / plugin-install suites) also fail on main before this change — see the pre/post diff captured during review.

GUI manual testing was not performed because this is a data-layer fix in the zustand store, and the repro requires a real Gateway round-trip that injects the [Day YYYY-MM-DD HH:MM GMT±X] prefix. The unit coverage targets the exact comparator used on the hot path, and the UI rendering logic in src/pages/Chat/index.tsx is unchanged.

Checklist

  • I ran relevant checks/tests locally.
  • I updated docs if behavior or interfaces changed.
  • I verified there are no unrelated changes in this PR.
Open in Web Open in Cursor 

cursoragent and others added 3 commits April 22, 2026 04:39
The Gateway echoes each persisted user message back with metadata that the
optimistic client message never carries:

- a leading timestamp prefix like '[Wed 2026-04-22 10:30 GMT+8] '
- '[message_id: uuid]' tags
- '[media attached: path (mime) | path]' references when the renderer
  sends attachments via chat:sendWithMedia
- a 'Conversation info (untrusted metadata): ...' block

matchesOptimisticUserMessage compared the raw text, so the Gateway-
prefixed echo from chat.history (loaded during the poll or after a
final event) no longer matched the optimistic message. The merger then
appended the optimistic message on top of the echo, and the chat stream
rendered the same user bubble twice.

Align the comparator with pages/Chat/message-utils.ts::cleanUserText so
the comparison runs on the same visible string that the chat bubble
displays. This keeps the dedupe stable no matter which metadata the
Gateway decides to tack on.

Co-authored-by: Haze <hazeone@users.noreply.github.com>
Co-authored-by: Haze <hazeone@users.noreply.github.com>
Co-authored-by: Haze <hazeone@users.noreply.github.com>
@hazeone hazeone marked this pull request as ready for review April 23, 2026 12:40
@hazeone hazeone merged commit 31d4531 into main Apr 23, 2026
6 checks passed
@hazeone hazeone deleted the cursor/fix-duplicate-user-message-render-5c84 branch April 23, 2026 12:41
Copy link
Copy Markdown
Contributor

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

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: 9136624330

ℹ️ About Codex in GitHub

Codex has been enabled to automatically 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 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +147 to +150
.replace(/^\s*\[(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun)\s+\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}\s+[^\]]+\]\s*/i, '')
.replace(/\s*\[media attached:[^\]]*\]/g, '')
.replace(/\s*\[message_id:\s*[^\]]+\]/g, '')
.replace(/^Conversation info\s*\([^)]*\):\s*```[a-z]*\n[\s\S]*?```\s*/i, '')
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.

P2 Badge Keep metadata strip order aligned with user-text rendering

stripGatewayUserMetadata removes the timestamp prefix before stripping Conversation info blocks, but cleanUserText in src/pages/Chat/message-utils.ts strips Conversation info before the timestamp (cleanUserText lines 20-24). For echoed messages shaped like [Wed ...] Conversation info ..., the matcher path here normalizes to the plain prompt while the renderer path still leaves the metadata block, so dedupe can drop the optimistic bubble and replace it with a visibly different server bubble. Please make both sanitizers share the same ordering/implementation to keep dedupe behavior consistent with what users actually see.

Useful? React with 👍 / 👎.

DigitalNomad-Chat added a commit to DigitalNomad-Chat/ClawX that referenced this pull request Apr 26, 2026
… dedupe (ValueCell-ai#821 ValueCell-ai#845 ValueCell-ai#870 ValueCell-ai#873 ValueCell-ai#875 ValueCell-ai#878 ValueCell-ai#880 ValueCell-ai#885 ValueCell-ai#886 ValueCell-ai#887 ValueCell-ai#891 ValueCell-ai#903)

Overhaul execution graph card (collapse/expand, narration steps, web_fetch links),
separate thinking messages, render LaTeX math, dedupe optimistic messages,
hide recoverable gateway timeouts, add startup history recovery.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants