@@ -13,6 +13,7 @@ import soy.iko.opencode.data.model.ModelRef
1313import soy.iko.opencode.data.model.Part
1414import soy.iko.opencode.data.model.SessionError
1515import soy.iko.opencode.data.model.SessionIdle
16+import soy.iko.opencode.data.model.StepFinishPart
1617import soy.iko.opencode.data.model.UnknownMessage
1718import soy.iko.opencode.data.network.EventStreamClient
1819import soy.iko.opencode.data.network.NetworkConfig
@@ -329,7 +330,17 @@ open class SessionRepository(
329330 // The session id rides on the part for message.part.updated; properties.sessionID
330331 // is null on the wire. Mirror reduce()'s `part.sessionID ?: properties.sessionID`
331332 // so a run that's only streaming parts still re-lights the indicator on reconnect.
332− is MessagePartUpdated -> (event.properties.part.sessionID ?: event.properties.sessionID) == sessionId
333+ // A step-finish part is the trailing completion marker for a run (it carries the
334+ // final cost/token totals) and arrives after the last stream delta. Treating it as
335+ // live activity would re-light the running indicator after a reconnect when the run
336+ // already finished during the outage — with no following SessionIdle to clear it,
337+ // pinning the spinner (and the foreground service) indefinitely. Mirrors
338+ // AppContainer.isLiveRunActivity's exclusion.
339+ is MessagePartUpdated -> {
340+ val part = event.properties.part
341+ part !is StepFinishPart &&
342+ (part.sessionID ?: event.properties.sessionID) == sessionId
343+ }
333344 is MessageUpdated -> {
334345 val info = event.properties.info
335346 info.sessionID == sessionId && info is AssistantMessage && !info.isComplete && info.error == null
@@ -404,11 +415,15 @@ internal class MessageStore {
404415
405416 fun seed(initial: List<MessageWithParts>, prune: Boolean = false, clearPivot: Boolean = true): Boolean {
406417 var changed = false
407− // On re-seed (after SSE reconnect), remove messages that are no longer in the
408− // server snapshot (e.g. deleted during the disconnection gap). This keeps the
409− // in-memory state in sync with the authoritative REST snapshot rather than
410− // accumulating stale messages forever. Skipped on the initial seed to avoid
411− // racing with just-arrived SSE events whose messages may not yet be in REST.
418+ // seed() does NOT prune by default. Pruning is intentionally NOT wired into the
419+ // reconnect re-seed (the only path that would want it): the live SSE stream — via
420+ // MessageRemoved events — drives deletions, and pruneStaleCacheSeeded() handles
421+ // ghosts from the on-disk cache on restart. Pruning on re-seed would race with
422+ // just-arrived SSE events whose messages may not yet be indexed by REST, dropping
423+ // live messages. The `prune = true` branch below is retained as a tested capability
424+ // for callers that can guarantee no concurrent SSE (e.g. a future explicit
425+ // "discard and reload from REST" entry point); the current production re-seed
426+ // callsite passes `prune = false` for the race reason above.
412427 if (prune) {
413428 // Build the retain-set from the synthetic key (matching the map's keying for
414429 // UnknownMessages with empty ids) so an entry stored under "unknown-c<created>"
@@ -596,20 +611,28 @@ internal class MessageStore {
596611 return true
597612 }
598613
599− /** Returns false if [eventSession] is non-null and doesn't match [sessionId]. */
600− private fun matchesSession(eventSession: String?, sessionId: String): Boolean =
601− eventSession == null || eventSession == sessionId
602−
603614 private fun handlePartRemoved(sessionId: String, event: MessagePartRemoved): Boolean {
604615 val messageId = event.properties.messageID ?: return false
605616 val partId = event.properties.partID ?: return false
606− if (!matchesSession(event.properties.sessionID, sessionId)) return false
617+ val eventSession = event.properties.sessionID
618+ when {
619+ eventSession != null && eventSession != sessionId -> return false
620+ // Null session id: only act if this store already holds the parent message, so a
621+ // null-session part removal can't drop a part from an unrelated session's store.
622+ eventSession == null && !messages.containsKey(messageId) -> return false
623+ }
607624 return removePart(messageId, partId)
608625 }
609626
610627 private fun handleMessageRemoved(sessionId: String, event: MessageRemoved): Boolean {
611628 val id = event.properties.messageID ?: return false
612− if (!matchesSession(event.properties.sessionID, sessionId)) return false
629+ val eventSession = event.properties.sessionID
630+ when {
631+ eventSession != null && eventSession != sessionId -> return false
632+ // Null session id: only act if this store already holds the message (message ids
633+ // are globally unique, so this attributes the removal to the owning session only).
634+ eventSession == null && !messages.containsKey(id) -> return false
635+ }
613636 val removed = messages.remove(id) ?: return false
614637 messageInfoUpdatedSincePivot.remove(id)
615638 removed.parts.keys.forEach { streamedSincePivot.remove(it) }