fix(notification): disconnect idle background socket (#858)

* fix(notification): disconnect idle background socket

* fix(notification): clear rejected prompt state
This commit is contained in:
AlexDev_
2026-08-09 12:06:59 +03:00
committed by GitHub
parent 0db741edfe
commit 3b033a025b
8 changed files with 221 additions and 40 deletions
@@ -13,6 +13,8 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
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.data.ws.HermesWsClient
import com.m57.hermescontrol.notification.NotificationHelper
import com.m57.hermescontrol.notification.NotificationReplyReceiver import com.m57.hermescontrol.notification.NotificationReplyReceiver
import com.m57.hermescontrol.theme.HermesControlTheme import com.m57.hermescontrol.theme.HermesControlTheme
import com.m57.hermescontrol.util.LocaleContextWrapper import com.m57.hermescontrol.util.LocaleContextWrapper
@@ -73,6 +75,21 @@ class MainActivity : ComponentActivity() {
consumeNotificationIntent(intent) consumeNotificationIntent(intent)
} }
override fun onStart() {
super.onStart()
NotificationHelper.setAppForeground(this, true)
NotificationHelper.stop(this)
if (AuthManager.isGatedMode() || !AuthManager.getToken().isNullOrBlank()) {
HermesWsClient.connect()
}
}
override fun onStop() {
NotificationHelper.setAppForeground(this, false)
NotificationHelper.start(this)
super.onStop()
}
private fun consumeNotificationIntent(intent: Intent?) { private fun consumeNotificationIntent(intent: Intent?) {
// Issue #832: MainActivity is exported (launcher requirement) — only // Issue #832: MainActivity is exported (launcher requirement) — only
// honor intents stamped with our own notification action. // honor intents stamped with our own notification action.
@@ -14,20 +14,15 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.buffer
import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.shareIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.serialization.decodeFromString import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString import kotlinx.serialization.encodeToString
@@ -91,6 +86,7 @@ object HermesWsClient {
private val connected = AtomicBoolean(false) private val connected = AtomicBoolean(false)
private val intentionalClose = AtomicBoolean(false) private val intentionalClose = AtomicBoolean(false)
private val acceptQueuedMessages = AtomicBoolean(true) private val acceptQueuedMessages = AtomicBoolean(true)
private val appInForeground = AtomicBoolean(true)
private val messageQueue = ConcurrentLinkedQueue<String>() private val messageQueue = ConcurrentLinkedQueue<String>()
private val queuedMessagesById = ConcurrentHashMap<String, String>() private val queuedMessagesById = ConcurrentHashMap<String, String>()
private val outboundLock = Any() private val outboundLock = Any()
@@ -153,26 +149,14 @@ object HermesWsClient {
// ── Public observable stream ───────────────────────────────────────── // ── Public observable stream ─────────────────────────────────────────
private val rawMessages = private val parsedEvents =
MutableSharedFlow<String>( MutableSharedFlow<WsEvent>(
extraBufferCapacity = 512, extraBufferCapacity = 512,
onBufferOverflow = BufferOverflow.DROP_OLDEST, onBufferOverflow = BufferOverflow.DROP_OLDEST,
) )
/** Collect this from ViewModels to receive all parsed [WsEvent]s. */ /** Collect this from ViewModels to receive all parsed [WsEvent]s. */
val events: SharedFlow<WsEvent> = val events: SharedFlow<WsEvent> = parsedEvents.asSharedFlow()
rawMessages
.buffer(Channel.BUFFERED)
.map { text ->
try {
val rpc = OkHttpProvider.json.decodeFromString<JsonRpcResponse>(text)
EventParser.parse(rpc, text)
} catch (e: Exception) {
Log.e(TAG, "Failed to parse message", e)
WsEvent.Unknown(text)
}
}.flowOn(Dispatchers.Default) // CPU-bound
.shareIn(wsScope, SharingStarted.Eagerly)
// ── Connection status flow ────────────────────────────────────────── // ── Connection status flow ──────────────────────────────────────────
private val _connectionStatus = MutableStateFlow(ConnectionStatus.DISCONNECTED) private val _connectionStatus = MutableStateFlow(ConnectionStatus.DISCONNECTED)
@@ -189,6 +173,8 @@ object HermesWsClient {
var pendingReply: Boolean = false var pendingReply: Boolean = false
private set private set
private val pendingPromptSubmits = ConcurrentHashMap.newKeySet<String>()
// ── Credential warning (issue #534) ───────────────────────────────── // ── Credential warning (issue #534) ─────────────────────────────────
// Backend surfaces `credential_warning` in `gateway.ready` / `session.info` // Backend surfaces `credential_warning` in `gateway.ready` / `session.info`
// WS payloads (desktop `requestDesktopOnboarding`). Mobile has no equivalent // WS payloads (desktop `requestDesktopOnboarding`). Mobile has no equivalent
@@ -233,7 +219,10 @@ object HermesWsClient {
is WsEvent.ToolStart, is WsEvent.ToolStart,
-> pendingReply = true -> pendingReply = true
is WsEvent.MessageComplete -> pendingReply = false is WsEvent.MessageComplete -> {
pendingReply = false
disconnectIfIdleInBackground()
}
else -> {} else -> {}
} }
} }
@@ -253,6 +242,23 @@ object HermesWsClient {
@VisibleForTesting @VisibleForTesting
val isConnected: Boolean get() = connected.get() val isConnected: Boolean get() = connected.get()
fun setAppForeground(foreground: Boolean) {
appInForeground.set(foreground)
if (!foreground) disconnectIfIdleInBackground()
}
private fun disconnectIfIdleInBackground() {
synchronized(outboundLock) {
if (!appInForeground.get() &&
!pendingReply &&
pendingCalls.isEmpty() &&
messageQueue.isEmpty()
) {
disconnect()
}
}
}
/** Open a WebSocket connection using settings from [AuthManager]. */ /** Open a WebSocket connection using settings from [AuthManager]. */
fun connect() { fun connect() {
if (networkCollectorStarted.compareAndSet(false, true)) { if (networkCollectorStarted.compareAndSet(false, true)) {
@@ -501,6 +507,8 @@ object HermesWsClient {
if (clearPendingMessages) { if (clearPendingMessages) {
messageQueue.clear() messageQueue.clear()
queuedMessagesById.clear() queuedMessagesById.clear()
pendingPromptSubmits.clear()
pendingReply = false
} }
connected.set(false) connected.set(false)
_connectionStatus.value = ConnectionStatus.DISCONNECTED _connectionStatus.value = ConnectionStatus.DISCONNECTED
@@ -577,6 +585,7 @@ object HermesWsClient {
} else { } else {
call.deferred.complete(result) call.deferred.complete(result)
} }
disconnectIfIdleInBackground()
} }
/** /**
@@ -621,7 +630,12 @@ object HermesWsClient {
) )
val json = OkHttpProvider.json.encodeToString(request) val json = OkHttpProvider.json.encodeToString(request)
if (BuildConfig.DEBUG) Log.d(TAG, "→ $json") if (BuildConfig.DEBUG) Log.d(TAG, "→ $json")
var reconnect = false
synchronized(outboundLock) { synchronized(outboundLock) {
if (method == WsMethods.PROMPT_SUBMIT) {
pendingPromptSubmits.add(id)
pendingReply = true
}
onSent?.invoke(id) onSent?.invoke(id)
val ws = webSocket val ws = webSocket
if (ws != null && connected.get()) { if (ws != null && connected.get()) {
@@ -640,11 +654,13 @@ object HermesWsClient {
if (isRetryableMessage(json)) { if (isRetryableMessage(json)) {
Log.d(TAG, "WS disconnected — queuing message") Log.d(TAG, "WS disconnected — queuing message")
queueMessage(id, json) queueMessage(id, json)
reconnect = true
} else { } else {
Log.w(TAG, "WS disconnected with oversized outgoing message — not queueing") Log.w(TAG, "WS disconnected with oversized outgoing message — not queueing")
} }
} }
} }
if (reconnect) connect()
return id return id
} }
@@ -701,10 +717,6 @@ object HermesWsClient {
text: String, text: String,
onSent: ((String) -> Unit)? = null, onSent: ((String) -> Unit)? = null,
): String { ): String {
// A reply is now pending — even before the first MessageStart event
// (the agent may be in its tool phase), so backgrounding the app in
// that window still arms the notification service (issue #794).
pendingReply = true
return send( return send(
method = WsMethods.PROMPT_SUBMIT, method = WsMethods.PROMPT_SUBMIT,
params = mapOf("session_id" to sessionId, "text" to text), params = mapOf("session_id" to sessionId, "text" to text),
@@ -848,6 +860,7 @@ object HermesWsClient {
markQueuedMessageSent(msg) markQueuedMessageSent(msg)
} }
} }
disconnectIfIdleInBackground()
} }
override fun onMessage( override fun onMessage(
@@ -862,25 +875,41 @@ object HermesWsClient {
val event = val event =
try { try {
val rpc = OkHttpProvider.json.decodeFromString<JsonRpcResponse>(text) val rpc = OkHttpProvider.json.decodeFromString<JsonRpcResponse>(text)
EventParser.parse(rpc, text) val parsed = EventParser.parse(rpc, text)
if (parsed is WsEvent.MessageComplete) {
parsed.copy(
storedSessionId = ActiveSessionHolder.resolveStoredSessionId(parsed.sessionId),
)
} else {
parsed
}
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Failed to parse message", e) Log.e(TAG, "Failed to parse message", e)
WsEvent.Unknown(text) WsEvent.Unknown(text)
} }
when (event) { when (event) {
is WsEvent.RpcResult -> { is WsEvent.RpcResult -> {
synchronized(outboundLock) { pendingPromptSubmits.remove(event.id) }
removeQueuedMessage(event.id) removeQueuedMessage(event.id)
resolvePending(event.id, event.result, null) resolvePending(event.id, event.result, null)
} }
is WsEvent.RpcError -> { is WsEvent.RpcError -> {
synchronized(outboundLock) {
if (pendingPromptSubmits.remove(event.id) &&
pendingPromptSubmits.isEmpty()
) {
pendingReply = false
}
}
removeQueuedMessage(event.id) removeQueuedMessage(event.id)
resolvePending(event.id, null, event.error) resolvePending(event.id, null, event.error)
disconnectIfIdleInBackground()
} }
else -> Unit else -> Unit
} }
val emitted = rawMessages.tryEmit(text) val emitted = parsedEvents.tryEmit(event)
if (!emitted && BuildConfig.DEBUG) { if (!emitted && BuildConfig.DEBUG) {
Log.w(TAG, "WebSocket message dropped due to buffer overflow") Log.w(TAG, "WebSocket message dropped due to buffer overflow")
} }
@@ -57,6 +57,8 @@ sealed class WsEvent {
* (e.g. a tool call wiped the streaming buffer mid-turn). * (e.g. a tool call wiped the streaming buffer mid-turn).
*/ */
val reasoning: String? = null, val reasoning: String? = null,
/** Stored session id captured before background disconnect clears the active mapping. */
val storedSessionId: String? = null,
) : WsEvent() ) : WsEvent()
data class MessageDone( data class MessageDone(
@@ -50,7 +50,7 @@ import java.util.concurrent.atomic.AtomicBoolean
* service — and its mandatory persistent notification — only exists * service — and its mandatory persistent notification — only exists
* while the user is actually waiting for a reply (issue #794). * while the user is actually waiting for a reply (issue #794).
* - Stopped by [NotificationHelper.stop] when the app returns to the * - Stopped by [NotificationHelper.stop] when the app returns to the
* foreground (called from ChatScreen's onStart / onResume), or by the * foreground (called from MainActivity.onStart), or by the
* service itself once the pending reply completes in the background * service itself once the pending reply completes in the background
* (the reply notification replaces the persistent "waiting" one). * (the reply notification replaces the persistent "waiting" one).
* *
@@ -101,7 +101,8 @@ class ChatNotificationService : Service() {
.ifBlank { getString(R.string.notif_new_message) } .ifBlank { getString(R.string.notif_new_message) }
showReplyNotification( showReplyNotification(
preview, preview,
ActiveSessionHolder.resolveStoredSessionId(event.sessionId), event.storedSessionId
?: 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
@@ -239,7 +240,7 @@ class ChatNotificationService : Service() {
intent: Intent?, intent: Intent?,
flags: Int, flags: Int,
startId: Int, startId: Int,
): Int = START_STICKY ): Int = START_NOT_STICKY
override fun onBind(intent: Intent?): IBinder? = null override fun onBind(intent: Intent?): IBinder? = null
@@ -278,6 +279,7 @@ object NotificationHelper {
foreground: Boolean, foreground: Boolean,
) { ) {
ChatNotificationService.setAppForeground(foreground) ChatNotificationService.setAppForeground(foreground)
HermesWsClient.setAppForeground(foreground)
} }
fun isAppInForeground(): Boolean = ChatNotificationService.isAppInForeground() fun isAppInForeground(): Boolean = ChatNotificationService.isAppInForeground()
@@ -1270,6 +1270,7 @@ class ChatViewModel(
// in-flight turn via session.redirect instead of queueing a fresh // in-flight turn via session.redirect instead of queueing a fresh
// prompt.submit. The backend rewrites the live turn when it can, or // prompt.submit. The backend rewrites the live turn when it can, or
// queues the correction as the next turn otherwise (issue #710). // queues the correction as the next turn otherwise (issue #710).
ActiveSessionHolder.set(agentSessionId, storageSessionId)
if (wasStreaming && attachments.isEmpty()) { if (wasStreaming && attachments.isEmpty()) {
wsClient.sendRedirect( wsClient.sendRedirect(
agentSessionId, agentSessionId,
@@ -17,7 +17,6 @@ import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.LocalLifecycleOwner
import com.m57.hermescontrol.NavigationController import com.m57.hermescontrol.NavigationController
import com.m57.hermescontrol.data.ws.ConnectionStatus import com.m57.hermescontrol.data.ws.ConnectionStatus
import com.m57.hermescontrol.notification.NotificationHelper
import com.m57.hermescontrol.ui.chat.ChatMessage import com.m57.hermescontrol.ui.chat.ChatMessage
import com.m57.hermescontrol.ui.chat.ChatViewModel import com.m57.hermescontrol.ui.chat.ChatViewModel
import com.m57.hermescontrol.ui.chat.ClarifyUi import com.m57.hermescontrol.ui.chat.ClarifyUi
@@ -64,23 +63,16 @@ fun ChatLifecycleEffects(
} }
} }
// Lifecycle observer for notification foreground service // Refresh chat state when the app returns to the foreground.
DisposableEffect(lifecycleOwner) { DisposableEffect(lifecycleOwner) {
val observer = val observer =
LifecycleEventObserver { _, event -> LifecycleEventObserver { _, event ->
when (event) { when (event) {
Lifecycle.Event.ON_START -> { Lifecycle.Event.ON_START -> {
NotificationHelper.setAppForeground(context, true)
NotificationHelper.stop(context)
viewModel.refreshSettings() viewModel.refreshSettings()
viewModel.refreshCurrentSession() viewModel.refreshCurrentSession()
} }
Lifecycle.Event.ON_STOP -> {
NotificationHelper.setAppForeground(context, false)
NotificationHelper.start(context)
}
else -> {} else -> {}
} }
} }
@@ -8,6 +8,7 @@ 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.ServerEndpoint import com.m57.hermescontrol.data.remote.ServerEndpoint
import com.m57.hermescontrol.data.remote.buildFakePersistentCookieJar import com.m57.hermescontrol.data.remote.buildFakePersistentCookieJar
import com.m57.hermescontrol.data.session.ActiveSessionHolder
import io.mockk.every import io.mockk.every
import io.mockk.mockk import io.mockk.mockk
import io.mockk.mockkObject import io.mockk.mockkObject
@@ -20,6 +21,7 @@ import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout import kotlinx.coroutines.withTimeout
import okhttp3.Response
import okhttp3.WebSocket import okhttp3.WebSocket
import okhttp3.WebSocketListener import okhttp3.WebSocketListener
import okhttp3.mockwebserver.MockResponse import okhttp3.mockwebserver.MockResponse
@@ -86,6 +88,7 @@ class HermesWsClientTest {
(queueField.get(HermesWsClient) as java.util.concurrent.ConcurrentLinkedQueue<String>).clear() (queueField.get(HermesWsClient) as java.util.concurrent.ConcurrentLinkedQueue<String>).clear()
HermesWsClient.disconnect(clearPendingMessages = true) // Ensure it starts clean HermesWsClient.disconnect(clearPendingMessages = true) // Ensure it starts clean
HermesWsClient.setAppForeground(true)
val acceptQueuedMessagesField = HermesWsClient::class.java.getDeclaredField("acceptQueuedMessages") val acceptQueuedMessagesField = HermesWsClient::class.java.getDeclaredField("acceptQueuedMessages")
acceptQueuedMessagesField.isAccessible = true acceptQueuedMessagesField.isAccessible = true
(acceptQueuedMessagesField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(true) (acceptQueuedMessagesField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(true)
@@ -821,6 +824,139 @@ class HermesWsClientTest {
assertEquals("1", (receivedEvent as WsEvent.RpcResult).id) assertEquals("1", (receivedEvent as WsEvent.RpcResult).id)
} }
@Test
fun testBackgroundWithoutPendingWorkDisconnects() {
mockWebServer.enqueue(MockResponse().withWebSocketUpgrade(object : WebSocketListener() {}))
HermesWsClient.connect()
runBlocking { withTimeout(5000) { HermesWsClient.connectionStatus.first { it == ConnectionStatus.CONNECTED } } }
HermesWsClient.setAppForeground(false)
assertFalse(HermesWsClient.isConnected)
assertEquals(ConnectionStatus.DISCONNECTED, HermesWsClient.connectionStatus.value)
}
@Test
fun testBackgroundWithPendingReplyStaysConnected() {
mockWebServer.enqueue(MockResponse().withWebSocketUpgrade(object : WebSocketListener() {}))
HermesWsClient.connect()
runBlocking { withTimeout(5000) { HermesWsClient.connectionStatus.first { it == ConnectionStatus.CONNECTED } } }
HermesWsClient.sendMessage("session-1", "hello")
HermesWsClient.setAppForeground(false)
assertTrue(HermesWsClient.isConnected)
}
@Test
fun testRejectedPromptDisconnectsIdleBackgroundSocket() {
lateinit var serverSocket: WebSocket
val connectedLatch = CountDownLatch(1)
mockWebServer.enqueue(
MockResponse().withWebSocketUpgrade(
object : WebSocketListener() {
override fun onOpen(
webSocket: WebSocket,
response: Response,
) {
serverSocket = webSocket
connectedLatch.countDown()
}
},
),
)
HermesWsClient.connect()
assertTrue(connectedLatch.await(5, TimeUnit.SECONDS))
val requestId = HermesWsClient.sendMessage("deleted-session", "hello")
HermesWsClient.setAppForeground(false)
assertTrue(HermesWsClient.pendingReply)
serverSocket.send(
"""{"jsonrpc":"2.0","id":"$requestId","error":{"code":4001,"message":"Session not found"}}""",
)
runBlocking {
withTimeout(5000) {
HermesWsClient.connectionStatus.first { it == ConnectionStatus.DISCONNECTED }
}
}
assertFalse(HermesWsClient.pendingReply)
assertFalse(HermesWsClient.isConnected)
}
@Test
fun testBackgroundQueuedSendDisconnectsAfterFlush() {
val messageLatch = CountDownLatch(1)
mockWebServer.enqueue(
MockResponse().withWebSocketUpgrade(
object : WebSocketListener() {
override fun onMessage(
webSocket: WebSocket,
text: String,
) {
messageLatch.countDown()
}
},
),
)
HermesWsClient.setAppForeground(false)
HermesWsClient.send(WsMethods.SESSION_REDIRECT)
assertTrue(messageLatch.await(5, TimeUnit.SECONDS))
runBlocking {
withTimeout(5000) {
HermesWsClient.connectionStatus.first { it == ConnectionStatus.DISCONNECTED }
}
}
assertFalse(HermesWsClient.isConnected)
}
@Test
fun testMessageCompleteDisconnectsIdleBackgroundSocket() {
lateinit var serverSocket: WebSocket
val connectedLatch = CountDownLatch(1)
mockWebServer.enqueue(
MockResponse().withWebSocketUpgrade(
object : WebSocketListener() {
override fun onOpen(
webSocket: WebSocket,
response: Response,
) {
serverSocket = webSocket
connectedLatch.countDown()
}
},
),
)
HermesWsClient.connect()
assertTrue(connectedLatch.await(5, TimeUnit.SECONDS))
ActiveSessionHolder.set("runtime-session", "stored-session")
HermesWsClient.sendMessage("session-1", "hello")
HermesWsClient.setAppForeground(false)
val completeEvent =
"""
{"jsonrpc":"2.0","method":"event","params":{"type":"message.complete",
"payload":{"text":"done","session_id":"runtime-session"}}}
""".trimIndent()
val receivedEvent =
runBlocking {
withTimeout(5000) {
launch { serverSocket.send(completeEvent) }
HermesWsClient.events.first { it is WsEvent.MessageComplete } as WsEvent.MessageComplete
}
}
runBlocking {
withTimeout(5000) {
HermesWsClient.connectionStatus.first { it == ConnectionStatus.DISCONNECTED }
}
}
assertFalse(HermesWsClient.isConnected)
assertEquals("stored-session", receivedEvent.storedSessionId)
}
@Test @Test
fun testDisconnect() { fun testDisconnect() {
val serverLatch = CountDownLatch(1) val serverLatch = CountDownLatch(1)
@@ -1238,6 +1238,7 @@ class ChatViewModelTest {
fun testSendMessage() = fun testSendMessage() =
runTest { runTest {
val (viewModel, sessionId) = createViewModelWithSession() val (viewModel, sessionId) = createViewModelWithSession()
ActiveSessionHolder.set(sessionId, "stale-session")
viewModel.sendMessage("Hello Hermes") viewModel.sendMessage("Hello Hermes")
advanceUntilIdle() advanceUntilIdle()
@@ -1254,6 +1255,7 @@ class ChatViewModelTest {
.role, .role,
) )
assertTrue(viewModel.uiState.value.isAgentTyping) assertTrue(viewModel.uiState.value.isAgentTyping)
assertEquals(sessionId, ActiveSessionHolder.resolveStoredSessionId(sessionId))
verify { HermesWsClient.sendMessage(sessionId, "Hello Hermes", any()) } verify { HermesWsClient.sendMessage(sessionId, "Hello Hermes", any()) }
} }