git.iko.soy/opencode-android
on master help sign in
Fix StepFinishPart pinning run indicator, stale deep-link on deleted profile, null-session removal leak, and image auth divergence
commitdb959e19b3532442280b7bf0fd1583b645190117 authoriko <mail@iko.soy> dateMon 6 Jul 2026, 04:48 (3 months ago) parent2fe31d0
3 files changed+52−15
app/src/main/java/soy/iko/opencode/data/repo/SessionRepository.kt+35−12 app/src/main/java/soy/iko/opencode/ui/OpencodeApp.kt+11−1 app/src/main/java/soy/iko/opencode/ui/components/RemoteImage.kt+6−2
app/src/main/java/soy/iko/opencode/data/repo/SessionRepository.kt+35−12
@@ -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) }
app/src/main/java/soy/iko/opencode/ui/OpencodeApp.kt+11−1
@@ -285,7 +285,17 @@ fun OpencodeApp(container: AppContainer) {
285285 // whichever is active. The effect re-runs when connection changes (keyed on it),
286286 // so on the second run the profile matches and the open proceeds.
287287 if (pending.profileId != null && connection?.profile?.id != pending.profileId) {
288− if (!container.connectByProfileId(pending.profileId)) return@LaunchedEffect
288+ // If the originating profile no longer exists (e.g. deleted after the
289+ // notification was queued), connectByProfileId returns false: the active
290+ // connection is unchanged and none of this effect's keys change, so it
291+ // would never re-run — the request would sit in _pendingOpenSession
292+ // forever and re-trigger on every later connection change. Consume it so
293+ // the stale request is dropped, mirroring the invalid-deep-link path in
294+ // MainActivity.
295+ if (!container.connectByProfileId(pending.profileId)) {
296+ container.consumePendingOpenSession()
297+ return@LaunchedEffect
298+ }
289299 return@LaunchedEffect // re-run on the new connection
290300 }
291301 // In two-pane mode the detail pane (TwoPaneSessionChat, hosted on the SESSIONS
app/src/main/java/soy/iko/opencode/ui/components/RemoteImage.kt+6−2
@@ -82,8 +82,12 @@ data class ImageLoadContext(
8282
8383/** Build an [ImageLoadContext] from a resolved connection profile (password included). */
8484fun ServerProfile.toImageContext(): ImageLoadContext {
85− val auth = if (hasAuth && !password.isNullOrEmpty()) {
86− val raw = "$username:$password".toByteArray()
85+ // Mirror HttpClientFactory's auth condition (hasAuth, i.e. a non-blank username): a
86+ // profile configured with a username but an empty password is still authenticated by the
87+ // REST/SSE channel (password defaults to the empty string there), so image loads must
88+ // attach the same Basic header or they'd 401 while every other request succeeds.
89+ val auth = if (hasAuth) {
90+ val raw = "$username:${password.orEmpty()}".toByteArray()
8791 "Basic " + Base64.encodeToString(raw, Base64.NO_WRAP)
8892 } else {
8993 null