Conversation
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>
There was a problem hiding this comment.
💡 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".
| .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, '') |
There was a problem hiding this comment.
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 👍 / 👎.
… 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.
Summary
Fix a regression where asking one question made the same user bubble render twice in the chat stream.
Root cause (lifecycle walkthrough)
ChatInput.handleSendcallsuseChatStore.sendMessage(text, attachments, targetAgentId).runtime-send-actions.ts::sendMessageappends an optimistic userRawMessage(plain trimmed text,timestamp = Date.now()/1000, randomid) tostate.messages, setssending: true, and recordslastUserMessageAt.POLL_START_DELAY = 3s,POLL_INTERVAL = 4s). Each tick callsloadHistory(true)whilesending === true.history-actions.ts::loadHistory(and the legacychat.ts::loadHistory), the server transcript is re-filtered and reduced toenrichedMessages. Whensendingis stilltrue, it tries to preserve the optimistic entry by checkingmatchesOptimisticUserMessage(candidate, optimistic, userMsMs)against the server echo:state.messagesbecomes the server view only.finalMessages = [...enrichedMessages, optimistic]— the optimistic entry is appended on top of the server echo, and the chat stream renders two user bubbles.matchesOptimisticUserMessagenormalizes text vianormalizeComparableUserText, which only collapses whitespace. The OpenClaw Gateway, however, rewrites every persisted user message with metadata that does not exist on the optimistic copy:[Wed 2026-04-22 10:30 GMT+8][message_id: uuid]tags[media attached: path (mime) | path]refs injected bychat:sendWithMediaConversation info (untrusted metadata): ...blocksameTextisfalse, neithersameAttachmentsbranch covers the plain-text case, so the matcher returnsfalse— exactly the bug state in the screenshot.The existing test mock in
tests/unit/chat-history-actions.test.tsalready 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
stripGatewayUserMetadataand plug it intonormalizeComparableUserTextin both the modularsrc/stores/chat/helpers.tsand the legacysrc/stores/chat.ts. The stripping rules mirrorcleanUserTextinsrc/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
Validation
tests/unit/chat-optimistic-match.test.tscovering identical text, weekday/timestamp prefix, appended[media attached: ...], inline[message_id: ...], and a negative case. Tests fail onmainand pass with the fix.pnpm exec vitest run tests/unit/chat-*.test.*→ all 42 chat-scoped tests pass.pnpm run typecheck→ clean after runningnode 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 insrc/pages/Chat/index.tsx).pnpm test(gateway-manager / plugin-install suites) also fail onmainbefore 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 insrc/pages/Chat/index.tsxis unchanged.Checklist