diff --git a/app/src/main/java/com/m57/hermescontrol/MainActivity.kt b/app/src/main/java/com/m57/hermescontrol/MainActivity.kt index 09819301..9e7b9da4 100644 --- a/app/src/main/java/com/m57/hermescontrol/MainActivity.kt +++ b/app/src/main/java/com/m57/hermescontrol/MainActivity.kt @@ -11,8 +11,6 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import com.m57.hermescontrol.data.local.AuthManager import com.m57.hermescontrol.notification.NotificationReplyReceiver @@ -20,9 +18,6 @@ import com.m57.hermescontrol.theme.HermesControlTheme import com.m57.hermescontrol.util.LocaleContextWrapper class MainActivity : ComponentActivity() { - // Observable state so both onCreate and onNewIntent updates flow to Compose - private var notificationSessionId by mutableStateOf(null) - /** * Apply the user-selected display language before any view is inflated. * Reads the persisted code from [AuthManager]; an uninitialized store @@ -38,8 +33,7 @@ class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - // Read notification tap sessionId (if any) from the intent that launched us - notificationSessionId = intent?.getStringExtra(NotificationReplyReceiver.EXTRA_SESSION_ID) + consumeNotificationIntent(intent) enableEdgeToEdge() setContent { @@ -55,7 +49,7 @@ class MainActivity : ComponentActivity() { modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background, ) { - MainNavigation(sessionId = notificationSessionId) + MainNavigation() } } } @@ -63,10 +57,13 @@ class MainActivity : ComponentActivity() { override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) - // When a notification tap arrives while the app is already running - // (task exists in background), Android delivers the intent here instead - // 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) + setIntent(intent) + consumeNotificationIntent(intent) + } + + 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) } } diff --git a/app/src/main/java/com/m57/hermescontrol/NavigationController.kt b/app/src/main/java/com/m57/hermescontrol/NavigationController.kt index db000718..139c42d0 100644 --- a/app/src/main/java/com/m57/hermescontrol/NavigationController.kt +++ b/app/src/main/java/com/m57/hermescontrol/NavigationController.kt @@ -1,5 +1,8 @@ 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.NavKey @@ -16,7 +19,8 @@ import androidx.navigation3.runtime.NavKey */ object NavigationController { var backStack: NavBackStack? = null - var pendingSessionId: String? = null + var pendingSessionId: String? by mutableStateOf(null) + private set // Top-level primary screens (Chat, Skills, Cron, System, Settings) private val primaryScreens: MutableSet = @@ -45,6 +49,14 @@ object NavigationController { 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. */ fun resetTo(screen: NavKey) { val stack = backStack ?: return diff --git a/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt b/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt index 2b0d0db3..b533016b 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/local/AuthManager.kt @@ -16,6 +16,7 @@ import com.m57.hermescontrol.data.model.PinnedModel import com.m57.hermescontrol.data.remote.CleartextPolicy import com.m57.hermescontrol.data.remote.CookieManager 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.ThemePreset import kotlinx.coroutines.CoroutineScope @@ -310,6 +311,7 @@ object AuthManager { ) { requirePrefs().edit().putString("token_$profileId", token).apply() if (getSelectedProfileId() == profileId) { + if (token == null) ActiveSessionHolder.clear() // B7 (Jul 08 2026, kanban t_470): sync in-memory cachedToken // to prevent stale tokens during ticket refresh synchronized(this) { @@ -326,6 +328,9 @@ object AuthManager { } fun setSelectedProfileId(id: String?) { + if (getSelectedProfileId() != id?.takeIf { it.isNotBlank() }) { + ActiveSessionHolder.clear() + } serverStore.update { it.copy(selectedProfileId = id) } synchronized(this) { tokenInitialized = false diff --git a/app/src/main/java/com/m57/hermescontrol/data/session/ActiveSessionHolder.kt b/app/src/main/java/com/m57/hermescontrol/data/session/ActiveSessionHolder.kt index e65397e1..2b9b4bae 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/session/ActiveSessionHolder.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/session/ActiveSessionHolder.kt @@ -17,12 +17,32 @@ import kotlinx.coroutines.flow.asStateFlow */ object ActiveSessionHolder { private val _activeSessionId = MutableStateFlow(null) + private var activeStoredSessionId: String? = null /** The currently active chat session id, or null if none is known yet. */ val activeSessionId: StateFlow = _activeSessionId.asStateFlow() /** 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 + 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 } } diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/HermesWsClient.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/HermesWsClient.kt index 5662aedc..06293ada 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/HermesWsClient.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/HermesWsClient.kt @@ -7,6 +7,7 @@ import com.m57.hermescontrol.data.local.AuthManager import com.m57.hermescontrol.data.remote.DashboardSessionTokenRefresher import com.m57.hermescontrol.data.remote.NetworkMonitor import com.m57.hermescontrol.data.remote.OkHttpProvider +import com.m57.hermescontrol.data.session.ActiveSessionHolder import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -452,6 +453,7 @@ object HermesWsClient { /** Cleanly close the WebSocket and stop auto-reconnect. */ fun disconnect(clearPendingMessages: Boolean = false) { + ActiveSessionHolder.clear() synchronized(outboundLock) { intentionalClose.set(true) acceptQueuedMessages.set(!clearPendingMessages) @@ -885,6 +887,7 @@ object HermesWsClient { // reason is still inspected internally to detect auth failures. Log.i(TAG, "WebSocket closed: $code") connected.set(false) + ActiveSessionHolder.clear() stopHealthTracking() if (code == 4001 || code == 4401 || reason.contains("unauthorized", ignoreCase = true) || @@ -915,6 +918,7 @@ object HermesWsClient { // detection. Log.e(TAG, "WebSocket failure: ${t.javaClass.simpleName}", t) connected.set(false) + ActiveSessionHolder.clear() stopHealthTracking() val code = response?.code ?: 0 if (code == 401 || t.message?.contains( diff --git a/app/src/main/java/com/m57/hermescontrol/notification/ChatNotificationService.kt b/app/src/main/java/com/m57/hermescontrol/notification/ChatNotificationService.kt index e5fb59f9..4eec1d68 100644 --- a/app/src/main/java/com/m57/hermescontrol/notification/ChatNotificationService.kt +++ b/app/src/main/java/com/m57/hermescontrol/notification/ChatNotificationService.kt @@ -14,6 +14,7 @@ import androidx.core.app.RemoteInput import com.m57.hermescontrol.MainActivity import com.m57.hermescontrol.R 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.WsEvent import kotlinx.coroutines.CoroutineScope @@ -98,7 +99,10 @@ class ChatNotificationService : Service() { .take(100) .replace("\n", " ") .ifBlank { getString(R.string.notif_new_message) } - showReplyNotification(preview, event.sessionId) + showReplyNotification( + preview, + ActiveSessionHolder.resolveStoredSessionId(event.sessionId), + ) // The wait is over — retire the foreground // service. The reply notification above // replaces the persistent "waiting" one, diff --git a/app/src/main/java/com/m57/hermescontrol/notification/NotificationReplyReceiver.kt b/app/src/main/java/com/m57/hermescontrol/notification/NotificationReplyReceiver.kt index 6baecda7..c6742dac 100644 --- a/app/src/main/java/com/m57/hermescontrol/notification/NotificationReplyReceiver.kt +++ b/app/src/main/java/com/m57/hermescontrol/notification/NotificationReplyReceiver.kt @@ -8,7 +8,9 @@ import android.content.Intent import androidx.core.app.NotificationCompat import androidx.core.app.RemoteInput 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.WsMethods import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -20,6 +22,7 @@ open class NotificationReplyReceiver : BroadcastReceiver() { companion object { const val KEY_TEXT_REPLY = "key_text_reply" 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 @@ -74,7 +77,15 @@ open class NotificationReplyReceiver : BroadcastReceiver() { 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 = 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 + } } 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 2c9ffb48..ae24bb16 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 @@ -845,7 +845,7 @@ class ChatViewModel( // Mirror the active session id app-wide so session-scoped // drawer screens (e.g. Processes, issue #532) can issue // session-scoped RPCs. See ActiveSessionHolder. - ActiveSessionHolder.set(runtimeId) + ActiveSessionHolder.set(runtimeId, storageId) _streamingState.update { StreamingState() } addSystemMessage("Session created", persist = true) loadSessions() @@ -868,7 +868,7 @@ class ChatViewModel( isLoading = false, ) runtimeSessionId = runtimeId - ActiveSessionHolder.set(runtimeId) + ActiveSessionHolder.set(runtimeId, storageId) sessionHasServerPresence = false sessionGoneRecoveryInFlight = false addSystemMessage("Session branched", persist = true) @@ -938,7 +938,7 @@ class ChatViewModel( ) } // Mirror the active runtime session id app-wide (issue #532). - ActiveSessionHolder.set(runtimeSessionId ?: sessionId) + ActiveSessionHolder.set(runtimeSessionId ?: sessionId, sessionId) addSystemMessage("Session resumed") fetchContextUsage() val generation = request?.generation ?: sessionGeneration @@ -1848,7 +1848,7 @@ class ChatViewModel( resumedGeneration = -1L hydratedGeneration = -1L runtimeSessionId = null - ActiveSessionHolder.set(sessionId) + ActiveSessionHolder.clear() loadedMessageOffset = 0 isSyncingMessages = false streamingController.resetStreaming() diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatLifecycleEffects.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatLifecycleEffects.kt index 66eaeca3..de1f080e 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatLifecycleEffects.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatLifecycleEffects.kt @@ -10,7 +10,6 @@ import androidx.compose.material3.SnackbarHostState import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.SideEffect import androidx.compose.ui.platform.LocalContext import androidx.core.content.ContextCompat import androidx.lifecycle.Lifecycle @@ -48,20 +47,13 @@ fun ChatLifecycleEffects( val context = LocalContext.current val lifecycleOwner = LocalLifecycleOwner.current - // Publish the notification session ID to the ViewModel synchronously - SideEffect { - viewModel.initialSessionId = sessionId - } - // 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 - val target = if (!sessionId.isNullOrBlank()) sessionId else NavigationController.pendingSessionId + val target = NavigationController.consumePendingSessionId() ?: sessionId if (!target.isNullOrBlank()) { viewModel.switchSession(target) - if (target == NavigationController.pendingSessionId) { - NavigationController.pendingSessionId = null - } } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsScreen.kt index 254b5f5d..f7fd132d 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsScreen.kt @@ -555,8 +555,7 @@ fun SessionsScreen( if (state.isSelecting) { viewModel.toggleSessionSelection(session.id) } else { - NavigationController.pendingSessionId = session.id - NavigationController.navigateTo(ChatScreen) + NavigationController.openChatSession(session.id) } }, onCardLongClick = { @@ -691,8 +690,7 @@ fun SessionsScreen( if (state.isSelecting) { viewModel.toggleSessionSelection(session.id) } else { - NavigationController.pendingSessionId = session.id - NavigationController.navigateTo(ChatScreen) + NavigationController.openChatSession(session.id) } }, onCardLongClick = { diff --git a/app/src/test/java/com/m57/hermescontrol/NavigationControllerTest.kt b/app/src/test/java/com/m57/hermescontrol/NavigationControllerTest.kt index 884c28f3..5881c85f 100644 --- a/app/src/test/java/com/m57/hermescontrol/NavigationControllerTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/NavigationControllerTest.kt @@ -27,11 +27,13 @@ class NavigationControllerTest { fun setUp() { // Start fresh — no pinned back stack from a previous test NavigationController.backStack = null + NavigationController.consumePendingSessionId() } @After fun tearDown() { NavigationController.backStack = null + NavigationController.consumePendingSessionId() } // ── Dedup guard: navigateTo with same key ────────────────────────────── @@ -121,6 +123,29 @@ class NavigationControllerTest { assertEquals(KeysScreen, backStack.lastOrNull()) } + @Test + fun `openChatSession navigates once and exposes a consumable session id`() { + val backStack = NavBackStack(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(HistoryScreen) + NavigationController.backStack = backStack + + NavigationController.openChatSession(" ") + + assertEquals(HistoryScreen, backStack.lastOrNull()) + assertNull(NavigationController.consumePendingSessionId()) + } + // ── resetTo: atomic clear + navigate ────────────────────────────────── @Test diff --git a/app/src/test/java/com/m57/hermescontrol/data/session/ActiveSessionHolderTest.kt b/app/src/test/java/com/m57/hermescontrol/data/session/ActiveSessionHolderTest.kt new file mode 100644 index 00000000..e5a6f99a --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/session/ActiveSessionHolderTest.kt @@ -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")) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/notification/NotificationReplyReceiverTest.kt b/app/src/test/java/com/m57/hermescontrol/notification/NotificationReplyReceiverTest.kt index 25640bc1..620732a5 100644 --- a/app/src/test/java/com/m57/hermescontrol/notification/NotificationReplyReceiverTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/notification/NotificationReplyReceiverTest.kt @@ -9,7 +9,9 @@ import androidx.core.app.RemoteInput import com.m57.hermescontrol.data.local.ChatMessageDao import com.m57.hermescontrol.data.local.ChatMessageEntity 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.WsMethods import io.mockk.coEvery import io.mockk.every import io.mockk.mockk @@ -18,6 +20,7 @@ import io.mockk.mockkStatic import io.mockk.slot import io.mockk.unmockkAll import io.mockk.verify +import kotlinx.coroutines.CompletableDeferred import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse @@ -31,7 +34,7 @@ import org.junit.Test * handles inline reply actions on chat notifications. * * 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] * (c) A "Replied" confirmation notification is posted * (d) Invalid/missing input is handled gracefully (no crash) @@ -59,6 +62,7 @@ class NotificationReplyReceiverTest { @Before fun setUp() { upsertCallCount = 0 + ActiveSessionHolder.clear() // Mock Android framework statics (same pattern as HermesWsClientTest) mockkStatic(android.util.Log::class) @@ -97,7 +101,17 @@ class NotificationReplyReceiverTest { // Mock HermesWsClient singleton mockkObject(HermesWsClient) - every { HermesWsClient.sendMessage(any(), any()) } returns "mock-req-id" + every { HermesWsClient.request(any(), any(), any()) } answers { + val method = arg(0) + val params = arg>(1) + val result = + if (method == WsMethods.SESSION_RESUME) { + mapOf("session_id" to "runtime-${params["session_id"]}") + } else { + mapOf("accepted" to true) + } + CompletableDeferred(result) + } // Create receiver with overridden goAsyncCompat() and buildReplyNotification() // — BroadcastReceiver.goAsync() is final (Java) and cannot be mocked via spyk, @@ -113,6 +127,7 @@ class NotificationReplyReceiverTest { @After fun tearDown() { + ActiveSessionHolder.clear() HermesDatabase.setForTest(null) unmockkAll() } @@ -126,7 +141,13 @@ class NotificationReplyReceiverTest { receiver.onReceive(mockContext, mockIntent) 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 @@ -275,6 +296,88 @@ class NotificationReplyReceiverTest { 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() + 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().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().apply { completeExceptionally(IllegalStateException("rejected")) } + } else { + CompletableDeferred(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 fun `Room hang triggers timeout and finishes pending result`() { givenValidReply("session-abc", "Hello")