feat(chat): desktop-parity session resume — warm cache + bounded retry (#766)
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 <hy4ri@users.noreply.github.com>
This commit is contained in:
1 parent
982f8aec4e
commit
5e1ee0b4e5
5 files changed
+498
-6
No files matched your search
@@ -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.
|
||||
|
||||
@@ -122,6 +122,9 @@ data class ChatUiState(
|
||||
val subagentIndicators: List<SubagentIndicator> = emptyList(),
|
||||
/** Agent todo / plan items (issue #736). */
|
||||
val todos: List<TodoItem> = 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> = _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
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -119,6 +119,7 @@
|
||||
<string name="chat_empty_title">Ready to chat</string>
|
||||
<string name="chat_empty_subtitle">Send a message to start a conversation</string>
|
||||
<string name="chat_status_connecting">Connecting to Hermes\u2026</string>
|
||||
<string name="chat_resume_error_title">Couldn\'t connect to this session</string>
|
||||
<string name="chat_status_reconnecting">Reconnecting\u2026</string>
|
||||
<string name="chat_status_disconnected">Disconnected</string>
|
||||
<string name="chat_status_no_network">No network</string>
|
||||
|
||||
@@ -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<Pair<String, String>> {
|
||||
val captured = mutableListOf<Pair<String, String>>()
|
||||
every { HermesWsClient.send(any(), any(), any()) } answers {
|
||||
reqCount++
|
||||
val id = "req-id-$reqCount"
|
||||
captured.add(arg<String>(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 {
|
||||
|
||||
Reference in new issue
Block a user