fix(notification): resume stored session before reply (#805)

This commit is contained in:
AlexDev_ authored and GitHub committed 2026-08-04 19:54:49 +03:00
1 parent e1a04a9396
commit 356ccc98b5
13 files changed
+268 -39

No files matched your search

@@ -11,8 +11,6 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import com.m57.hermescontrol.data.local.AuthManager import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.notification.NotificationReplyReceiver import com.m57.hermescontrol.notification.NotificationReplyReceiver
@@ -20,9 +18,6 @@ import com.m57.hermescontrol.theme.HermesControlTheme
import com.m57.hermescontrol.util.LocaleContextWrapper import com.m57.hermescontrol.util.LocaleContextWrapper
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
// Observable state so both onCreate and onNewIntent updates flow to Compose
private var notificationSessionId by mutableStateOf<String?>(null)
/** /**
* Apply the user-selected display language before any view is inflated. * Apply the user-selected display language before any view is inflated.
* Reads the persisted code from [AuthManager]; an uninitialized store * Reads the persisted code from [AuthManager]; an uninitialized store
@@ -38,8 +33,7 @@ class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
// Read notification tap sessionId (if any) from the intent that launched us consumeNotificationIntent(intent)
notificationSessionId = intent?.getStringExtra(NotificationReplyReceiver.EXTRA_SESSION_ID)
enableEdgeToEdge() enableEdgeToEdge()
setContent { setContent {
@@ -55,7 +49,7 @@ class MainActivity : ComponentActivity() {
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background, color = MaterialTheme.colorScheme.background,
) { ) {
MainNavigation(sessionId = notificationSessionId) MainNavigation()
} }
} }
} }
@@ -63,10 +57,13 @@ class MainActivity : ComponentActivity() {
override fun onNewIntent(intent: Intent) { override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent) super.onNewIntent(intent)
// When a notification tap arrives while the app is already running setIntent(intent)
// (task exists in background), Android delivers the intent here instead consumeNotificationIntent(intent)
// of onCreate. Update the observable state so Compose recomposes }
// and ChatScreen's LaunchedEffect switches to the correct session.
notificationSessionId = intent.getStringExtra(NotificationReplyReceiver.EXTRA_SESSION_ID) private fun consumeNotificationIntent(intent: Intent?) {
val sessionId = intent?.getStringExtra(NotificationReplyReceiver.EXTRA_SESSION_ID)
intent?.removeExtra(NotificationReplyReceiver.EXTRA_SESSION_ID)
sessionId?.takeIf { it.isNotBlank() }?.let(NavigationController::openChatSession)
} }
} }
@@ -1,5 +1,8 @@
package com.m57.hermescontrol package com.m57.hermescontrol
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.navigation3.runtime.NavBackStack import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.NavKey import androidx.navigation3.runtime.NavKey
@@ -16,7 +19,8 @@ import androidx.navigation3.runtime.NavKey
*/ */
object NavigationController { object NavigationController {
var backStack: NavBackStack<NavKey>? = null var backStack: NavBackStack<NavKey>? = null
var pendingSessionId: String? = null var pendingSessionId: String? by mutableStateOf(null)
private set
// Top-level primary screens (Chat, Skills, Cron, System, Settings) // Top-level primary screens (Chat, Skills, Cron, System, Settings)
private val primaryScreens: MutableSet<NavKey> = private val primaryScreens: MutableSet<NavKey> =
@@ -45,6 +49,14 @@ object NavigationController {
stack.add(key) stack.add(key)
} }
fun openChatSession(sessionId: String) {
if (sessionId.isBlank()) return
pendingSessionId = sessionId
navigateTo(ChatScreen)
}
fun consumePendingSessionId(): String? = pendingSessionId.also { pendingSessionId = null }
/** Clear the stack and navigate to the given screen atomically. */ /** Clear the stack and navigate to the given screen atomically. */
fun resetTo(screen: NavKey) { fun resetTo(screen: NavKey) {
val stack = backStack ?: return val stack = backStack ?: return
@@ -16,6 +16,7 @@ import com.m57.hermescontrol.data.model.PinnedModel
import com.m57.hermescontrol.data.remote.CleartextPolicy import com.m57.hermescontrol.data.remote.CleartextPolicy
import com.m57.hermescontrol.data.remote.CookieManager import com.m57.hermescontrol.data.remote.CookieManager
import com.m57.hermescontrol.data.remote.ServerEndpoint import com.m57.hermescontrol.data.remote.ServerEndpoint
import com.m57.hermescontrol.data.session.ActiveSessionHolder
import com.m57.hermescontrol.theme.ThemePreference import com.m57.hermescontrol.theme.ThemePreference
import com.m57.hermescontrol.theme.ThemePreset import com.m57.hermescontrol.theme.ThemePreset
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@@ -310,6 +311,7 @@ object AuthManager {
) { ) {
requirePrefs().edit().putString("token_$profileId", token).apply() requirePrefs().edit().putString("token_$profileId", token).apply()
if (getSelectedProfileId() == profileId) { if (getSelectedProfileId() == profileId) {
if (token == null) ActiveSessionHolder.clear()
// B7 (Jul 08 2026, kanban t_470): sync in-memory cachedToken // B7 (Jul 08 2026, kanban t_470): sync in-memory cachedToken
// to prevent stale tokens during ticket refresh // to prevent stale tokens during ticket refresh
synchronized(this) { synchronized(this) {
@@ -326,6 +328,9 @@ object AuthManager {
} }
fun setSelectedProfileId(id: String?) { fun setSelectedProfileId(id: String?) {
if (getSelectedProfileId() != id?.takeIf { it.isNotBlank() }) {
ActiveSessionHolder.clear()
}
serverStore.update { it.copy(selectedProfileId = id) } serverStore.update { it.copy(selectedProfileId = id) }
synchronized(this) { synchronized(this) {
tokenInitialized = false tokenInitialized = false
@@ -17,12 +17,32 @@ import kotlinx.coroutines.flow.asStateFlow
*/ */
object ActiveSessionHolder { object ActiveSessionHolder {
private val _activeSessionId = MutableStateFlow<String?>(null) private val _activeSessionId = MutableStateFlow<String?>(null)
private var activeStoredSessionId: String? = null
/** The currently active chat session id, or null if none is known yet. */ /** The currently active chat session id, or null if none is known yet. */
val activeSessionId: StateFlow<String?> = _activeSessionId.asStateFlow() val activeSessionId: StateFlow<String?> = _activeSessionId.asStateFlow()
/** Update the active session id. Pass null to clear (e.g. on logout). */ /** Update the active session id. Pass null to clear (e.g. on logout). */
fun set(sessionId: String?) { @Synchronized
fun set(
sessionId: String?,
storedSessionId: String? = null,
) {
_activeSessionId.value = sessionId _activeSessionId.value = sessionId
activeStoredSessionId = storedSessionId
}
@Synchronized
fun resolveStoredSessionId(sessionId: String?): String? =
if (sessionId == _activeSessionId.value) activeStoredSessionId ?: sessionId else sessionId
@Synchronized
fun resolveRuntimeSessionId(sessionId: String): String? =
_activeSessionId.value.takeIf { activeStoredSessionId == sessionId }
@Synchronized
fun clear() {
_activeSessionId.value = null
activeStoredSessionId = null
} }
} }
@@ -7,6 +7,7 @@ import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.remote.DashboardSessionTokenRefresher import com.m57.hermescontrol.data.remote.DashboardSessionTokenRefresher
import com.m57.hermescontrol.data.remote.NetworkMonitor import com.m57.hermescontrol.data.remote.NetworkMonitor
import com.m57.hermescontrol.data.remote.OkHttpProvider import com.m57.hermescontrol.data.remote.OkHttpProvider
import com.m57.hermescontrol.data.session.ActiveSessionHolder
import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -452,6 +453,7 @@ object HermesWsClient {
/** Cleanly close the WebSocket and stop auto-reconnect. */ /** Cleanly close the WebSocket and stop auto-reconnect. */
fun disconnect(clearPendingMessages: Boolean = false) { fun disconnect(clearPendingMessages: Boolean = false) {
ActiveSessionHolder.clear()
synchronized(outboundLock) { synchronized(outboundLock) {
intentionalClose.set(true) intentionalClose.set(true)
acceptQueuedMessages.set(!clearPendingMessages) acceptQueuedMessages.set(!clearPendingMessages)
@@ -885,6 +887,7 @@ object HermesWsClient {
// reason is still inspected internally to detect auth failures. // reason is still inspected internally to detect auth failures.
Log.i(TAG, "WebSocket closed: $code") Log.i(TAG, "WebSocket closed: $code")
connected.set(false) connected.set(false)
ActiveSessionHolder.clear()
stopHealthTracking() stopHealthTracking()
if (code == 4001 || code == 4401 || if (code == 4001 || code == 4401 ||
reason.contains("unauthorized", ignoreCase = true) || reason.contains("unauthorized", ignoreCase = true) ||
@@ -915,6 +918,7 @@ object HermesWsClient {
// detection. // detection.
Log.e(TAG, "WebSocket failure: ${t.javaClass.simpleName}", t) Log.e(TAG, "WebSocket failure: ${t.javaClass.simpleName}", t)
connected.set(false) connected.set(false)
ActiveSessionHolder.clear()
stopHealthTracking() stopHealthTracking()
val code = response?.code ?: 0 val code = response?.code ?: 0
if (code == 401 || t.message?.contains( if (code == 401 || t.message?.contains(
@@ -14,6 +14,7 @@ import androidx.core.app.RemoteInput
import com.m57.hermescontrol.MainActivity import com.m57.hermescontrol.MainActivity
import com.m57.hermescontrol.R import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.local.AuthManager import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.session.ActiveSessionHolder
import com.m57.hermescontrol.data.ws.HermesWsClient import com.m57.hermescontrol.data.ws.HermesWsClient
import com.m57.hermescontrol.data.ws.WsEvent import com.m57.hermescontrol.data.ws.WsEvent
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@@ -98,7 +99,10 @@ class ChatNotificationService : Service() {
.take(100) .take(100)
.replace("\n", " ") .replace("\n", " ")
.ifBlank { getString(R.string.notif_new_message) } .ifBlank { getString(R.string.notif_new_message) }
showReplyNotification(preview, event.sessionId) showReplyNotification(
preview,
ActiveSessionHolder.resolveStoredSessionId(event.sessionId),
)
// The wait is over — retire the foreground // The wait is over — retire the foreground
// service. The reply notification above // service. The reply notification above
// replaces the persistent "waiting" one, // replaces the persistent "waiting" one,
@@ -8,7 +8,9 @@ import android.content.Intent
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.app.RemoteInput import androidx.core.app.RemoteInput
import com.m57.hermescontrol.R import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.session.ActiveSessionHolder
import com.m57.hermescontrol.data.ws.HermesWsClient import com.m57.hermescontrol.data.ws.HermesWsClient
import com.m57.hermescontrol.data.ws.WsMethods
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
@@ -20,6 +22,7 @@ open class NotificationReplyReceiver : BroadcastReceiver() {
companion object { companion object {
const val KEY_TEXT_REPLY = "key_text_reply" const val KEY_TEXT_REPLY = "key_text_reply"
const val EXTRA_SESSION_ID = "extra_session_id" const val EXTRA_SESSION_ID = "extra_session_id"
private const val REPLY_TIMEOUT_MS = 5_000L
} }
// Reusable scope for async reply processing — avoids creating a new // Reusable scope for async reply processing — avoids creating a new
@@ -74,7 +77,15 @@ open class NotificationReplyReceiver : BroadcastReceiver() {
return@withContext return@withContext
} }
HermesWsClient.sendMessage(sessionId, replyText) val runtimeSessionId =
ActiveSessionHolder.resolveRuntimeSessionId(sessionId)
?: resumeSession(sessionId)
HermesWsClient
.request(
WsMethods.PROMPT_SUBMIT,
mapOf("session_id" to runtimeSessionId, "text" to replyText),
timeoutMs = REPLY_TIMEOUT_MS,
).await()
val entity = val entity =
com.m57.hermescontrol.data.local.ChatMessageEntity( com.m57.hermescontrol.data.local.ChatMessageEntity(
@@ -111,4 +122,19 @@ open class NotificationReplyReceiver : BroadcastReceiver() {
} }
} }
} }
private suspend fun resumeSession(storedSessionId: String): String {
val result =
HermesWsClient
.request(
WsMethods.SESSION_RESUME,
mapOf("session_id" to storedSessionId),
timeoutMs = REPLY_TIMEOUT_MS,
).await() as? Map<*, *>
val runtimeSessionId =
(result?.get("session_id") as? String)?.takeIf { it.isNotBlank() }
?: error("Resume returned no runtime session id")
ActiveSessionHolder.set(runtimeSessionId, storedSessionId)
return runtimeSessionId
}
} }
@@ -845,7 +845,7 @@ class ChatViewModel(
// Mirror the active session id app-wide so session-scoped // Mirror the active session id app-wide so session-scoped
// drawer screens (e.g. Processes, issue #532) can issue // drawer screens (e.g. Processes, issue #532) can issue
// session-scoped RPCs. See ActiveSessionHolder. // session-scoped RPCs. See ActiveSessionHolder.
ActiveSessionHolder.set(runtimeId) ActiveSessionHolder.set(runtimeId, storageId)
_streamingState.update { StreamingState() } _streamingState.update { StreamingState() }
addSystemMessage("Session created", persist = true) addSystemMessage("Session created", persist = true)
loadSessions() loadSessions()
@@ -868,7 +868,7 @@ class ChatViewModel(
isLoading = false, isLoading = false,
) )
runtimeSessionId = runtimeId runtimeSessionId = runtimeId
ActiveSessionHolder.set(runtimeId) ActiveSessionHolder.set(runtimeId, storageId)
sessionHasServerPresence = false sessionHasServerPresence = false
sessionGoneRecoveryInFlight = false sessionGoneRecoveryInFlight = false
addSystemMessage("Session branched", persist = true) addSystemMessage("Session branched", persist = true)
@@ -938,7 +938,7 @@ class ChatViewModel(
) )
} }
// Mirror the active runtime session id app-wide (issue #532). // Mirror the active runtime session id app-wide (issue #532).
ActiveSessionHolder.set(runtimeSessionId ?: sessionId) ActiveSessionHolder.set(runtimeSessionId ?: sessionId, sessionId)
addSystemMessage("Session resumed") addSystemMessage("Session resumed")
fetchContextUsage() fetchContextUsage()
val generation = request?.generation ?: sessionGeneration val generation = request?.generation ?: sessionGeneration
@@ -1848,7 +1848,7 @@ class ChatViewModel(
resumedGeneration = -1L resumedGeneration = -1L
hydratedGeneration = -1L hydratedGeneration = -1L
runtimeSessionId = null runtimeSessionId = null
ActiveSessionHolder.set(sessionId) ActiveSessionHolder.clear()
loadedMessageOffset = 0 loadedMessageOffset = 0
isSyncingMessages = false isSyncingMessages = false
streamingController.resetStreaming() streamingController.resetStreaming()
@@ -10,7 +10,6 @@ import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
@@ -48,20 +47,13 @@ fun ChatLifecycleEffects(
val context = LocalContext.current val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current val lifecycleOwner = LocalLifecycleOwner.current
// Publish the notification session ID to the ViewModel synchronously
SideEffect {
viewModel.initialSessionId = sessionId
}
// Switch to session from notification/history // Switch to session from notification/history
LaunchedEffect(sessionId, NavigationController.pendingSessionId, connectionStatus) { val pendingSessionId = NavigationController.pendingSessionId
LaunchedEffect(sessionId, pendingSessionId, connectionStatus) {
if (connectionStatus != ConnectionStatus.CONNECTED) return@LaunchedEffect if (connectionStatus != ConnectionStatus.CONNECTED) return@LaunchedEffect
val target = if (!sessionId.isNullOrBlank()) sessionId else NavigationController.pendingSessionId val target = NavigationController.consumePendingSessionId() ?: sessionId
if (!target.isNullOrBlank()) { if (!target.isNullOrBlank()) {
viewModel.switchSession(target) viewModel.switchSession(target)
if (target == NavigationController.pendingSessionId) {
NavigationController.pendingSessionId = null
}
} }
} }
@@ -555,8 +555,7 @@ fun SessionsScreen(
if (state.isSelecting) { if (state.isSelecting) {
viewModel.toggleSessionSelection(session.id) viewModel.toggleSessionSelection(session.id)
} else { } else {
NavigationController.pendingSessionId = session.id NavigationController.openChatSession(session.id)
NavigationController.navigateTo(ChatScreen)
} }
}, },
onCardLongClick = { onCardLongClick = {
@@ -691,8 +690,7 @@ fun SessionsScreen(
if (state.isSelecting) { if (state.isSelecting) {
viewModel.toggleSessionSelection(session.id) viewModel.toggleSessionSelection(session.id)
} else { } else {
NavigationController.pendingSessionId = session.id NavigationController.openChatSession(session.id)
NavigationController.navigateTo(ChatScreen)
} }
}, },
onCardLongClick = { onCardLongClick = {
@@ -27,11 +27,13 @@ class NavigationControllerTest {
fun setUp() { fun setUp() {
// Start fresh — no pinned back stack from a previous test // Start fresh — no pinned back stack from a previous test
NavigationController.backStack = null NavigationController.backStack = null
NavigationController.consumePendingSessionId()
} }
@After @After
fun tearDown() { fun tearDown() {
NavigationController.backStack = null NavigationController.backStack = null
NavigationController.consumePendingSessionId()
} }
// ── Dedup guard: navigateTo with same key ────────────────────────────── // ── Dedup guard: navigateTo with same key ──────────────────────────────
@@ -121,6 +123,29 @@ class NavigationControllerTest {
assertEquals(KeysScreen, backStack.lastOrNull()) assertEquals(KeysScreen, backStack.lastOrNull())
} }
@Test
fun `openChatSession navigates once and exposes a consumable session id`() {
val backStack = NavBackStack<NavKey>(HistoryScreen)
NavigationController.backStack = backStack
NavigationController.openChatSession("stored-session")
assertEquals(ChatScreen, backStack.lastOrNull())
assertEquals("stored-session", NavigationController.consumePendingSessionId())
assertNull(NavigationController.consumePendingSessionId())
}
@Test
fun `openChatSession ignores blank ids`() {
val backStack = NavBackStack<NavKey>(HistoryScreen)
NavigationController.backStack = backStack
NavigationController.openChatSession(" ")
assertEquals(HistoryScreen, backStack.lastOrNull())
assertNull(NavigationController.consumePendingSessionId())
}
// ── resetTo: atomic clear + navigate ────────────────────────────────── // ── resetTo: atomic clear + navigate ──────────────────────────────────
@Test @Test
@@ -0,0 +1,43 @@
package com.m57.hermescontrol.data.session
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class ActiveSessionHolderTest {
@After
fun tearDown() {
ActiveSessionHolder.clear()
}
@Test
fun `runtime and stored ids resolve in both directions`() {
ActiveSessionHolder.set("runtime-session", "stored-session")
assertEquals("stored-session", ActiveSessionHolder.resolveStoredSessionId("runtime-session"))
assertEquals("runtime-session", ActiveSessionHolder.resolveRuntimeSessionId("stored-session"))
}
@Test
fun `session switch discards the previous mapping`() {
ActiveSessionHolder.set("runtime-previous", "stored-previous")
ActiveSessionHolder.clear()
ActiveSessionHolder.set("runtime-current", "stored-current")
assertEquals("runtime-previous", ActiveSessionHolder.resolveStoredSessionId("runtime-previous"))
assertNull(ActiveSessionHolder.resolveRuntimeSessionId("stored-previous"))
}
@Test
fun `mapping can be rebuilt after reconnect`() {
ActiveSessionHolder.set("runtime-before", "stored-session")
ActiveSessionHolder.clear()
assertNull(ActiveSessionHolder.activeSessionId.value)
assertNull(ActiveSessionHolder.resolveRuntimeSessionId("stored-session"))
ActiveSessionHolder.set("runtime-after", "stored-session")
assertEquals("runtime-after", ActiveSessionHolder.resolveRuntimeSessionId("stored-session"))
}
}
@@ -9,7 +9,9 @@ import androidx.core.app.RemoteInput
import com.m57.hermescontrol.data.local.ChatMessageDao import com.m57.hermescontrol.data.local.ChatMessageDao
import com.m57.hermescontrol.data.local.ChatMessageEntity import com.m57.hermescontrol.data.local.ChatMessageEntity
import com.m57.hermescontrol.data.local.HermesDatabase import com.m57.hermescontrol.data.local.HermesDatabase
import com.m57.hermescontrol.data.session.ActiveSessionHolder
import com.m57.hermescontrol.data.ws.HermesWsClient import com.m57.hermescontrol.data.ws.HermesWsClient
import com.m57.hermescontrol.data.ws.WsMethods
import io.mockk.coEvery import io.mockk.coEvery
import io.mockk.every import io.mockk.every
import io.mockk.mockk import io.mockk.mockk
@@ -18,6 +20,7 @@ import io.mockk.mockkStatic
import io.mockk.slot import io.mockk.slot
import io.mockk.unmockkAll import io.mockk.unmockkAll
import io.mockk.verify import io.mockk.verify
import kotlinx.coroutines.CompletableDeferred
import org.junit.After import org.junit.After
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse import org.junit.Assert.assertFalse
@@ -31,7 +34,7 @@ import org.junit.Test
* handles inline reply actions on chat notifications. * handles inline reply actions on chat notifications.
* *
* Issue #291 (Critical Test Coverage): Verifies the full flow — * Issue #291 (Critical Test Coverage): Verifies the full flow —
* (a) WS message is sent via [HermesWsClient.sendMessage] * (a) WS message is confirmed via [HermesWsClient.request]
* (b) Reply is persisted to Room via [ChatMessageDao.upsert] * (b) Reply is persisted to Room via [ChatMessageDao.upsert]
* (c) A "Replied" confirmation notification is posted * (c) A "Replied" confirmation notification is posted
* (d) Invalid/missing input is handled gracefully (no crash) * (d) Invalid/missing input is handled gracefully (no crash)
@@ -59,6 +62,7 @@ class NotificationReplyReceiverTest {
@Before @Before
fun setUp() { fun setUp() {
upsertCallCount = 0 upsertCallCount = 0
ActiveSessionHolder.clear()
// Mock Android framework statics (same pattern as HermesWsClientTest) // Mock Android framework statics (same pattern as HermesWsClientTest)
mockkStatic(android.util.Log::class) mockkStatic(android.util.Log::class)
@@ -97,7 +101,17 @@ class NotificationReplyReceiverTest {
// Mock HermesWsClient singleton // Mock HermesWsClient singleton
mockkObject(HermesWsClient) mockkObject(HermesWsClient)
every { HermesWsClient.sendMessage(any(), any()) } returns "mock-req-id" every { HermesWsClient.request(any(), any(), any()) } answers {
val method = arg<String>(0)
val params = arg<Map<String, Any>>(1)
val result =
if (method == WsMethods.SESSION_RESUME) {
mapOf("session_id" to "runtime-${params["session_id"]}")
} else {
mapOf("accepted" to true)
}
CompletableDeferred<Any?>(result)
}
// Create receiver with overridden goAsyncCompat() and buildReplyNotification() // Create receiver with overridden goAsyncCompat() and buildReplyNotification()
// — BroadcastReceiver.goAsync() is final (Java) and cannot be mocked via spyk, // — BroadcastReceiver.goAsync() is final (Java) and cannot be mocked via spyk,
@@ -113,6 +127,7 @@ class NotificationReplyReceiverTest {
@After @After
fun tearDown() { fun tearDown() {
ActiveSessionHolder.clear()
HermesDatabase.setForTest(null) HermesDatabase.setForTest(null)
unmockkAll() unmockkAll()
} }
@@ -126,7 +141,13 @@ class NotificationReplyReceiverTest {
receiver.onReceive(mockContext, mockIntent) receiver.onReceive(mockContext, mockIntent)
Thread.sleep(500) // Need to wait for coroutine Thread.sleep(500) // Need to wait for coroutine
verify { HermesWsClient.sendMessage("session-abc", "Hello") } verify {
HermesWsClient.request(
WsMethods.PROMPT_SUBMIT,
match { it["session_id"] == "runtime-session-abc" && it["text"] == "Hello" },
any(),
)
}
} }
@Test @Test
@@ -275,6 +296,88 @@ class NotificationReplyReceiverTest {
verify { mockPendingResult.finish() } verify { mockPendingResult.finish() }
} }
@Test
fun `reply after reconnect resumes stored id and never sends stale runtime id`() {
ActiveSessionHolder.set("stale-runtime", "session-abc")
ActiveSessionHolder.clear()
givenValidReply("session-abc", "Hello")
receiver.onReceive(mockContext, mockIntent)
Thread.sleep(500)
verify {
HermesWsClient.request(WsMethods.SESSION_RESUME, match { it["session_id"] == "session-abc" }, any())
HermesWsClient.request(
WsMethods.PROMPT_SUBMIT,
match { it["session_id"] == "runtime-session-abc" },
any(),
)
}
verify(inverse = true) {
HermesWsClient.request(
WsMethods.PROMPT_SUBMIT,
match { it["session_id"] == "stale-runtime" },
any(),
)
}
}
@Test
fun `reply is persisted and confirmed only after send succeeds`() {
val sendResult = CompletableDeferred<Any?>()
every { HermesWsClient.request(WsMethods.PROMPT_SUBMIT, any(), any()) } returns sendResult
givenValidReply("session-abc", "Hello")
receiver.onReceive(mockContext, mockIntent)
Thread.sleep(100)
assertEquals(0, upsertCallCount)
verify(inverse = true) { mockNotificationManager.notify(any(), any()) }
sendResult.complete(mapOf("accepted" to true))
Thread.sleep(500)
assertEquals(1, upsertCallCount)
verify { mockNotificationManager.notify(ChatNotificationService.PENDING_NOTIFICATION_ID, any()) }
}
@Test
fun `failed send is neither persisted nor confirmed`() {
every { HermesWsClient.request(WsMethods.PROMPT_SUBMIT, any(), any()) } returns
CompletableDeferred<Any?>().apply { completeExceptionally(IllegalStateException("rejected")) }
givenValidReply("session-abc", "Hello")
receiver.onReceive(mockContext, mockIntent)
Thread.sleep(500)
assertEquals(0, upsertCallCount)
verify(inverse = true) { mockNotificationManager.notify(any(), any()) }
verify { mockPendingResult.finish() }
}
@Test
fun `failed resume remains retryable on the next reply`() {
var resumeAttempts = 0
every { HermesWsClient.request(WsMethods.SESSION_RESUME, any(), any()) } answers {
resumeAttempts++
if (resumeAttempts == 1) {
CompletableDeferred<Any?>().apply { completeExceptionally(IllegalStateException("rejected")) }
} else {
CompletableDeferred<Any?>(mapOf("session_id" to "runtime-session-abc"))
}
}
givenValidReply("session-abc", "Hello")
receiver.onReceive(mockContext, mockIntent)
Thread.sleep(500)
receiver.onReceive(mockContext, mockIntent)
Thread.sleep(500)
assertEquals(2, resumeAttempts)
assertEquals(1, upsertCallCount)
assertEquals("runtime-session-abc", ActiveSessionHolder.resolveRuntimeSessionId("session-abc"))
}
@Test @Test
fun `Room hang triggers timeout and finishes pending result`() { fun `Room hang triggers timeout and finishes pending result`() {
givenValidReply("session-abc", "Hello") givenValidReply("session-abc", "Hello")