From 5e1ee0b4e5ef7b1df7de2207638bc3198482a936 Mon Sep 17 00:00:00 2001 From: M57 <41645758+Hy4ri@users.noreply.github.com> Date: Sun, 2 Aug 2026 00:04:23 +0300 Subject: [PATCH] =?UTF-8?q?feat(chat):=20desktop-parity=20session=20resume?= =?UTF-8?q?=20=E2=80=94=20warm=20cache=20+=20bounded=20retry=20(#766)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bring the desktop's session reconnection UX to mobile: - Warm-cache fast-path: switchSession paints the cached Room transcript immediately instead of blanking the screen, then refreshes from the server in parallel (stale cache can't clobber a fresher server page) - Bounded auto-retry: a failed session.resume (WS reject or REST transcript failure) retries with exponential backoff 1s->2s->4s->8s, capped at 4 attempts; WS + REST failures in the same cycle count as one - Explicit error recovery: after exhaustion the chat shows an error card with a manual Retry instead of latching the spinner forever; manual retry resets the backoff cycle - Reconnect-aware resume: gateway (re)connect clears stale error state and re-resumes the current session on the new socket - Unit tests: warm-cache paint, bounded retry exhaustion, WS+REST dedupe, manual retry reset, reconnect restart Co-authored-by: M57 --- .../m57/hermescontrol/ui/chat/ChatScreen.kt | 10 +- .../hermescontrol/ui/chat/ChatViewModel.kt | 185 ++++++++++++++- .../chat/components/ChatResumeErrorOverlay.kt | 91 ++++++++ app/src/main/res/values/strings.xml | 1 + .../ui/chat/ChatViewModelTest.kt | 217 ++++++++++++++++++ 5 files changed, 498 insertions(+), 6 deletions(-) create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatResumeErrorOverlay.kt diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt index e9839df4..f50b3fac 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt @@ -78,6 +78,7 @@ import com.m57.hermescontrol.ui.chat.components.ChatInputBar import com.m57.hermescontrol.ui.chat.components.ChatLifecycleEffects import com.m57.hermescontrol.ui.chat.components.ChatLoadingOverlay import com.m57.hermescontrol.ui.chat.components.ChatMessageList +import com.m57.hermescontrol.ui.chat.components.ChatResumeErrorOverlay import com.m57.hermescontrol.ui.chat.components.ChatScrollToBottomFab import com.m57.hermescontrol.ui.chat.components.ContextDetailSheet import com.m57.hermescontrol.ui.chat.components.ContextUsageChip @@ -517,7 +518,14 @@ fun ChatScreen( ) // Loading overlay - ChatLoadingOverlay(isLoading = state.isLoading) + ChatLoadingOverlay(isLoading = state.isLoading && state.resumeError == null) + + // Resume-exhausted overlay — explicit error + Retry instead + // of an infinite spinner (desktop parity). + ChatResumeErrorOverlay( + errorMessage = state.resumeError, + onRetry = viewModel::retryResumeSession, + ) // Scroll-to-bottom FAB (issue #682): shows while follow is // paused and renders the unseen-message badge. diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt index dd7074f1..a9b879a0 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt @@ -122,6 +122,9 @@ data class ChatUiState( val subagentIndicators: List = emptyList(), /** Agent todo / plan items (issue #736). */ val todos: List = emptyList(), + // Session resume recovery (desktop parity: bounded auto-retry + error UI) + val resumeError: String? = null, + val isResumeRetrying: Boolean = false, ) { /** Convenience — derived from [connectionStatus]. */ val isConnected: Boolean get() = connectionStatus == ConnectionStatus.CONNECTED @@ -205,6 +208,15 @@ class ChatViewModel( private var runtimeSessionId: String? = null private var loadedMessageOffset = 0 private var isSyncingMessages = false + + // ── Session resume recovery (desktop parity) ──────────────────────── + // Bounded auto-retry with exponential backoff, mirroring the desktop's + // use-route-resume: a failed session.resume retries 1s→2s→4s→8s up to + // MAX_RESUME_RETRIES, then surfaces an explicit error + manual Retry + // instead of latching the spinner forever. + private var resumeRetrySessionId: String? = null + private var resumeRetryAttempt = 0 + private var resumeRetryJob: Job? = null val streamingState: StateFlow = _streamingState.asStateFlow() /** Tracks the auto-clear coroutine for reaction animations. */ @@ -354,7 +366,12 @@ class ChatViewModel( // ── WS Event Handling ──────────────────────────────────────────────── private fun handleGatewayReady() { - _uiState.update { it.copy(isLoading = false) } + // A (re)connect is a fresh start: clear any stale resume error and + // cancel a pending retry — the re-resume below rebinds the session + // on the new socket (desktop parity: gatewayBecameOpen re-resumes + // even when the route looks already active). + _uiState.update { it.copy(isLoading = false, resumeError = null, isResumeRetrying = false) } + cancelResumeRetry() addSystemMessage("Connected to Hermes") loadSessions() fetchCommandCatalog() @@ -682,9 +699,16 @@ class ChatViewModel( // the WS round-trip. Calling loadCachedMessages() here would // overwrite any message the user sent between switchSession() and // the server ack, making the chat appear to go blank. + // A successful resume also retires any pending retry job and + // clears the retry indicators (a reconnect re-resume landing + // while a backoff timer was armed must not double-fire). + resumeRetryJob?.cancel() + resumeRetryJob = null _uiState.update { it.copy( isLoading = false, + isResumeRetrying = false, + resumeError = null, currentSessionId = sessionId, currentSessionModel = if (model != null && provider != null) { @@ -787,6 +811,19 @@ class ChatViewModel( else -> error.toString() } + // Session resume failures go through the bounded retry (desktop + // parity) instead of a one-shot snackbar — the session may be + // mid-flush on the gateway or the WS may have just rebound, and a + // brief backoff usually clears it. Persistent failure ends in the + // explicit error + Retry state. + if (method == WsMethods.SESSION_RESUME) { + val sessionId = _uiState.value.currentSessionId + if (sessionId != null) { + handleResumeFailure(sessionId, errorMsg) + } + return + } + // Surface error in UI (these are server-pushed RpcError for // fire-and-forget RPCs — awaited RPCs handle their own failure // via the HermesWsClient.request() deferred). @@ -1452,7 +1489,9 @@ class ChatViewModel( fun switchSession(sessionId: String) { if (sessionId == _uiState.value.currentSessionId) return - // Reset streaming and pagination state before resuming the Desktop session. + // Reset streaming, pagination, and any pending resume retry before + // resuming the Desktop session. + cancelResumeRetry() runtimeSessionId = null loadedMessageOffset = 0 streamingController.resetStreaming() @@ -1471,12 +1510,19 @@ class ChatViewModel( fullContextTokens = null, contextBreakdown = null, compressionCount = null, + resumeError = null, + isResumeRetrying = false, ) } // Mirror the active session id app-wide (issue #532). ActiveSessionHolder.set(sessionId) _streamingState.update { StreamingState() } viewModelScope.launch { + // Warm-cache fast-path (desktop parity): paint the cached Room + // transcript immediately so the screen never sits blank, then load + // the fresh server transcript in parallel. If the cache is empty + // the spinner stays up until the server page lands. + loadCachedMessages(sessionId) // Resume the selected desktop session, then load its complete transcript. launch(Dispatchers.IO) { wsClient.send( @@ -1494,8 +1540,10 @@ class ChatViewModel( viewModelScope.launch(Dispatchers.IO) { val cachedMessages = repo.loadMessages(sessionId) _uiState.update { state -> - // Only replace if still showing this session - if (state.currentSessionId == sessionId) { + // Only paint if still showing this session AND no fresher server + // page has landed yet (a fast REST fetch must not be clobbered + // by a stale cache read that loses the race). + if (state.currentSessionId == sessionId && state.messages.isEmpty()) { state.copy(messages = cachedMessages, isLoading = false) } else { state @@ -1533,14 +1581,133 @@ class ChatViewModel( it.copy( isLoading = false, isLoadingOlder = false, - errorMessage = "Failed to load messages: ${result.error.message}", ) } + // Route the transcript failure through the bounded resume + // retry (desktop parity) instead of a one-shot snackbar — + // a transient backend/network blip recovers on its own, + // and a persistent failure ends in an explicit Retry. + handleResumeFailure(sessionId, "Failed to load messages: ${result.error.message}") } } } } + // ── Session resume recovery (desktop parity) ───────────────────────── + + private fun cancelResumeRetry() { + resumeRetryJob?.cancel() + resumeRetryJob = null + resumeRetrySessionId = null + resumeRetryAttempt = 0 + } + + private fun resumeRetryDelayMs(attempt: Int): Long = + minOf(RESUME_RETRY_MAX_MS, RESUME_RETRY_BASE_MS * (1L shl attempt)) + + /** + * Bounded auto-retry for a failed session resume (mirrors the desktop's + * use-route-resume). A failed resume — gateway RPC reject or REST + * transcript failure — retries with exponential backoff (1s→2s→4s→8s), + * capped at [MAX_RESUME_RETRIES]. After exhaustion the UI gets an + * explicit error + manual Retry ([retryResumeSession]) instead of an + * infinite spinner. + */ + private fun handleResumeFailure( + sessionId: String, + errorMessage: String, + ) { + // Only handle if still on this session. + if (_uiState.value.currentSessionId != sessionId) return + + // New session → reset the counter for a fresh backoff cycle. + if (resumeRetrySessionId != sessionId) { + resumeRetrySessionId = sessionId + resumeRetryAttempt = 0 + } + + if (resumeRetryAttempt >= MAX_RESUME_RETRIES) { + // Exhausted — surface the error + manual Retry affordance. + _uiState.update { + it.copy( + isLoading = false, + isResumeRetrying = false, + resumeError = errorMessage, + ) + } + return + } + + // A WS RPC reject and the REST transcript failure for the same resume + // land close together — treat them as ONE failure: if a retry is + // already armed for this session, don't double-count or re-schedule. + if (resumeRetrySessionId == sessionId && resumeRetryJob?.isActive == true) { + return + } + + val delayMs = resumeRetryDelayMs(resumeRetryAttempt) + resumeRetryAttempt++ + + _uiState.update { + it.copy( + isLoading = false, + isResumeRetrying = true, + resumeError = null, + ) + } + + resumeRetryJob?.cancel() + resumeRetryJob = + viewModelScope.launch { + delay(delayMs) + // Re-check liveness at fire time: the user may have switched + // sessions or the gateway may have reconnected meanwhile. + if (_uiState.value.currentSessionId != sessionId) return@launch + + _uiState.update { it.copy(isResumeRetrying = false) } + if (_uiState.value.messages.isEmpty()) { + _uiState.update { it.copy(isLoading = true) } + } + // Retry the full resume: rebind the runtime via WS + refresh + // the transcript via REST. Both are idempotent. + launch(Dispatchers.IO) { + wsClient.send( + WsMethods.SESSION_RESUME, + mapOf("session_id" to sessionId), + onSent = { id -> trackRequest(id, WsMethods.SESSION_RESUME) }, + ) + } + loadSessionMessages(sessionId) + } + } + + /** + * Manual retry after the bounded auto-retry exhausted. Clears the + * exhausted latch and starts a fresh backoff cycle (mirrors the desktop's + * resumeSession: reconnect / reselect / Retry all reset the counter). + */ + fun retryResumeSession() { + val sessionId = _uiState.value.currentSessionId ?: return + cancelResumeRetry() + _uiState.update { + it.copy( + resumeError = null, + isResumeRetrying = false, + isLoading = true, + ) + } + viewModelScope.launch { + launch(Dispatchers.IO) { + wsClient.send( + WsMethods.SESSION_RESUME, + mapOf("session_id" to sessionId), + onSent = { id -> trackRequest(id, WsMethods.SESSION_RESUME) }, + ) + } + loadSessionMessages(sessionId) + } + } + fun loadOlderMessages() { val state = _uiState.value val sessionId = state.currentSessionId ?: return @@ -2505,5 +2672,13 @@ class ChatViewModel( } companion object { + /** Max auto-retry attempts for a failed session.resume (desktop parity). */ + const val MAX_RESUME_RETRIES = 4 + + /** Base backoff for resume retries — doubles per attempt, capped at 8s. */ + const val RESUME_RETRY_BASE_MS = 1_000L + + /** Upper bound for the resume retry backoff delay. */ + const val RESUME_RETRY_MAX_MS = 8_000L } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatResumeErrorOverlay.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatResumeErrorOverlay.kt new file mode 100644 index 00000000..9b6c387a --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatResumeErrorOverlay.kt @@ -0,0 +1,91 @@ +package com.m57.hermescontrol.ui.chat.components + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.m57.hermescontrol.R + +/** + * Full-screen overlay shown when a session resume has exhausted its bounded + * auto-retries (desktop parity). Gives the user an explicit error + manual + * Retry instead of an infinite spinner. + */ +@Composable +fun ChatResumeErrorOverlay( + errorMessage: String?, + onRetry: () -> Unit, +) { + AnimatedVisibility( + visible = errorMessage != null, + enter = fadeIn(), + exit = fadeOut(), + modifier = Modifier.fillMaxSize(), + ) { + Box( + modifier = + Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background.copy(alpha = 0.7f)), + contentAlignment = Alignment.Center, + ) { + Card( + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface, + ), + elevation = CardDefaults.cardElevation(4.dp), + ) { + Column( + modifier = Modifier.padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + imageVector = Icons.Filled.Warning, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + modifier = Modifier.size(40.dp), + ) + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = stringResource(R.string.chat_resume_error_title), + style = MaterialTheme.typography.titleMedium, + textAlign = TextAlign.Center, + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = errorMessage.orEmpty(), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + Spacer(modifier = Modifier.height(16.dp)) + Button(onClick = onRetry) { + Text(stringResource(R.string.action_retry)) + } + } + } + } + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f028a225..ed0e36aa 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -119,6 +119,7 @@ Ready to chat Send a message to start a conversation Connecting to Hermes\u2026 + Couldn\'t connect to this session Reconnecting\u2026 Disconnected No network diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt index e073cefe..6ff06cf2 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt @@ -39,6 +39,7 @@ import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain import kotlinx.serialization.json.JsonPrimitive @@ -1163,6 +1164,222 @@ class ChatViewModelTest { verify { HermesWsClient.send(WsMethods.SESSION_RESUME, mapOf("session_id" to "session-456"), any()) } } + // ── Session resume recovery (desktop parity: warm cache + bounded retry) ── + + /** Override the send stub to capture (method → id) pairs. */ + private fun captureSends(): MutableList> { + val captured = mutableListOf>() + every { HermesWsClient.send(any(), any(), any()) } answers { + reqCount++ + val id = "req-id-$reqCount" + captured.add(arg(0) to id) + arg<((String) -> Unit)?>(2)?.invoke(id) + id + } + return captured + } + + private fun stubSession456Rests(success: Boolean) { + val mockApi = ApiClient.hermesApi + // mapServerMessages reads AuthManager.getBaseUrl() on the SUCCESS path + // before mapping any messages. mockkObject is spy-semantics: unstubbed + // calls fall through to the REAL AuthManager, whose serverStore is null + // unless another test class happened to init it earlier in the JVM + // (the order-dependent flakiness). Stub it for determinism — same + // pattern as E2eIntegrationTest / ApiClientTest. + every { AuthManager.getBaseUrl() } returns "http://test.local/" + if (success) { + coEvery { + mockApi.getSessions(any(), any(), any()) + } returns + retrofit2.Response.success( + com.m57.hermescontrol.data.model.SessionListResponse( + sessions = + listOf( + com.m57.hermescontrol.data.model.SessionInfo( + id = "session-456", + title = "Test", + message_count = 0, + ), + ), + total = 1, + ), + ) + coEvery { + mockApi.getSessionMessages("session-456", any(), any(), any()) + } returns + retrofit2.Response.success( + com.m57.hermescontrol.data.model.SessionMessagesResponse( + messages = emptyList(), + ), + ) + } else { + // Relaxed mock returns a non-success response → NetworkResult.Failure. + coEvery { + mockApi.getSessionMessages("session-456", any(), any(), any()) + } returns + retrofit2.Response.error( + 500, + okhttp3.ResponseBody.create(null, "{\"detail\":\"boom\"}"), + ) + } + } + + @Test + fun testSwitchSession_paintsWarmCache_andKeepsItWhenResumeExhausted() = + runTest { + // Seed the Room cache for the target session. + fakeRepo.dao.addMessageDirect( + com.m57.hermescontrol.data.local.ChatMessageEntity( + id = "cached-1", + sessionId = "session-456", + role = "user", + content = "Cached hello", + timestamp = 1L, + ), + ) + // Server keeps failing → bounded retries exhaust → resumeError. + stubSession456Rests(success = false) + + val (viewModel, _) = createViewModelWithSession() + + viewModel.switchSession("session-456") + advanceUntilIdle() + + // Warm cache painted and survived the exhausted retry cycle — the + // screen never went blank, and the user gets history + an error. + assertEquals(1, viewModel.uiState.value.messages.size) + assertEquals("Cached hello", viewModel.uiState.value.messages[0].content) + assertFalse(viewModel.uiState.value.isLoading) + assertNotNull("resumeError must be set after retries exhaust", viewModel.uiState.value.resumeError) + assertFalse(viewModel.uiState.value.isResumeRetrying) + } + + @Test + fun testSessionResume_boundedRetry_thenExplicitError() = + runTest { + stubSession456Rests(success = true) + val (viewModel, _) = createViewModelWithSession() + val captured = captureSends() + + viewModel.switchSession("session-456") + advanceUntilIdle() + + // Drive one RPC failure per cycle; each failure arms a backoff retry. + // Exhaustion needs MAX+1 failures: attempts 0..MAX-1 arm retries, + // the MAX-th failure hits the exhausted latch. + for (cycle in 1..(ChatViewModel.MAX_RESUME_RETRIES + 1)) { + val resumeId = captured.last { it.first == WsMethods.SESSION_RESUME }.second + mockEventsFlow.emit( + WsEvent.RpcError( + resumeId, + JsonRpcError(code = -32000, message = "resume rejected"), + ), + ) + advanceUntilIdle() + } + + val resumeSends = captured.count { it.first == WsMethods.SESSION_RESUME } + assertEquals( + "initial + MAX_RESUME_RETRIES retries", + ChatViewModel.MAX_RESUME_RETRIES + 1, + resumeSends, + ) + assertNotNull(viewModel.uiState.value.resumeError) + assertFalse(viewModel.uiState.value.isResumeRetrying) + assertFalse(viewModel.uiState.value.isLoading) + } + + @Test + fun testSessionResume_rpcAndRestFailuresCountAsOneCycle() = + runTest { + // REST fails + one RpcError lands while the retry is already armed — + // they must count as ONE failure, so the retry budget is not + // double-burned (5 sends total, not 4). + stubSession456Rests(success = false) + val (viewModel, _) = createViewModelWithSession() + val captured = captureSends() + + viewModel.switchSession("session-456") + runCurrent() + // First cycle: REST already failed and armed the retry; now the WS + // reject for the same resume arrives while that job is pending. + val resumeId = captured.last { it.first == WsMethods.SESSION_RESUME }.second + mockEventsFlow.emit( + WsEvent.RpcError( + resumeId, + JsonRpcError(code = -32000, message = "resume rejected"), + ), + ) + runCurrent() + advanceUntilIdle() + + val resumeSends = captured.count { it.first == WsMethods.SESSION_RESUME } + assertEquals( + "initial + MAX_RESUME_RETRIES retries — no double-burn", + ChatViewModel.MAX_RESUME_RETRIES + 1, + resumeSends, + ) + assertNotNull(viewModel.uiState.value.resumeError) + } + + @Test + fun testRetryResumeSession_clearsErrorAndResends() = + runTest { + stubSession456Rests(success = true) + val (viewModel, _) = createViewModelWithSession() + val captured = captureSends() + + viewModel.switchSession("session-456") + advanceUntilIdle() + + // Exhaust the retry budget (MAX+1 failures — see boundedRetry test). + for (cycle in 1..(ChatViewModel.MAX_RESUME_RETRIES + 1)) { + val resumeId = captured.last { it.first == WsMethods.SESSION_RESUME }.second + mockEventsFlow.emit( + WsEvent.RpcError( + resumeId, + JsonRpcError(code = -32000, message = "resume rejected"), + ), + ) + advanceUntilIdle() + } + assertNotNull(viewModel.uiState.value.resumeError) + + // Manual retry clears the exhausted latch and dispatches a fresh resume. + viewModel.retryResumeSession() + advanceUntilIdle() + + assertNull(viewModel.uiState.value.resumeError) + assertFalse(viewModel.uiState.value.isResumeRetrying) + val resumeSends = captured.count { it.first == WsMethods.SESSION_RESUME } + assertEquals(ChatViewModel.MAX_RESUME_RETRIES + 2, resumeSends) + } + + @Test + fun testGatewayReconnect_clearsResumeErrorAndRestartsCycle() = + runTest { + stubSession456Rests(success = false) + val (viewModel, _) = createViewModelWithSession() + + viewModel.switchSession("session-456") + advanceUntilIdle() + assertNotNull("resumeError must be set after retries exhaust", viewModel.uiState.value.resumeError) + + // Reconnect is a fresh start: the error latch is cleared and the + // current session is re-resumed on the new socket. + mockConnectionStatus.value = ConnectionStatus.CONNECTED + mockEventsFlow.emit(WsEvent.GatewayReady(null)) + runCurrent() + + assertNull(viewModel.uiState.value.resumeError) + assertFalse(viewModel.uiState.value.isResumeRetrying) + + // The re-resume's own failures start a NEW bounded cycle. + advanceUntilIdle() + assertNotNull(viewModel.uiState.value.resumeError) + } + @Test fun testInterruptSession_withSessionId_sendsRpc() = runTest {