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:
M57andM57 authored and GitHub committed 2026-08-02 00:04:23 +03:00
1 parent 982f8aec4e
commit 5e1ee0b4e5
5 files changed
+498 -6

No files matched your search

@@ -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 {