git.iko.soy/opencode-android
on master help sign in
Fix 4 bugs: optimistic dedup clears all duplicates, retryWhen lacks jitter, pre-stream error notifications dropped, TTS chunk index breaks on '#' in message id
2fe31d0iko
app/src/main/java/soy/iko/opencode/di/AppContainer.kt+11−5
@@ -738,14 +738,20 @@ open class AppContainer private constructor(
738 }
738 }
739 // A run failed. Clear its active-run tracking (an error isn't always
739 // A run failed. Clear its active-run tracking (an error isn't always
740 // followed by a SessionIdle) and notify unless it's being viewed.
740 // followed by a SessionIdle) and notify unless it's being viewed.
741+ // The notification is NOT gated on wasRunning: activeRuns is populated
742+ // only by SSE streaming events (MessagePartUpdated/MessageUpdated), so a
743+ // run that errors before any streaming started (e.g. the model is
744+ // unavailable, the server fails to create the assistant message) never
745+ // entered the set and wasRunning would be false — silently dropping the
746+ // error notification for a backgrounded user. SessionError itself is the
747+ // server's signal that a run failed, so notify on any error for a session
748+ // the user isn't actively viewing.
741 if (event is SessionError) {
749 if (event is SessionError) {
742 val esid = event.properties.sessionID ?: return@collect
750 val esid = event.properties.sessionID ?: return@collect
743− val wasRunning = synchronized(activeRuns) {
751+ synchronized(activeRuns) {
744− activeRuns.remove(esid).also { removed ->
752+ if (activeRuns.remove(esid)) publishRunState()
745− if (removed) publishRunState()
746− }
747 }
753 }
748− if (wasRunning && !isActivelyViewing(esid)) {
754+ if (!isActivelyViewing(esid)) {
749 appScope.launch { notifySessionError(esid, conn.repository, conn.profile.id) }
755 appScope.launch { notifySessionError(esid, conn.repository, conn.profile.id) }
750 }
756 }
751 return@collect
757 return@collect
app/src/main/java/soy/iko/opencode/ui/chat/ChatViewModel.kt+40−7
@@ -234,9 +234,16 @@ class ChatViewModel(
234 // Exponential backoff capped at retryMaxDelayMs so a persistently failing
234 // Exponential backoff capped at retryMaxDelayMs so a persistently failing
235 // server is retried with growing delays (retryInitialDelayMs, then doubling)
235 // server is retried with growing delays (retryInitialDelayMs, then doubling)
236 // instead of hammered at a fixed interval forever. Clamp the shift so a long
236 // instead of hammered at a fixed interval forever. Clamp the shift so a long
237− // failure streak can't overflow the Long delay.
237+ // failure streak can't overflow the Long delay. Apply symmetric jitter
238+ // (matching withRetry's jitteredBackoff) so concurrent retrying flows (e.g.
239+ // messages + catalogs after a server blip) don't retry in lockstep and
240+ // thundering-herd the recovering server.
238− val backoffMs = (NetworkConfig.retryInitialDelayMs shl attempt.coerceAtMost(16L).toInt())
241+ val shift = attempt.coerceAtMost(16L).toInt()
242+ val baseDelay = (NetworkConfig.retryInitialDelayMs shl shift)
239 .coerceAtMost(NetworkConfig.retryMaxDelayMs)
243 .coerceAtMost(NetworkConfig.retryMaxDelayMs)
244+ val jitter = ((baseDelay * NetworkConfig.retryJitterFactor) *
245+ (kotlin.random.Random.nextDouble() * 2 - 1)).toLong()
246+ val backoffMs = (baseDelay + jitter).coerceAtLeast(0)
240 delay(backoffMs)
247 delay(backoffMs)
241 true
248 true
242 }
249 }
@@ -275,7 +282,11 @@ class ChatViewModel(
275 /** Merge SSE-driven messages with optimistic entries. An optimistic entry whose trimmed
282 /** Merge SSE-driven messages with optimistic entries. An optimistic entry whose trimmed
276 * text matches a real [UserMessage] in [sse] is dropped (the server echoed it back).
283 * text matches a real [UserMessage] in [sse] is dropped (the server echoed it back).
277 * Remaining entries are appended as synthetic [UserMessage]s at the end. Failed entries
284 * Remaining entries are appended as synthetic [UserMessage]s at the end. Failed entries
278− * are kept so the user sees the failed indicator until they retry or dismiss. */
285+ * are kept so the user sees the failed indicator until they retry or dismiss.
286+ *
287+ * Only the OLDEST matching entry per real text is dropped, so duplicate prompts (the same
288+ * text sent twice) each match their own server-echoed UserMessage instead of both
289+ * vanishing on the first echo. */
279 private fun mergeOptimistic(
290 private fun mergeOptimistic(
280 sse: List<MessageWithParts>,
291 sse: List<MessageWithParts>,
281 optimistic: List<OptimisticEntry>,
292 optimistic: List<OptimisticEntry>,
@@ -290,13 +301,16 @@ class ChatViewModel(
290 .trim()
301 .trim()
291 if (text.isNotEmpty()) realUserTexts.add(text)
302 if (text.isNotEmpty()) realUserTexts.add(text)
292 }
303 }
293− val surviving = optimistic.filter { it.text.trim() !in realUserTexts }
304+ val surviving = dropOldestMatches(optimistic, realUserTexts)
294 if (surviving.isEmpty()) return sse
305 if (surviving.isEmpty()) return sse
295 return sse + surviving.map { it.toMessageWithParts() }
306 return sse + surviving.map { it.toMessageWithParts() }
296 }
307 }
297
308
298 /** Remove optimistic entries whose text now matches a real user message delivered by SSE.
309 /** Remove optimistic entries whose text now matches a real user message delivered by SSE.
299− * Called from the SSE flow's onEach so stale optimistic entries are cleaned up promptly. */
310+ * Called from the SSE flow's onEach so stale optimistic entries are cleaned up promptly.
311+ * Only the OLDEST matching entry per real text is removed, so duplicate prompts (the same
312+ * text sent twice) each match their own server-echoed UserMessage instead of both
313+ * vanishing on the first echo. */
300 private fun reconcileOptimistic(sse: List<MessageWithParts>) {
314 private fun reconcileOptimistic(sse: List<MessageWithParts>) {
301 if (_optimisticMessages.value.isEmpty()) return
315 if (_optimisticMessages.value.isEmpty()) return
302 val realUserTexts = mutableSetOf<String>()
316 val realUserTexts = mutableSetOf<String>()
@@ -308,9 +322,28 @@ class ChatViewModel(
308 if (text.isNotEmpty()) realUserTexts.add(text)
322 if (text.isNotEmpty()) realUserTexts.add(text)
309 }
323 }
310 if (realUserTexts.isEmpty()) return
324 if (realUserTexts.isEmpty()) return
311− _optimisticMessages.update { entries ->
325+ _optimisticMessages.update { entries -> dropOldestMatches(entries, realUserTexts) }
312− entries.filterNot { it.text.trim() in realUserTexts }
326+ }
327+
328+ /** Drop the first (oldest) entry whose trimmed text matches each real user text, once per
329+ * text. A second optimistic entry with the same text survives until a second matching
330+ * UserMessage arrives, so duplicate prompts aren't both cleared by the first echo. */
331+ private fun dropOldestMatches(
332+ entries: List<OptimisticEntry>,
333+ realUserTexts: Set<String>,
334+ ): List<OptimisticEntry> {
335+ if (entries.isEmpty() || realUserTexts.isEmpty()) return entries
336+ val remaining = realUserTexts.toMutableSet()
337+ val result = ArrayList<OptimisticEntry>(entries.size)
338+ for (entry in entries) {
339+ val text = entry.text.trim()
340+ if (text in remaining) {
341+ remaining.remove(text)
342+ } else {
343+ result.add(entry)
344+ }
313 }
345 }
346+ return result
314 }
347 }
315
348
316 /** Convert an optimistic entry to a [MessageWithParts] for display. */
349 /** Convert an optimistic entry to a [MessageWithParts] for display. */
app/src/main/java/soy/iko/opencode/ui/chat/TtsController.kt+6−1
@@ -83,7 +83,12 @@ class TtsController(context: Context) : RememberObserver {
83 override fun onStart(utteranceId: String?) {
83 override fun onStart(utteranceId: String?) {
84 // Advance the current-chunk pointer so pause() can capture the right resume
84 // Advance the current-chunk pointer so pause() can capture the right resume
85 // index. The tracked id is the final chunk; intermediate ids are "$id#$index".
85 // index. The tracked id is the final chunk; intermediate ids are "$id#$index".
86+ // Use substringAfterLast (not substringAfter) so a message id that itself
87+ // contains '#' is handled: the chunk index is always the part after the LAST
88+ // '#', and substringAfter would wrongly slice at the first one (returning a
89+ // non-integer and never advancing the pointer — pause/resume would replay
90+ // already-heard audio).
86− val idx = utteranceId?.substringAfter('#')?.toIntOrNull()
91+ val idx = utteranceId?.substringAfterLast('#')?.toIntOrNull()
87 if (idx != null) currentChunk = idx
92 if (idx != null) currentChunk = idx
88 }
93 }
89 override fun onDone(utteranceId: String?) {
94 override fun onDone(utteranceId: String?) {