@@ -100,6 +100,10 @@ open class SessionRepository(
100 if (state == EventStreamClient.ConnectionState.Connected) {
100 if (state == EventStreamClient.ConnectionState.Connected) {
101 if (hasConnectedBefore) {
101 if (hasConnectedBefore) {
102 val generation = ++seedGeneration
102 val generation = ++seedGeneration
103+ // Pivot before the fetch: parts already in memory predate this
104+ // snapshot and must yield to it, while parts that stream in during
105+ // the fetch (added to streamedSincePivot via reduce) outrank it.
106+ lock.withLock { store.beginReseed() }
103 val fresh = runCatchingCancellable { api.listMessages(sessionId) }
107 val fresh = runCatchingCancellable { api.listMessages(sessionId) }
104 .onFailure { Log.w("SessionRepository", "Re-seed message load failed for $sessionId; relying on SSE: ${safeExceptionSummary(it)}") }
108 .onFailure { Log.w("SessionRepository", "Re-seed message load failed for $sessionId; relying on SSE: ${safeExceptionSummary(it)}") }
105 .getOrDefault(emptyList())
109 .getOrDefault(emptyList())
@@ -159,6 +163,18 @@ internal class MessageStore {
159 // messageId -> (info + parts), insertion-ordered.
163 // messageId -> (info + parts), insertion-ordered.
160 private val messages = LinkedHashMap<String, MessageWithParts>()
164 private val messages = LinkedHashMap<String, MessageWithParts>()
166+ /** Part ids upserted via [reduce] since the last reseed pivot ([beginReseed]) or seed.
167+ * On a reconnect re-seed these are the parts that streamed in *during* the REST fetch,
168+ * so they're newer than the snapshot and must win over it; every other part is taken
169+ * from the authoritative REST snapshot. Without this distinction the merge kept every
170+ * overlapping in-memory part, so a part that changed server-side during the disconnect
171+ * (e.g. a tool going running -> completed) stayed stale until app restart. */
172+ private val streamedSincePivot = mutableSetOf<String>()
174+ /** Mark the point a reconnect re-seed's REST fetch begins: parts streamed from here on
175+ * are newer than the fetched snapshot. Call under the same lock as [seed]/[reduce]. */
176+ fun beginReseed() { streamedSincePivot.clear() }
162 /** Monotonic counter for synthetic UnknownMessage keys, avoiding nanoTime collisions. */
178 /** Monotonic counter for synthetic UnknownMessage keys, avoiding nanoTime collisions. */
163 private val unknownCounter = java.util.concurrent.atomic.AtomicLong(0)
179 private val unknownCounter = java.util.concurrent.atomic.AtomicLong(0)
@@ -194,7 +210,13 @@ internal class MessageStore {
194 // append any streamed-only parts, and adopt the authoritative REST info.
210 // append any streamed-only parts, and adopt the authoritative REST info.
195 val streamedById = existing.parts.associateBy { it.id }
211 val streamedById = existing.parts.associateBy { it.id }
196 val snapshotIds = m.parts.mapTo(mutableSetOf()) { it.id }
212 val snapshotIds = m.parts.mapTo(mutableSetOf()) { it.id }
213+ // Prefer the in-memory version of an overlapping part ONLY if it streamed
214+ // in since the reseed pivot (i.e. during this fetch), so it's newer than
215+ // the snapshot. Otherwise the REST snapshot is authoritative and wins,
216+ // discarding an in-memory part that went stale during a disconnect.
197− val ordered = m.parts.map { p -> streamedById[p.id] ?: p }.toMutableList()
217+ val ordered = m.parts.map { p ->
218+ if (p.id in streamedSincePivot) streamedById[p.id] ?: p else p
198 for (p in existing.parts) {
220 for (p in existing.parts) {
199 if (p.id !in snapshotIds) ordered.add(p)
221 if (p.id !in snapshotIds) ordered.add(p)
@@ -207,6 +229,9 @@ internal class MessageStore {
209 if (evictOldMessages()) changed = true
231 if (evictOldMessages()) changed = true
232+ // Reset the pivot: everything just merged is now the baseline, so only parts that
233+ // stream in *after* this seed can outrank the next reconnect's snapshot.
234+ streamedSincePivot.clear()
210 return changed
235 return changed
@@ -238,10 +263,16 @@ internal class MessageStore {
238 val part = event.properties.part
263 val part = event.properties.part
239 val messageId = part.messageID ?: event.properties.messageID
264 val messageId = part.messageID ?: event.properties.messageID
240 val partSession = part.sessionID ?: event.properties.sessionID
265 val partSession = part.sessionID ?: event.properties.sessionID
267+ messageId == null -> false
241− if (messageId == null || (partSession != null && partSession != sessionId)) {
268+ partSession != null -> if (partSession == sessionId) upsertPart(messageId, part) else false
242− false
269+ // No session id anywhere on the event: a null session is NOT a wildcard
243− } else {
270+ // (mirroring isIdle/isError). Every open session's store sees the shared
271+ // event stream, so accepting it unconditionally would leak the part into
272+ // unrelated conversations (e.g. both panes in two-pane mode). Attribute it
273+ // only if this store already holds the parent message — MessageUpdated,
274+ // which always carries a session id, will have created it here first.
244− upsertPart(messageId, part)
275+ else -> if (messages.containsKey(messageId)) upsertPart(messageId, part) else false
@@ -285,6 +316,9 @@ internal class MessageStore {
285 info = UnknownMessage(id = messageId, sessionID = part.sessionID ?: ""),
316 info = UnknownMessage(id = messageId, sessionID = part.sessionID ?: ""),
286 parts = newParts,
317 parts = newParts,
319+ // Record that this part streamed in, so a re-seed after a reconnect knows it's
320+ // newer than the REST snapshot and keeps it (see seed()/beginReseed()).
321+ streamedSincePivot.add(part.id)
288 if (current == null) evictOldMessages()
322 if (current == null) evictOldMessages()
289 return true
323 return true