fix: prevent malformed text messages from crashing conversation render (#465)#467
Conversation
…er (Mininglamp-OSS#465) A text message with a missing content body (payload type is text but content.text is undefined) caused ProhibitwordsService.filter to receive undefined, which the sensitive-word-tool library crashed on when reading content.length. This aborted refreshMessages, blanking the whole conversation and blocking sends for the affected group. - ProhibitwordsService.filter now normalizes non-string input to an empty string and only invokes the library for real strings. - The text-filter branch in ConversationVM.refreshMessages skips entries whose content object is missing. Adds regression tests covering refreshMessages, appendMessage and updateMessageStatusBySendAck with malformed text messages, plus unit tests for the filter null-guard against the real library.
Mininglamp-OSS#465) The crash fix intentionally keeps malformed text messages (content missing) in the rendered list. When a replied-to message is later edited, updateReplyMessageContent walked those entries and dereferenced message.content.reply, throwing on the undefined content object — the same root cause as the filter crash, via a different trigger. Use optional chaining so entries without a content object are skipped, and add a regression test covering the edited-reply path with a malformed message present.
OctoBoooot
left a comment
There was a problem hiding this comment.
Review: fix: prevent malformed text messages from crashing conversation render (#465) (#467)
Verdict: Approve
Excellent defensive crash fix for a real production incident (one group's history fully blanked + unsendable). The root-cause analysis is precise and the fix is layered at exactly the right depth.
Root cause is correctly identified and the fix matches it. A type===1 (text) message with a missing content body decodes to content.text === undefined; refreshMessages ran the sensitive-word filter on every text message, ProhibitwordsService.filter(undefined) forwarded straight to the third-party sensitive-word-tool, which read content.length and threw — aborting refreshMessages before it assigned the render list, blanking the conversation. Send-acks and live appends re-run the same refresh, so every retry re-hit the bad history message. That's a complete, mechanism-level diagnosis, and the diff addresses each link in the chain.
Defense in depth, verified across all sites:
ProhibitwordsService.filter(v: unknown): string(:60-65) — the load-bearing fix. Normalizes any non-string / empty to""and only calls the library for real strings. Widening the signature tounknownis the right call: it hardens every caller, not just the one that crashed.vm.ts:1878-1882(refreshMessages text branch) — guardsif (content)before touchingcontent.text, so a fully-missing content object is skipped rather than dereferenced.vm.ts:1322(updateReplyMessageContent) —message.content?.replyoptional chaining for the retained-malformed-message edit path (same root cause, different trigger). Verified the innercontent.reply.messageID/.contentreads at:1325-1326are safe because the?.reply === undefinedcontinue-guard above filters them out.- Cross-checked the other two
filter()call sites (Pages/Chat/vm.ts:228/281/611): all already guardlastMessage?.content &&before calling, AND are now additionally covered by the hardenedfilter— belt and suspenders.
Tests reproduce the actual crash. ProhibitwordsService.test.ts runs against the real sensitive-word-tool (not a mock), so the filter(undefined/null/non-string/"") → "" cases genuinely reproduce the original throw before the fix. messageOrder.test.ts covers all four trigger paths (refreshMessages, appendMessage, updateMessageStatusBySendAck, edited-reply) with a malformed message present, each asserting no-throw + correct normalization. Both the missing-content and present-content-missing-text shapes are tested.
Praise
- Choosing to fix
ProhibitwordsService.filterat the type boundary (string→unknown, normalize internally) rather than only guarding the one crashing call site — that's the difference between patching the symptom and hardening the contract. Every present and future caller offilteris now safe against the same class of dirty input, and the comment names exactly why (third-partycontent.lengthread). - The
ProhibitwordsServicetest runs the realsensitive-word-toolrather than mocking it. A mocked test would have passed even with the bug present (the mock wouldn't read.length); using the real library means the test actually reproduces #465's throw and would catch a regression that swaps the guard back. This is the right instinct for a "library threw on our dirty input" fix — test against the thing that threw. messageOrder.test.tsdistinguishes the two malformed shapes (contententirely missing vscontentpresent withtextundefined) — these hit different guards (if (content)vs the filter-normalization), and testing both ensures neither guard is silently load-bearing-alone.
Out of scope (informational)
- PR is a draft (per gate) — flagging so merge waits for ready-for-review. Also
vm.tsitself remains without a co-located unit test by naming convention, but the newmessageOrder.test.ts/ProhibitwordsService.test.tsexercise the changed paths directly, so the coverage that matters for this fix is present.
Jerry-Xin
left a comment
There was a problem hiding this comment.
The PR is relevant to octo-web, but it does not fully prevent malformed text messages with missing content from crashing conversation render.
🔴 Blocking
- 🔴 Critical:
refreshMessagesstill leavescontent === undefinedtext messages in the render list. The new guard atpackages/dmworkbase/src/Components/Conversation/vm.ts:1877only skips filtering, thenmessageUIlater evaluatesmessage.flameatpackages/dmworkbase/src/Components/Conversation/index.tsx:1977, which dereferencesthis.message.content.contentObjatpackages/dmworkbase/src/Service/Model.tsx:741. That will still throw for the exact malformed shape described in the PR. If it gets past that path, text rendering also callsmessage.partsatpackages/dmworkbase/src/bridge/message/useTextMessageUI.ts:66, andparseMentiondereferencesthis.content.contentTypeatpackages/dmworkbase/src/Service/Model.tsx:401. The regression test only callsrefreshMessages, so it misses the actual React render crash. Please normalize missing text content to a safe text content object, drop/replace malformed messages before render, or make the sharedMessageWrapaccessors render-safe, then add a regression that exercises the render/message cell path.
✅ Highlights
ProhibitwordsService.filteris correctly hardened againstundefined,null, non-string, and empty-string inputs.- The edited-reply optional chaining fix is appropriate for retained malformed messages.
Verification
- I attempted the targeted Vitest run, but this checkout has no installed dependencies:
vitest: command not foundand pnpm reportednode_modulesmissing.
mochashanyao
left a comment
There was a problem hiding this comment.
[Octo-Q · automated review]
Verdict: Approve — no blocking findings; notes below (data-flow traced).
octo-web PR#467 Review Report
PR: fix: prevent malformed text messages from crashing conversation render (#465)
Head SHA: 417f3af7447103b70b7a1c126a26ea5e724e79a6
Reviewer: Octo-Q (automated review)
1. Verification Summary
| Item | Status | Evidence |
|---|---|---|
refreshMessages guards against content=undefined for text messages |
✅ | vm.ts:1879-1881 — const content = message.content; if (content) |
updateReplyMessageContent uses optional chaining on message.content |
✅ | vm.ts:1322 — message.content?.reply === undefined |
ProhibitwordsService.filter handles non-string/non-empty input |
✅ | ProhibitwordsService.ts:60-63 — typeof v !== "string" || v.length === 0 → "" |
| Tests cover the fix paths | ✅ | 4 new test cases in messageOrder.test.ts; 5 new tests in ProhibitwordsService.test.ts |
No regressions to existing call sites of ProhibitwordsService.filter |
✅ | All 3 call sites in Chat/vm.ts (L228/281/611) already guard with ?.content before calling |
2. Findings
F1 — message.content.conversationDigest in reply handler lacks null guard (P2 / pre-existing)
Location: index.tsx:1110 and index.tsx:3556
// index.tsx:1109-1110 (reply() handler)
const content = message.remoteExtra?.isEdit
? message.remoteExtra?.contentEdit?.conversationDigest
: message.content.conversationDigest; // ← crashes if content is undefined
// index.tsx:3555-3556 (ReplyView render)
const messageText = message.remoteExtra?.isEdit
? message.remoteExtra?.contentEdit?.conversationDigest
: message.content.conversationDigest; // ← same issueDiff-scope check (R2):
- Pre-existing — not introduced or touched by this PR.
- The PR did NOT change the reply handler path or the ReplyView render.
- On base branch, behavior is identical —
message.content.conversationDigestcrashes identically when content is undefined.
Severity (R1): Not P1. The PR does not amplify this path. The crash was already latent before #465. Note: index.tsx:1593 already uses message.content?.conversationDigest (optional chaining), suggesting inconsistency across the codebase.
Suggestion: Apply the same optional-chaining fix at lines 1110 and 3556 for consistency:
message.content?.conversationDigestF2 — Missing trailing newline in ProhibitwordsService.ts (nit)
Location: ProhibitwordsService.ts end-of-file — diff shows \ No newline at end of file.
POSIX convention requires trailing newline. Minor cosmetic issue.
3. Data-Flow Trace
refreshMessages (vm.ts:1874-1885)
- Input:
newMessagesarray (from SDK sync / WS push / local buffer) - Gate:
message.contentType === MessageContentType.text→ enters text-processing branch - Data consumed:
message.content.text - Upstream:
Message.contentis set by SDK during deserialization. For malformed text messages (#465), the SDK can producecontent = undefinedwhilecontentType = 1 (text). - Fix verification: The PR extracts
const content = message.contentand guards withif (content). When content is undefined, the loop body is skipped entirely — no crash. ✅ - Downstream effect:
content.text = ProhibitwordsService.shared.filter(content.text)— ProhibitwordsService now returns""for undefined input (defensive), but the caller guard meansfilter(undefined)is never reached. Belt-and-suspenders. ✅
updateReplyMessageContent (vm.ts:1316-1328)
- Input:
MessageExtraarray (fromupdateMessageByMessageExtras) - Data consumed:
message.content.reply.messageID,message.content.reply.content - Upstream:
message.contentcomes fromMessageWrap.contentgetter →this.message.content(SDK Message object). For malformed messages, this can be undefined. - Fix verification:
message.content?.reply === undefined→ if content is undefined,content?.replyevaluates to undefined, which IS=== undefined, so the loop continues. Lines 1325-1326 are only reached when bothcontentandcontent.replyexist. ✅
ProhibitwordsService.filter (ProhibitwordsService.ts:60-66)
- Input:
v: unknown— previously typed asstring - Data consumed:
v.length(viasensitiveWordTool.filter(v)) - Upstream: Called with
content.textfrom 4 call sites (3 in Chat/vm.ts, 1 in Conversation/vm.ts). All callers now guard with?.contentbefore calling. - Fix verification: Early return
""for non-string/empty input.sensitiveWordTool.filter()is only called with valid non-empty strings. ✅
4. Blind Spot Checklist (R5)
- C1 Double-path parity: N/A — no symmetric add/remove or create/delete paths affected.
- C2 Control-flow ordering / nested reuse: N/A — no security controls (regex/escape/sanitize) or deeply-nested reused logic affected.
- C3 Authorization boundary: N/A — no permission/jail/tool exposure changes.
- C4 Auth lifecycle / container-member state cascade: N/A — no auth changes.
- C5 Build/note ≠ runtime: N/A — no build artifacts, extensions, or packaging changes.
- C6 Governance/policy docs: N/A — no doc changes.
5. Cross-round Blocker Re-check (R6)
N/A — first review.
6. Additional Observations
- Test quality: The 4 new tests in
messageOrder.test.tscorrectly exercise the fix paths with realistic malformed message shapes (missing content, undefined text, mixed with well-formed messages). The 5 tests inProhibitwordsService.test.tstest the realsensitive-word-toolintegration (not mocked), confirming the library would crash on undefined input. - Mock update: The mock in
messageOrder.test.ts:131was updated to match the newfilter(v: unknown): stringsignature — good practice to keep mocks consistent with implementation.
[Octo-Q] verdict: APPROVE — No P0/P1 findings. The fix is focused, correct, and well-tested. One P2 (pre-existing reply handler crash risk) and one nit noted for follow-up.
Retracting this APPROVE — it missed the render-layer crash (Model.tsx:741 flame getter, unguarded content.contentObj) where the retained malformed message still throws. Re-reviewing as REQUEST_CHANGES with the verified blocker. My 'verified across all sites' praise was overreach; corrected.
OctoBoooot
left a comment
There was a problem hiding this comment.
Review: fix: prevent malformed text messages from crashing conversation render (#465) (#467) — corrected
Verdict: Request changes
Correction: My earlier APPROVE on this SHA was wrong, and I'm retracting it. I praised the fix as "complete and defense-in-depth, verified across all sites" — but I only traced the data-refresh writer paths (filter() callers, updateReplyMessageContent) and never traced the render-time getters that read the retained malformed message. Jerry-Xin and mochashanyao independently caught the render-layer gap; I re-verified it at head and they're right. Wrong praise burns trust the same way a wrong blocker does, so this correction matters.
The crash the PR title claims to fix is still live at the render layer. Verified at head:
- The fix retains the content-missing message:
vm.ts:1878skips the filter withif (content)but the message stays innewMessages→this.messages→ render. Conversation/index.tsx:1977messageUI()readsmessage.flamefor every rendered message.Model.tsx:741flamegetter doesif (this.message.content.contentObj)— no optional chaining. For acontent === undefinedmessage (the exact shape the PR's ownif (content)guard andmissing-contenttest construct), this throwsTypeError: Cannot read properties of undefined (reading 'contentObj')— reproducing the original "crashing conversation render."
So the guards landed one layer above where the crash actually fires. The filter/reply paths are now safe, but the retained malformed message still reaches the render-time getters unprotected.
Blockers
- Render-layer crash unfixed. The malformed (
content === undefined) message is retained inthis.messagesand crashes atModel.tsx:741(flamegetter, unguardedthis.message.content.contentObj) viaConversation/index.tsx:1977's per-messagemessage.flameread. Two fix shapes: (a) normalize/drop content-missing messages inrefreshMessagesbeforegenMessageLinkedDataso no downstream getter seescontent === undefined, or (b) add optional chaining to the render-time getters (flame, and audit siblings likeparts). (a) is the more robust single-point fix — it stops every render-time getter from having to individually defend. - Regression tests pin the wrong layer. The new
messageOrder.test.tscases assertrefreshMessages/appendMessage/updateMessageStatusBySendAck/reply-edit don't throw, but none mount the cell or readflame— so they pass green while the UI still crashes. A regression test must exercise the real render path (readmessage.flame/ render the cell with a malformed message present) to actually pin #465.
Praise
ProhibitwordsService.filter(v: unknown): stringhardening is genuinely good and stands — normalizing null/non-string/empty to""at the type boundary protects every caller (verified thePages/Chat/vm.tssites benefit too). This part of the fix is correct; it's just not where the render crash lives.
Out of scope (informational)
- Draft PR + trailing-newline nit on
ProhibitwordsService.ts(mochashanyao's 🟡) — minor, separate from the blocker.
yujiawei
left a comment
There was a problem hiding this comment.
Code Review — PR #467 (octo-web)
Reviewed at head SHA 417f3af7447103b70b7a1c126a26ea5e724e79a6 against main (merge-base 6055b72). Independent multi-pass review of the changed files plus the surrounding render/decode paths.
Verdict: Approved
This PR correctly and completely fixes the reported crash. The findings below are non-blocking robustness and test-quality suggestions.
1. Spec compliance — PASS
The diff implements exactly what #465 requires, with no over-build and no deviation:
- The real throw site was
ProhibitwordsService.filter()forwarding a non-string tosensitive-word-tool(which readscontent.length). The fix hardensfilter(v: unknown): stringto normalize any non-string/empty value to""and only call the library for real strings (Service/ProhibitwordsService.ts:60-66). This is the right place to fix it and also protects the other call sites. - The two VM read paths that could dereference missing content are guarded:
refreshMessagesskips the filter whencontentis falsy (Components/Conversation/vm.ts:1877-1883), andupdateReplyMessageContentuses optional chaining (vm.ts:1318).
I confirmed the actual production scenario is fully resolved. The decoded payload for the affected history message has content present (a MessageText object) but content.text === undefined. With the fix, that message renders safely all the way through: Model.tsx parseMention uses textContent.text || '' (line 408) and reads contentType/mention off a real object, and the flame getter (line 741) is already if-guarded. The only failure for this shape was the filter call, which is now fixed.
2. Code quality — Approved (with suggestions)
Suggestion A — render-path guards are asymmetric with the new VM guards (non-blocking)
The fix (and the new missingContent test) explicitly defend the case where content is entirely undefined. But the render accessors do not guard that same shape:
Service/Model.tsx:401—parseMention()readsthis.content.contentTypewith no null check; reached during render via thepartsgetter (Model.tsx:364, consumed inMessages/Text/index.tsx:96,121).Service/Model.tsx:741—flamegetter readsthis.message.content.contentObjunguarded; reached viaComponents/Conversation/index.tsx.Messages/Text/index.tsx:221—message?.content.replyoptional-chainsmessagebut notcontent.
In practice this is not currently reachable, because the decode path always assigns a content object: Service/Convert.ts:296-300 sets message.content = WKSDK.shared().getMessageContent(contentType), and the SDK factory never returns undefined. So a fully-undefined content only arises from synthetic test construction, and these render accessors are safe today.
The point is internal consistency: the PR hardens the VM layer and adds a regression test for content === undefined, yet leaves the render layer crashing on that exact shape. If that state ever does occur (a future SDK change, or a path that bypasses Convert.ts), the crash resurfaces in render where it is harder to diagnose, and — because the conversation is wrapped in an ErrorBoundary (Pages/Chat/index.tsx:1228) — it would surface as a degraded error panel rather than the original recoverable behavior. Recommend either making the render accessors null-safe too, or dropping the synthetic content === undefined coverage to match the actual reachable failure (content.text === undefined). Either way the layers should agree on what shapes they defend.
Suggestion B — regression test mocks the unit under test (non-blocking)
In Components/Conversation/__tests__/messageOrder.test.ts, ProhibitwordsService is mocked with a re-implementation of the new normalization logic, and MessageWrap is mocked entirely. The "does not throw" assertions therefore validate VM control flow but never exercise the real MessageWrap render path (parts/parseMention/flame) where a malformed message would actually be rendered. The meaningful regression guard is the new Service/__tests__/ProhibitwordsService.test.ts, which runs the real sensitive-word-tool and reproduces the original throw — that one is well-targeted. Consider adding one assertion that drives a real MessageWrap through render (or at least asserts the message shape is normalized before entering this.messages) so the regression covers the path that actually broke.
Minor
Service/ProhibitwordsService.tsends without a trailing newline (\ No newline at end of file).
3. Items checked and found clean
- All three sibling
ProhibitwordsService.shared.filtercall sites inPages/Chat/vm.ts(227-228, 280-281, 610-611) already guard withlastMessage?.content &&before touching.text, so the conversation-list path does not reproduce the crash. The signature changestring → unknownis type-compatible (return staysstring, assigned to.text). fullStreamContent(Model.tsx:729) already uses optional chaining.- Build/test verification could not be completed in this environment (no
node_modules;tsc/vitestunavailable), so the type-check and test-run are unverified here — recommend confirming CI is green before merge.
…ersation crash (Mininglamp-OSS#465) A text message whose content failed to decode (message.content === undefined) slipped past the write/filter-layer guards and crashed the React render: the SDK Message.contentType getter, plus MessageWrap.flame and parts, dereference the missing content while the list is sorted, deduplicated and rendered. Normalize such messages to an empty MessageText at a single point at the top of ConversationVM.refreshMessages, before any contentType read, so a malformed message renders as an empty bubble instead of taking down the whole list. Tests: - messageOrder.test.ts: make the MessageWrap mock contentType faithful to the SDK (reads through content) so the missing-content case can no longer false-green. - malformedMessageRender.test.tsx: real wukongimjssdk + real MessageWrap coverage of the crashing getters, the refreshMessages normalization, and the render path.
a0cbccb
There was a problem hiding this comment.
Review: fix: prevent malformed text messages from crashing conversation render (#465) — delta review (#467)
Verdict: Approve
Delta from 417f3af → a0cbccb. The author pivoted from the wrong-layer patch to the correct fix shape, and it fully resolves the render-layer blocker I (corrected), Steve, OctoBoooot, and Jerry-Xin all converged on.
Prior blocker — resolved. Instead of scattering guards across the readers, refreshMessages now normalizes at a single point (vm.ts:1875): any message with content == null gets a new MessageText("") assigned before the sort/dedup/filter/render pipeline runs. Verified the ordering: the normalization loop is the first statement in refreshMessages, ahead of sortMessages / filterPersonMessagesBySpace / deduplicateSystemTips / insertTimeOrHistorySplit (all of which read contentType) and ahead of render. After it, no message in the list has missing content, so the three crash getters can't fire:
Message.contentType(this.content.contentType)MessageWrap.flame(this.message.content.contentObj)MessageWrap.parts→parseMention(this.content.contentType)
This is the robust single-point fix that was the better of the two options I suggested — it stops every downstream getter from individually needing to defend, rather than patching readers one at a time.
Tests now pin the right layer. malformedMessageRender.test.tsx (new, 186 lines) uses the real wukongimjssdk + real MessageWrap, and:
- First proves the raw malformed wrap throws on
contentType/flame/parts— i.e. it reproduces the actual crash, so the test would catch a regression that removes the normalization. - Then asserts
refreshMessagesnormalizes it (content instanceof MessageText,flame === false,parts→ empty text) and thatgetTextMessageUI+ an actualTextCellReactDOM render don't throw.
That closes the exact gap from the prior round: the old tests false-greened because the mock contentType didn't read through content; this round also fixes the mock to be faithful to the SDK (messageOrder.test.ts), so the missing-content case can no longer pass spuriously.
The earlier-round guards (if (content) in the filter loop, content?.reply in updateReplyMessageContent, the ProhibitwordsService.filter(unknown) hardening) are all still present — now belt-and-suspenders on top of the normalization, which is fine.
Praise
- Choosing normalization-at-the-boundary over per-getter guards is the architecturally correct call for the
refreshMessagesingestion path. A malformed message entering the rendered list is the invariant violation; fixing it once before sort/dedup/render meansflame,parts,contentTypeare all safe for every message that reachesthis.messages. The commit message names all three crashing getters explicitly, showing the fix was derived from tracing the actual crash surface, not guessing. malformedMessageRender.test.tsxruns the real SDK and actually renders the cell — and it asserts the pre-fix throw on the three getters first. A test that only checked "after fix, no throw" could pass against a no-op; proving the getters throw on the raw input means the test genuinely pins the crash. This is exactly the render-path coverage the prior round was missing.- Fixing the
messageOrder.test.tsmock to readcontentTypethroughcontent(faithful to the SDK) — the prior round's false-green came from an unfaithful mock. Correcting the mock so the missing-content case can't spuriously pass is the kind of test-infrastructure fix that prevents the next content-shape bug from slipping through too.
Out of scope (informational)
- Residual crash path on live WS push (pre-existing, not introduced here; credit @mochashanyao). The single-point normalization only protects messages flowing through
refreshMessages. The live WS message listener readsmessage.contentTypeatvm.ts:842(thertcDataearly-return) beforeappendMessage→refreshMessagesruns — and the SDKcontentTypegetter dereferencesthis.content.contentType(this PR's own test asserts it throws on a content-missing message). So a malformed message arriving via live push can still crash at the listener entry, ahead of normalization. This is narrower than the filed #465 incident (which was history-load) and the line is untouched by this PR, so it doesn't block — but my earlier "the whole class is fixed" framing was too broad: this PR fixes the ingestion/render path, not the live-listener entry. Worth a follow-up to normalize (or guard thecontentTyperead) at the top of the WS listener too. Correcting my own overclaim here. - PR is still a draft — merge waits for ready-for-review + the
check-sprintlinked-issue gate (body hasCloses #465, so that should clear once CI re-runs green on a non-draft).
There was a problem hiding this comment.
Summary: The PR is in scope for octo-web and the fix addresses the malformed-message crash at the right render-path choke point.
💬 Non-blocking
- 🔵 Suggestion:
packages/dmworkbase/src/Components/Conversation/vm.ts:1322now handles missingcontent, butreply: nullwould still fall through and throw atmessage.content.reply.messageID. Considerif (message.content?.reply == null) continuefor the same defensive shape. - 🔵 Suggestion:
packages/dmworkbase/src/Components/Conversation/__tests__/messageOrder.test.ts:177says the mock is faithful to the SDK dereference behavior, but it falls back tomessage.contentTypewhencontentis missing. The new real-SDK test covers the actual crash path, so this is not blocking, but the comment could be tightened.
✅ Highlights
ProhibitwordsService.filternow hardens all callers against non-string inputs.refreshMessagesnormalizes malformed content (new MessageText("")) before sort/dedupe/render reads can touch SDK getters.- Regression coverage includes service-level filtering, VM refresh/append/ack paths, and a real SDK render-path test (
malformedMessageRender.test.tsx) that exercises the actual crashing getters (contentType/flame/parts) and aReactDOM.renderof the text cell.
Note: could not run the targeted Vitest suite because this checkout has no node_modules.
ploy-elison
left a comment
There was a problem hiding this comment.
APPROVED — the render-layer root cause is genuinely closed this round, and the tests are honest regression guards rather than green-by-mock.
Verification (head a0cbccb, traced on the branch, not the commit message)
1. Single-point normalization actually fronts the whole render path.
refreshMessages (vm.ts) now normalizes m.message.content == null → new MessageText("") as its very first statement, before sortMessages, distinctMessages, filterPersonMessagesBySpace, deduplicateSystemTips, the text-filter loop, genMessageLinkedData, and buildRenderItems. this.messages is assigned only inside refreshMessages (the one non-empty assignment), and every other contentType / flame / parts read in the VM operates on this.messages or on messagesOfOrigin entries that have already been through refreshMessages and were normalized in place. So once a malformed message has passed through here once, the SDK getters (Message.contentType → this.content.contentType, MessageWrap.flame → content.contentObj, MessageWrap.parts → parseMention → content.contentType) are all safe. The earlier choke points that abort the rebuild (deduplicateSystemTips reading m.contentType) are downstream of the normalization, so they no longer throw.
2. MessageText("") is a safe normalization target.
It mutates only message.content. messageSeq / order / clientSeq / clientMsgNo / messageID / status / remoteExtra are untouched, so sort ordering, dedup keys, send-ack matching and extras are unaffected. For the empty text: contentType === text, flame === false (no contentObj), parts === []. A malformed bubble degrades to an empty bubble instead of taking down the list — correct trade-off given the original content is undecodable anyway.
3. Tests are real, not false-green — confirmed by running them.
malformedMessageRender.test.tsxuses the realwukongimjssdk+ realMessageWrap(only app/service singletons are stubbed). It asserts the raw wrap throws oncontentType/flame/parts, then thatrefreshMessagesnormalizes it, then drivesgetTextMessageUIand an actualReactDOM.render. I reverted the vm.ts fix to its pre-a0cbccba state and this suite goes red with exactly the documented crash —Message.contentType→this.content.contentTypeundefined deref, fired fromdeduplicateSystemTipsinsiderefreshMessages. With the fix in place all 24 tests across the three files pass.messageOrder.test.ts: thecontentTypemock now reads throughcontentand theProhibitwordsServicemock collapses non-strings to"", plus the new append / send-ack / edited-reply cases. These are logic-flow guards on a mocked SDK (they don't independently reproduce the getter crash — that's by design, since the real-SDK reproduction lives inmalformedMessageRender).ProhibitwordsService.test.tsexercises the realsensitive-word-tooland locks the null-guard (undefined / null / non-string / empty / passthrough).
4. First-round filter null-guard retained and tightened — filter(v: unknown): string now short-circuits non-strings and empty strings before touching the library. Good.
updateReplyMessageContent's content?.reply optional chaining is correct: it sits behind the same normalization for the rendered list and additionally guards the standalone path.
Non-blocking follow-ups (not in this PR's render-path scope; file separately)
- Live push path still reads raw
contentTypebefore normalization. In the realtime message listener,if (message.contentType == MessageContentTypeConst.rtcData)dereferences the raw SDKMessage.contentTypebefore the message is wrapped/appended/normalized. A malformed message arriving over WS (rather than from history sync) would still throw there. Severity is lower than the original bug — it drops a single live message rather than blanking the loaded conversation — and it's pre-existing, but it's the same root cause via a different entry point and worth closing. - Conversation-list
lastMessagestill derefs content on the LHS. InPages/Chat/vm.ts(lines ~228 / ~281 / ~611) the assignmentsconversation.lastMessage.content.text = filter(...)are now safe on thefilterargument, but iflastMessage.contentitself is undefined the left-hand.content.text =still throws. Different surface from this PR's group-conversation render, same family.
Neither blocks merge. The PR delivers its stated scope.
mochashanyao
left a comment
There was a problem hiding this comment.
[Octo-Q · automated review]
Verdict: Approve — no blocking findings; notes below (data-flow traced).
octo-web PR#467 Review Report (automated review)
Reviewer: Octo-Q (automated review)
PR: Mininglamp-OSS/octo-web #467
Head SHA: a0cbccbae30a7ff3470cb838e2d3d6e8ed20fcc5
Scope: fix: prevent malformed text messages from crashing conversation render (#465)
Files: 5 changed (+303/-6) — 2 production, 3 test
1. Verification Summary
| Item | Status | Evidence |
|---|---|---|
| Root cause correctly identified | ✅ | PR description matches code: ProhibitwordsService.filter(undefined) → sensitive-word-tool reads content.length → crash. Confirmed at vm.ts:1891 (pre-fix) and ProhibitwordsService.ts:60 (pre-fix). |
Single-point normalization in refreshMessages |
✅ | vm.ts:1875-1880 — m.message.content == null → new MessageText(""). Runs before sortMessages, deduplicateSystemTips, insertTimeOrHistorySplit, prohibitwords loop, and rebuildRenderItems. All downstream contentType reads are safe. |
ProhibitwordsService.filter hardening |
✅ | ProhibitwordsService.ts:60-65 — v: unknown, typeof guard, early return "" for non-string/empty. Matches crash signature (library reads content.length on non-string). |
updateReplyMessageContent optional chain |
✅ | vm.ts:1321 — message.content?.reply === undefined guards against missing content. Correctly skips malformed entries. |
| Test coverage | ✅ | 3 test files: unit tests for filter (5 cases), messageOrder regression (4 cases with malformed messages), render-layer test with real SDK + real MessageWrap (4 cases). |
appendMessage / updateMessageStatusBySendAck covered |
✅ | Both call refreshMessages internally (vm.ts:1449, vm.ts:1291), so normalization applies. |
2. Findings
Finding 1 — P2 (pre-existing): WS push messageListener reads message.contentType before normalization
File: vm.ts:842
Code: if (message.contentType == MessageContentTypeConst.rtcData) { return }
The messageListener callback receives raw SDK Message objects from WS push. At line 842, message.contentType invokes the SDK getter which dereferences this.content.contentType. If a WS-delivered message has content === undefined (same root cause as #465), this throws inside the listener.
Diff-scope check: Pre-existing. The PR does not touch the messageListener callback. The fix in refreshMessages only covers the history-load / append / ack paths, not the raw WS entry point. Not amplified.
Severity: P2 — lower impact than the history path because (a) the reported crash came from history, not live push; (b) the listener crash would affect only the current message, not the entire rendered list. But it is a reachable crash on the same root cause.
Suggestion: Add a message.content == null guard before line 842, or wrap the listener body in try/catch to isolate per-message errors.
Finding 2 — P2 (pre-existing): Chat/vm.ts sidebar paths accidentally protected by && short-circuit
File: Pages/Chat/vm.ts:228, 281, 611
Code: if (conversation.lastMessage?.content && conversation.lastMessage?.contentType === MessageContentType.text)
The && short-circuit ensures contentType (which derefs content.contentType) is only read when content is truthy. Combined with the ProhibitwordsService.filter hardening (now handles non-string), this is safe today.
Diff-scope check: Pre-existing. The && guard was already present. The PR's filter hardening adds defense-in-depth for the inner .text access.
Severity: P2/nit — safe but fragile. The protection is accidental (the && was likely added for space filtering, not crash prevention). A future refactor that reorders the condition could expose the crash.
Suggestion: Consider adding an explicit comment or extracting a guard function to make the safety intent clear.
Finding 3 — Nit: Redundant if (content) guard in prohibitwords loop
File: vm.ts:1889-1892
Code:
const content = message.content
if (content) {
content.text = ProhibitwordsService.shared.filter(content.text)
}After the normalization at lines 1875-1880, message.content is guaranteed non-null for all messages in the array. The if (content) check is therefore always true. Harmless defense-in-depth, but creates a false impression that content can be null at this point.
Suggestion: No action needed. If cleaning up, can remove the if and inline.
3. Suggestions
- Follow-up PR for WS push path (Finding 1): Add
if (message.content == null) returnbefore line 842 in themessageListener, mirroring therefreshMessagesnormalization strategy. This closes the last remaining crash path on the same root cause. - Consider normalizing in the
MessageWrapconstructor instead of (or in addition to)refreshMessages. This would protect all downstream readers automatically, including any future paths that bypassrefreshMessages. Trade-off: mutates SDK state at wrap time.
4. Additional Findings
module.tsx:1001readsmessage.content.contentObjfor thread creation. After normalization,contentisMessageText("")—contentObjwould be undefined, falling through to the??fallback. Safe.- The
MessageWrap.flamegetter (Model.tsx:740) andpartsgetter (Model.tsx:364) both dereferencethis.message.content.contentObj/this.content.contentType. Both are now protected by the upstream normalization inrefreshMessages. ThemalformedMessageRender.test.tsxtest at lines 112-115 correctly verifies these crash pre-normalization and are safe post-normalization.
5. Data Flow Trace
| Consumed data | Upstream source | Does data reach consumption point? |
|---|---|---|
message.contentType in sort/dedup/render |
SDK Message.contentType getter → this.content.contentType |
✅ After normalization: content = MessageText("") → contentType = 1 (text) |
message.content.text in prohibitwords loop |
SDK MessageText.text |
✅ After normalization: text = "". Filter returns "". Safe. |
message.content.reply in updateReplyMessageContent |
SDK content's reply property |
✅ After normalization: content = MessageText(""), no reply property → optional chain ?.reply === undefined → continue. Safe. |
message.content in MessageWrap.flame getter |
this.message.content.contentObj |
✅ After normalization: content = MessageText(""). contentObj is undefined → flame = false. No crash. |
message.content in MessageWrap.parts → parseMention |
this.content.contentType |
✅ After normalization: contentType = 1 (text). text = "" → parseMention returns single empty text part. No crash. |
ProhibitwordsService.filter(v) |
Various callers pass content.text or lastMessage.content.text |
✅ Filter now accepts unknown, returns "" for non-string. All callers safe. |
conversation.lastMessage.content.text in Chat/vm.ts |
SDK Conversation.lastMessage → Message.content.text |
✅ Protected by && short-circuit (content existence checked before contentType read). Filter hardening adds second layer. |
6. Blind-spot Checklist (R5)
- C1 — Dual-path parity (symmetric path self-check): N/A. This PR does not touch add/remove/subscribe/unsubscribe pairs. The fix is a unilateral guard on a read path.
- C2 — Control-flow ordering / nesting reuse: N/A. The normalization runs once at the top of
refreshMessages. No nested or reused control flow. TheProhibitwordsService.filteris called from 4 sites — verified all are safe (see data flow trace). - C3 — Authorization boundary ≠ capability boundary: N/A. No permission/auth/jail/tool exposure changes.
- C4 — Authorization lifecycle / container-member state cascade: N/A. No auth changes.
- C5 — Build/note ≠ runtime path: N/A. No build artifacts, extensions, CLI, or relative-URL changes.
- C6 — Governance/policy/security doc consistency: N/A. No governance or policy changes.
7. Cross-round Blocker Recheck (R6)
N/A — first review of this PR.
[Octo-Q] verdict: APPROVE — No P0 or P1 introduced or amplified by this PR. The fix is well-designed (single-point normalization + defense-in-depth), correctly addresses the reported crash, and has thorough test coverage including real-SDK render-path tests. Two pre-existing P2 items noted (WS push path, Chat/vm.ts accidental guard) for follow-up.
yujiawei
left a comment
There was a problem hiding this comment.
Code Review — PR #467 (octo-web)
Verdict: APPROVE — head SHA a0cbccba. The fix correctly resolves the #465 P0 crash, the tests genuinely reproduce the original failure, and I could not confirm any remaining crash path after tracing the data flow against the SDK source.
1. Spec compliance
The linked issue (#465) is a P0: one group's conversation went blank and could not send, with TypeError: Cannot read properties of undefined (reading 'length') in the filter → refreshMessages → appendMessage / updateMessageStatusBySendAck chain.
- Missing requirements: none. The PR fixes the crash at its source, hardens the related call sites, and adds regression tests.
- Out-of-scope additions: none. No new flags, fields, or public APIs. The whole-
content-missing normalization inrefreshMessagesgoes slightly beyond the literal stack trace (which is a missingcontent.text, not a missingcontent), but it is defensive hardening for the same bug class, not scope creep. - Deviations: none.
Root-cause analysis in the PR description matches the code: ProhibitwordsService.filter forwarded a non-string straight to sensitive-word-tool, which reads content.length and throws. Confirmed against the SDK: Message.contentType is get() { return this.content.contentType }, so any content-less message also throws on the contentType read.
2. Code quality
The load-bearing fix is correct. ProhibitwordsService.filter(v: unknown) now normalizes any non-string (including undefined/null/"") to "" before touching the third-party library (ProhibitwordsService.ts:60-66). This is the actual production crash path from the #465 stack trace, and it is fixed cleanly.
Single-point normalization is the right shape. The render list this.messages is assigned in exactly one place — refreshMessages at vm.ts:1895, after the content == null → new MessageText("") normalization at vm.ts:1873-1875. Every entry point that mutates the rendered set (appendMessage, updateMessageStatusBySendAck) routes through refreshMessages, so no un-normalized message can reach buildRenderItems / the React layer.
I checked the realtime/WebSocket path specifically and it is not a regression. The live listeners (vm.ts:842, module.tsx:530) read message.contentType on the raw SDK message before it is wrapped. That read is safe because the SDK's decodePayload always returns a defined MessageContent (it falls back to UnknownContent() and always assigns message.content), so a wire-delivered message never has content === undefined. The content-less shape only arises from artificially constructed messages; appendMessage still funnels those through refreshMessages before render. No crash path remains there.
Tests are real, not theater. I reverted the vm.ts changes locally and re-ran the suite: 6 of the new cases fail with the exact original TypeError, and pass once the fix is restored (24/24 green with the fix). The new malformedMessageRender.test.tsx exercises the real SDK getters (contentType, flame, parts) that crash the list, which is good coverage.
Non-blocking suggestions (P2)
messageOrder.test.tscovers the case where origin already holds a malformed message, but not a directvm.appendMessage(malformed)arrival. Adding that one case would document the realtime path explicitly. Not required — it is covered transitively today.- The in-place mutation
m.message.content = new MessageText("")(vm.ts:1875) edits the shared SDKMessage. This is safe here (it only replaces a missing content with an empty text, which is strictly safer for any other reader), but a null-safe getter onMessageWrapwould be a slightly cleaner long-term shape if more of these accumulate.
3. Overall
Spec ✅ and quality approved → APPROVE. Solid, well-tested P0 fix.
QA Engineer Verdict: APPROVETest coverage: Excellent for a P0 bug fix.
Boundary coverage: Both crash variants tested — content entirely missing AND content present but text undefined. CI: All checks green (Build, code-review, dependency-review, OSV scan, secret-scan). Minor note: |
Security Engineer Verdict: APPROVE (CLEARED)Threat assessment: This PR fixes a client-side Denial-of-Service vector — a malformed text message could crash the entire conversation render, making the group unusable. The fix properly addresses the root cause at multiple defense layers. STRIDE analysis:
SBOM: No new dependencies added. Type signature widening ( CI security checks: OSV scan pass, secret scan pass, dependency review pass. No security concerns. |
Code Reviewer Verdict: APPROVECorrectness: Fix addresses the root cause at the right abstraction level. Single-point normalization in Readability: Comments are clear, reference #465, and explain the "why" not the "what". Chinese comments consistent with codebase convention. Design fit:
Maintainability: The normalization loop is at the top of No issues found. Clean P0 bug fix. |
lml2468
left a comment
There was a problem hiding this comment.
Multica Loop Review — All 3 personas APPROVE
- qa-engineer: APPROVE — thorough test coverage (13 test cases across 3 files), CI all green
- security-engineer: APPROVE (CLEARED) — fixes client-side DoS vector, no new security concerns
- code-reviewer: APPROVE — well-targeted minimal fix, clean design
Ready for human merge.
Aggregate Verdict: APPROVED — 等人工合并All 3 reviewer personas have completed review:
下一步: 等待人工合并。PR 已就绪。 |
Closes #465.
Problem
In one specific group, the conversation history vanished and messages could not be sent, while other groups worked fine. The console showed:
TypeError: Cannot read properties of undefined (reading 'length')in thefilter → refreshMessages → appendMessage / updateMessageStatusBySendAckchain.Root cause
The affected group's history contains a malformed text message: the payload is typed as text (
type === 1) but the content body is missing, so the decodedmessage.content.textisundefined. During render,ConversationVM.refreshMessagesruns the sensitive-word filter on every text message and calledProhibitwordsService.filter(undefined).ProhibitwordsService.filterforwarded the value straight to the third-partysensitive-word-tool, which readscontent.lengthand threw. This abortedrefreshMessagesbefore it could assign the rendered list, blanking the whole conversation. Send acks and live-message appends both re-run the same refresh, so every attempt hit the same bad history message and failed.Fix
ProhibitwordsService.filternow acceptsunknown, normalizes any non-string (includingundefined/null/empty string) to"", and only invokes the library for real strings. This also hardens the other call sites.ConversationVM.refreshMessagesskips entries whosecontentobject is missing.ConversationVM.updateReplyMessageContentuses optional chaining so retained malformed messages no longer crash when a replied-to message is edited (same root cause, different trigger).Tests
ProhibitwordsServiceunit tests run against the realsensitive-word-tooland reproduce the original crash before the fix (filter(undefined/null/non-string/"")→"", normal strings pass through).messageOrderregression tests coverrefreshMessages,appendMessage,updateMessageStatusBySendAckand the edited-reply path with a malformed text message present, asserting no throw and correct normalization.