@@ -516,7 +516,10 @@ internal class MessageStore {
516516 * Returns true if anything was removed. */
517517 fun pruneStaleCacheSeeded(cacheSeededIds: Set<String>, rest: List<MessageWithParts>): Boolean {
518518 if (cacheSeededIds.isEmpty()) return false
519− val restIds = rest.mapTo(mutableSetOf()) { it.info.id }
519+ // Build the retain-set from the synthetic key (matching the map's keying for
520+ // UnknownMessages with empty ids) so an entry stored under "unknown-c<created>" isn't
521+ // wrongly pruned just because its raw id ("") isn't in the REST snapshot's raw-id set.
522+ val restIds = rest.mapTo(mutableSetOf()) { syntheticKeyFor(it) }
520523 var changed = false
521524 for (id in cacheSeededIds) {
522525 if (id !in restIds && messages.remove(id) != null) changed = true
@@ -536,6 +539,26 @@ internal class MessageStore {
536539 m.info.id
537540 }
538541
542+ /** Resolve the actual map key for a raw message id from a server event. A non-empty id is
543+ * the map key as-is. An empty id ("") can't be the map key (handleMessageUpdated stores
544+ * UnknownMessages with empty ids under a synthetic "unknown-c<created>" / "unknown-h<hash>"
545+ * key), so search the map for the holder whose info is an UnknownMessage with an empty id.
546+ * Returns null when no holder matches — callers treat this as "not in the store" and skip.
547+ *
548+ * This is O(N) only for the rare empty-id case (UnknownMessages from unrecognized server
549+ * roles); the common non-empty-id path is an O(1) map lookup. Without this, handleMessageRemoved
550+ * / handlePartRemoved / the null-session MessagePartUpdated guard would all miss synthetic-keyed
551+ * holders, leaving ghost entries and dropped part removals. */
552+ private fun resolveMapKey(messageId: String): String? {
553+ if (messageId.isNotEmpty()) return messageId
554+ // Empty id: find a holder stored under a synthetic key whose info is an UnknownMessage
555+ // with an empty id. There's normally at most one such holder per session (an unrecognized
556+ // server role with no id is rare), so the linear scan is acceptable.
557+ return messages.entries.firstOrNull { entry ->
558+ entry.value.info is UnknownMessage && entry.value.info.id.isEmpty()
559+ }?.key
560+ }
561+
539562 /** Reorder the message map by message creation time (stable). Ties and entries with no
540563 * server time yet (e.g. a brand-new message still streaming and not in REST) keep their
541564 * relative order, so such a message stays last. Preserves each entry's map key so the
@@ -562,18 +585,30 @@ internal class MessageStore {
562585
563586 is MessagePartUpdated -> {
564587 val part = event.properties.part
565− val messageId = part.messageID ?: event.properties.messageID
588+ val rawMessageId = part.messageID ?: event.properties.messageID
566589 val partSession = part.sessionID ?: event.properties.sessionID
567590 when {
568− messageId == null -> false
569− partSession != null -> if (partSession == sessionId) upsertPart(messageId, part) else false
570− // No session id anywhere on the event: a null session is NOT a wildcard
571− // (mirroring isIdle/isError). Every open session's store sees the shared
572− // event stream, so accepting it unconditionally would leak the part into
573− // unrelated conversations (e.g. both panes in two-pane mode). Attribute it
574− // only if this store already holds the parent message — MessageUpdated,
575− // which always carries a session id, will have created it here first.
576− else -> if (messages.containsKey(messageId)) upsertPart(messageId, part) else false
591+ rawMessageId == null -> false
592+ // Resolve the map key: an UnknownMessage with an empty id is stored under a
593+ // synthetic key, so upsertPart(rawMessageId, part) would miss it and fork a
594+ // duplicate holder under key "". resolveMapKey returns the synthetic key when
595+ // the holder exists, or the raw id (possibly "") when it doesn't — in which
596+ // case upsertPart creates a fresh holder under that id, matching the prior
597+ // behavior for a genuinely new message.
598+ else -> {
599+ val mapKey = resolveMapKey(rawMessageId) ?: rawMessageId
600+ if (partSession != null) {
601+ if (partSession == sessionId) upsertPart(mapKey, part) else false
602+ } else {
603+ // No session id anywhere on the event: a null session is NOT a wildcard
604+ // (mirroring isIdle/isError). Every open session's store sees the shared
605+ // event stream, so accepting it unconditionally would leak the part into
606+ // unrelated conversations (e.g. both panes in two-pane mode). Attribute it
607+ // only if this store already holds the parent message — MessageUpdated,
608+ // which always carries a session id, will have created it here first.
609+ if (messages.containsKey(mapKey)) upsertPart(mapKey, part) else false
610+ }
611+ }
577612 }
578613 }
579614
@@ -619,21 +654,30 @@ internal class MessageStore {
619654 }
620655
621656 private fun handlePartRemoved(sessionId: String, event: MessagePartRemoved): Boolean {
622− val messageId = event.properties.messageID ?: return false
657+ val rawMessageId = event.properties.messageID ?: return false
623658 val partId = event.properties.partID ?: return false
624659 val eventSession = event.properties.sessionID
660+ // Resolve the actual map key: an UnknownMessage with an empty id is stored under a
661+ // synthetic key, so a bare messages.containsKey(rawMessageId) would miss it and the
662+ // part removal would be silently dropped.
663+ val mapKey = resolveMapKey(rawMessageId) ?: return false
625664 when {
626665 eventSession != null && eventSession != sessionId -> return false
627666 // Null session id: only act if this store already holds the parent message, so a
628667 // null-session part removal can't drop a part from an unrelated session's store.
629− eventSession == null && !messages.containsKey(messageId) -> return false
668+ eventSession == null && !messages.containsKey(mapKey) -> return false
630669 }
631− return removePart(messageId, partId)
670+ return removePart(mapKey, partId)
632671 }
633672
634673 private fun handleMessageRemoved(sessionId: String, event: MessageRemoved): Boolean {
635− val id = event.properties.messageID ?: return false
674+ val rawId = event.properties.messageID ?: return false
636675 val eventSession = event.properties.sessionID
676+ // Resolve the actual map key: an UnknownMessage with an empty id is stored under a
677+ // synthetic key ("unknown-c<created>" / "unknown-h<hash>"), so messages.remove(rawId)
678+ // would miss it and leave the ghost entry stranded — the pivotKey cleanup below would
679+ // be dead code (the early return fires before it runs).
680+ val id = resolveMapKey(rawId) ?: return false
637681 when {
638682 eventSession != null && eventSession != sessionId -> return false
639683 // Null session id: only act if this store already holds the message (message ids
@@ -648,7 +692,7 @@ internal class MessageStore {
648692 // impact today — seed() only consults it when the holder exists — but unbounded growth
649693 // across many add/remove cycles in a long-lived session). Mirrors evictOldMessages,
650694 // which already uses the map key (oldestKey) for its pivot cleanup.
651− val pivotKey = if (id.isEmpty() && removed.info is UnknownMessage) {
695+ val pivotKey = if (rawId.isEmpty() && removed.info is UnknownMessage) {
652696 removed.info.time?.created?.let { "unknown-c$it" } ?: "unknown-h${removed.info.hashCode()}"
653697 } else {
654698 id