@@ -516,7 +516,10 @@ internal class MessageStore {
516 * Returns true if anything was removed. */
516 * Returns true if anything was removed. */
517 fun pruneStaleCacheSeeded(cacheSeededIds: Set<String>, rest: List<MessageWithParts>): Boolean {
517 fun pruneStaleCacheSeeded(cacheSeededIds: Set<String>, rest: List<MessageWithParts>): Boolean {
518 if (cacheSeededIds.isEmpty()) return false
518 if (cacheSeededIds.isEmpty()) return false
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.
519− val restIds = rest.mapTo(mutableSetOf()) { it.info.id }
522+ val restIds = rest.mapTo(mutableSetOf()) { syntheticKeyFor(it) }
520 var changed = false
523 var changed = false
521 for (id in cacheSeededIds) {
524 for (id in cacheSeededIds) {
522 if (id !in restIds && messages.remove(id) != null) changed = true
525 if (id !in restIds && messages.remove(id) != null) changed = true
@@ -536,6 +539,26 @@ internal class MessageStore {
536 m.info.id
539 m.info.id
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.
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()
539 /** Reorder the message map by message creation time (stable). Ties and entries with no
562 /** Reorder the message map by message creation time (stable). Ties and entries with no
540 * server time yet (e.g. a brand-new message still streaming and not in REST) keep their
563 * server time yet (e.g. a brand-new message still streaming and not in REST) keep their
541 * relative order, so such a message stays last. Preserves each entry's map key so the
564 * relative order, so such a message stays last. Preserves each entry's map key so the
@@ -562,18 +585,30 @@ internal class MessageStore {
563 is MessagePartUpdated -> {
586 is MessagePartUpdated -> {
564 val part = event.properties.part
587 val part = event.properties.part
565− val messageId = part.messageID ?: event.properties.messageID
588+ val rawMessageId = part.messageID ?: event.properties.messageID
566 val partSession = part.sessionID ?: event.properties.sessionID
589 val partSession = part.sessionID ?: event.properties.sessionID
568− messageId == null -> 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.
599+ val mapKey = resolveMapKey(rawMessageId) ?: rawMessageId
600+ if (partSession != null) {
569− partSession != null -> if (partSession == sessionId) upsertPart(messageId, part) else false
601+ if (partSession == sessionId) upsertPart(mapKey, part) else false
570− // No session id anywhere on the event: a null session is NOT a wildcard
603+ // 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
604+ // (mirroring isIdle/isError). Every open session's store sees the shared
572− // event stream, so accepting it unconditionally would leak the part into
605+ // event stream, so accepting it unconditionally would leak the part into
573− // unrelated conversations (e.g. both panes in two-pane mode). Attribute it
606+ // unrelated conversations (e.g. both panes in two-pane mode). Attribute it
574− // only if this store already holds the parent message — MessageUpdated,
607+ // only if this store already holds the parent message — MessageUpdated,
575− // which always carries a session id, will have created it here first.
608+ // which always carries a session id, will have created it here first.
576− else -> if (messages.containsKey(messageId)) upsertPart(messageId, part) else false
609+ if (messages.containsKey(mapKey)) upsertPart(mapKey, part) else false
@@ -619,21 +654,30 @@ internal class MessageStore {
621 private fun handlePartRemoved(sessionId: String, event: MessagePartRemoved): Boolean {
656 private fun handlePartRemoved(sessionId: String, event: MessagePartRemoved): Boolean {
622− val messageId = event.properties.messageID ?: return false
657+ val rawMessageId = event.properties.messageID ?: return false
623 val partId = event.properties.partID ?: return false
658 val partId = event.properties.partID ?: return false
624 val eventSession = event.properties.sessionID
659 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
626 eventSession != null && eventSession != sessionId -> return false
665 eventSession != null && eventSession != sessionId -> return false
627 // Null session id: only act if this store already holds the parent message, so a
666 // Null session id: only act if this store already holds the parent message, so a
628 // null-session part removal can't drop a part from an unrelated session's store.
667 // 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
631− return removePart(messageId, partId)
670+ return removePart(mapKey, partId)
634 private fun handleMessageRemoved(sessionId: String, event: MessageRemoved): Boolean {
673 private fun handleMessageRemoved(sessionId: String, event: MessageRemoved): Boolean {
635− val id = event.properties.messageID ?: return false
674+ val rawId = event.properties.messageID ?: return false
636 val eventSession = event.properties.sessionID
675 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
638 eventSession != null && eventSession != sessionId -> return false
682 eventSession != null && eventSession != sessionId -> return false
639 // Null session id: only act if this store already holds the message (message ids
683 // Null session id: only act if this store already holds the message (message ids
@@ -648,7 +692,7 @@ internal class MessageStore {
648 // impact today — seed() only consults it when the holder exists — but unbounded growth
692 // impact today — seed() only consults it when the holder exists — but unbounded growth
649 // across many add/remove cycles in a long-lived session). Mirrors evictOldMessages,
693 // across many add/remove cycles in a long-lived session). Mirrors evictOldMessages,
650 // which already uses the map key (oldestKey) for its pivot cleanup.
694 // 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) {
652 removed.info.time?.created?.let { "unknown-c$it" } ?: "unknown-h${removed.info.hashCode()}"
696 removed.info.time?.created?.let { "unknown-c$it" } ?: "unknown-h${removed.info.hashCode()}"