@@ -610,7 +610,23 @@ internal class MessageStore {
610610 // behavior for a genuinely new message.
611611 else -> {
612612 val mapKey = resolveMapKey(rawMessageId) ?: rawMessageId
613− if (partSession != null) {
613+ // An evicted UnknownMessage with an empty id was recorded in
614+ // evictedMessageIds under its synthetic "unknown-c<created>" /
615+ // "unknown-h<hash>" key (see evictOldMessages). resolveMapKey("")
616+ // returns null (the holder is gone), so mapKey falls back to "" —
617+ // which isn't in the set. Drop the late part instead of re-inserting
618+ // a partial bubble. The set could contain synthetic keys for any of
619+ // possibly-several evicted unknown messages; without the message's
620+ // creation time on the Part we can't disambiguate, so treat ANY
621+ // evicted synthetic key as a match. This is over-broad (a genuinely-
622+ // new unknown message's first part would be dropped if an unrelated
623+ // one was just evicted), but UnknownMessages from unrecognized server
624+ // roles are rare, and a following MessageUpdated (which always
625+ // carries full info) re-creates the holder correctly either way.
626+ if (rawMessageId.isEmpty() && mapKey.isEmpty() &&
627+ evictedMessageIds.any { it.startsWith("unknown-") }) {
628+ false
629+ } else if (partSession != null) {
614630 if (partSession == sessionId) upsertPart(mapKey, part) else false
615631 } else {
616632 // No session id anywhere on the event: a null session is NOT a wildcard
@@ -656,7 +672,11 @@ internal class MessageStore {
656672 // A MessageUpdated re-introduces the message into the store (it was either evicted
657673 // by evictOldMessages or genuinely new). Drop any eviction record so a following
658674 // MessagePartUpdated updates this holder instead of being dropped as "late".
675+ // For an empty-id UnknownMessage the record was stored under the synthetic key (see
676+ // evictOldMessages), so remove that too — otherwise the over-broad empty-id guard in
677+ // reduce() would keep dropping parts for this message even after it's back in memory.
659678 info.id.takeIf { it.isNotEmpty() }?.let { evictedMessageIds.remove(it) }
679+ if (info.id.isEmpty()) evictedMessageIds.remove(key)
660680 if (existing == null) {
661681 messages[key] = Holder(info, LinkedHashMap())
662682 } else {
@@ -705,6 +725,9 @@ internal class MessageStore {
705725 // The message is gone from the store; drop any eviction record for it so the set
706726 // doesn't retain ids of long-deleted messages. (A re-seed would also clear this,
707727 // but a quiet session that never reconnects could otherwise accumulate entries.)
728+ // For an empty-id UnknownMessage the record was stored under the synthetic key (see
729+ // evictOldMessages), so remove that too — keep the set bounded and prevent the
730+ // over-broad empty-id guard in reduce() from dropping parts for a reused id.
708731 removed.info.id.takeIf { it.isNotEmpty() }?.let { evictedMessageIds.remove(it) }
709732 // Derive the same key handleMessageUpdated would have stored this holder under. For an
710733 // UnknownMessage with an empty id, that's the synthetic "unknown-c<created>" /
@@ -718,6 +741,7 @@ internal class MessageStore {
718741 } else {
719742 id
720743 }
744+ if (removed.info.id.isEmpty()) evictedMessageIds.remove(pivotKey)
721745 messageInfoUpdatedSincePivot.remove(pivotKey)
722746 removed.parts.keys.forEach { streamedSincePivot.remove(it) }
723747 return true
@@ -786,10 +810,19 @@ internal class MessageStore {
786810 // Record the evicted message's id so a late MessagePartUpdated for it is dropped
787811 // instead of re-inserting a single-part bubble (see upsertPart). Also clear any
788812 // prior eviction record for this id from an older cycle (the message re-entered
789− // via seed/MessageUpdated and has now been evicted again).
813+ // via seed/MessageUpdated and has now been evicted again). Record the MAP KEY
814+ // (oldestKey), not just info.id: an UnknownMessage with an empty id is stored
815+ // under a synthetic "unknown-c<created>" / "unknown-h<hash>" key, so recording
816+ // only info.id (which is "") would never match — a late MessagePartUpdated for
817+ // it would resolveMapKey("") → null → fall back to upsertPart("") and re-insert
818+ // a partial bubble. Recording the synthetic key lets the empty-id guard below
819+ // (in reduce) catch it.
790820 removed?.info?.id?.takeIf { it.isNotEmpty() }?.let { id ->
791821 evictedMessageIds.add(id)
792822 }
823+ if (removed != null && removed.info.id.isEmpty()) {
824+ evictedMessageIds.add(oldestKey)
825+ }
793826 // Prune the evicted message's pivot bookkeeping so these sets don't grow
794827 // unbounded over a long-running session that never reconnects (they're only
795828 // otherwise cleared on reseed/seed).