From 0fabad820d921693f6dc51c3e812abf02a452630 Mon Sep 17 00:00:00 2001 From: M57 <41645758+Hy4ri@users.noreply.github.com> Date: Sat, 20 Jun 2026 00:40:16 +0300 Subject: [PATCH] fix(chat): fix old session resume losing server session ID * fix(chat): reset agent typing status when resuming old sessions * fix(chat): fix race condition in websocket request registration * test(chat): update mocks for new send/sendMessage onSent callback * style(chat): use named onSent param instead of trailing lambda * style(chat): fix import ordering for ConcurrentHashMap * test(chat): fix remaining 2-arg verify calls for send() --- .../hermescontrol/data/ws/HermesWsClient.kt | 4 ++ .../hermescontrol/ui/chat/ChatViewModel.kt | 61 ++++++++++++------- .../ui/chat/ChatViewModelTest.kt | 42 +++++++++---- 3 files changed, 72 insertions(+), 35 deletions(-) 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 245e1161..7f201d41 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 @@ -136,8 +136,10 @@ object HermesWsClient { fun send( method: String, params: Map = emptyMap(), + onSent: ((String) -> Unit)? = null, ): String { val id = requestId.incrementAndGet().toString() + onSent?.invoke(id) val request = JsonRpcRequest(id = id, method = method, params = params) val json = gson.toJson(request) // B2 (Jun 18 2026, kanban t_8884db16): outgoing JSON contains user @@ -152,10 +154,12 @@ object HermesWsClient { fun sendMessage( sessionId: String, text: String, + onSent: ((String) -> Unit)? = null, ): String = send( method = WsMethods.PROMPT_SUBMIT, params = mapOf("session_id" to sessionId, "text" to text), + onSent = onSent, ) // ── Internal ───────────────────────────────────────────────────────── 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 63633add..46732d7d 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 @@ -21,6 +21,7 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import java.util.concurrent.ConcurrentHashMap data class ChatUiState( val messages: List = emptyList(), @@ -60,7 +61,7 @@ class ChatViewModel( val uiState: StateFlow = _uiState.asStateFlow() private val wsClient = HermesWsClient - private val pendingRequests = mutableMapOf() // requestId -> method + private val pendingRequests = ConcurrentHashMap() // requestId -> method private val dao = HermesDatabase.get(application).chatMessageDao() init { @@ -319,9 +320,17 @@ class ChatViewModel( } WsMethods.SESSION_RESUME -> { - _uiState.update { it.copy(isLoading = false) } - // Load cached messages from Room before the REST call - val sessionId = _uiState.value.currentSessionId + val resultMap = result as? Map + val sessionId = + (resultMap?.get("session_id") as? String) + ?: _uiState.value.currentSessionId + + _uiState.update { + it.copy( + isLoading = false, + currentSessionId = sessionId, + ) + } if (sessionId != null) { loadCachedMessages(sessionId) } @@ -382,8 +391,11 @@ class ChatViewModel( } viewModelScope.launch(Dispatchers.IO) { - val requestId = wsClient.sendMessage(sessionId, text) - pendingRequests[requestId] = WsMethods.PROMPT_SUBMIT + wsClient.sendMessage( + sessionId, + text, + onSent = { id -> pendingRequests[id] = WsMethods.PROMPT_SUBMIT }, + ) } } @@ -530,27 +542,30 @@ class ChatViewModel( fun interruptSession() { val sessionId = _uiState.value.currentSessionId ?: return viewModelScope.launch(Dispatchers.IO) { - val requestId = - wsClient.send( - WsMethods.SESSION_INTERRUPT, - mapOf("session_id" to sessionId), - ) - pendingRequests[requestId] = WsMethods.SESSION_INTERRUPT + wsClient.send( + WsMethods.SESSION_INTERRUPT, + mapOf("session_id" to sessionId), + onSent = { id -> pendingRequests[id] = WsMethods.SESSION_INTERRUPT }, + ) } } fun createNewSession() { _uiState.update { it.copy(isLoading = true, messages = emptyList()) } viewModelScope.launch(Dispatchers.IO) { - val requestId = wsClient.send(WsMethods.SESSION_CREATE) - pendingRequests[requestId] = WsMethods.SESSION_CREATE + wsClient.send( + WsMethods.SESSION_CREATE, + onSent = { id -> pendingRequests[id] = WsMethods.SESSION_CREATE }, + ) } } fun loadSessions() { viewModelScope.launch(Dispatchers.IO) { - val requestId = wsClient.send(WsMethods.SESSION_LIST) - pendingRequests[requestId] = WsMethods.SESSION_LIST + wsClient.send( + WsMethods.SESSION_LIST, + onSent = { id -> pendingRequests[id] = WsMethods.SESSION_LIST }, + ) } } @@ -562,18 +577,20 @@ class ChatViewModel( messages = emptyList(), currentSessionId = sessionId, showSessionPicker = false, + isAgentTyping = false, + isThinking = false, + thinkingText = "", ) } // Load cached messages first — instant display loadCachedMessages(sessionId) // Then resume session on server viewModelScope.launch(Dispatchers.IO) { - val requestId = - wsClient.send( - WsMethods.SESSION_RESUME, - mapOf("session_id" to sessionId), - ) - pendingRequests[requestId] = WsMethods.SESSION_RESUME + wsClient.send( + WsMethods.SESSION_RESUME, + mapOf("session_id" to sessionId), + onSent = { id -> pendingRequests[id] = WsMethods.SESSION_RESUME }, + ) } loadSessionMessages(sessionId) } diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt index 7660e9dd..96c1cf21 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt @@ -72,13 +72,19 @@ class ChatViewModelTest { // Default mock stubs for requests returning unique IDs var reqCount = 0 - every { HermesWsClient.send(any(), any()) } answers { + every { HermesWsClient.send(any(), any(), any()) } answers { reqCount++ - "req-id-$reqCount" + val id = "req-id-$reqCount" + val onSent = arg<((String) -> Unit)?>(2) + onSent?.invoke(id) + id } - every { HermesWsClient.sendMessage(any(), any()) } answers { + every { HermesWsClient.sendMessage(any(), any(), any()) } answers { reqCount++ - "req-msg-$reqCount" + val id = "req-msg-$reqCount" + val onSent = arg<((String) -> Unit)?>(2) + onSent?.invoke(id) + id } } @@ -115,16 +121,18 @@ class ChatViewModelTest { assertEquals(0, state.messages.size) // Verify that list sessions and create session requests are triggered - verify { HermesWsClient.send(WsMethods.SESSION_LIST) } - verify { HermesWsClient.send(WsMethods.SESSION_CREATE) } + verify { HermesWsClient.send(WsMethods.SESSION_LIST, any(), any()) } + verify { HermesWsClient.send(WsMethods.SESSION_CREATE, any(), any()) } } @Test fun testSessionCreateRpcResult() = runTest { var createReqId = "" - every { HermesWsClient.send(WsMethods.SESSION_CREATE, any()) } answers { + every { HermesWsClient.send(WsMethods.SESSION_CREATE, any(), any()) } answers { createReqId = "custom-create-id" + val onSent = arg<((String) -> Unit)?>(2) + onSent?.invoke(createReqId) createReqId } @@ -151,8 +159,10 @@ class ChatViewModelTest { fun testSessionListRpcResult() = runTest { var listReqId = "" - every { HermesWsClient.send(WsMethods.SESSION_LIST, any()) } answers { + every { HermesWsClient.send(WsMethods.SESSION_LIST, any(), any()) } answers { listReqId = "custom-list-id" + val onSent = arg<((String) -> Unit)?>(2) + onSent?.invoke(listReqId) listReqId } @@ -258,8 +268,10 @@ class ChatViewModelTest { fun testClarifyRequestAndRespond() = runTest { var createReqId = "" - every { HermesWsClient.send(WsMethods.SESSION_CREATE, any()) } answers { + every { HermesWsClient.send(WsMethods.SESSION_CREATE, any(), any()) } answers { createReqId = "create-req-clarify" + val onSent = arg<((String) -> Unit)?>(2) + onSent?.invoke(createReqId) createReqId } @@ -302,8 +314,10 @@ class ChatViewModelTest { // Trigger GatewayReady -> triggers createSession -> feed result to set active session var createReqId = "" - every { HermesWsClient.send(WsMethods.SESSION_CREATE, any()) } answers { + every { HermesWsClient.send(WsMethods.SESSION_CREATE, any(), any()) } answers { createReqId = "create-req" + val onSent = arg<((String) -> Unit)?>(2) + onSent?.invoke(createReqId) createReqId } mockEventsFlow.emit(WsEvent.GatewayReady(null)) @@ -324,7 +338,7 @@ class ChatViewModelTest { assertEquals(MessageRole.USER, state.messages[1].role) assertTrue(state.isAgentTyping) - verify { HermesWsClient.sendMessage("session-123", "Hello Hermes") } + verify { HermesWsClient.sendMessage("session-123", "Hello Hermes", any()) } } @Test @@ -348,15 +362,17 @@ class ChatViewModelTest { assertTrue("messages should be cleared by switchSession", state.messages.isEmpty()) assertNotNull("errorMessage should be set on load failure", state.errorMessage) - verify { HermesWsClient.send(WsMethods.SESSION_RESUME, mapOf("session_id" to "session-456")) } + verify { HermesWsClient.send(WsMethods.SESSION_RESUME, mapOf("session_id" to "session-456"), any()) } } @Test fun testRpcErrorHandling() = runTest { var createReqId = "" - every { HermesWsClient.send(WsMethods.SESSION_CREATE, any()) } answers { + every { HermesWsClient.send(WsMethods.SESSION_CREATE, any(), any()) } answers { createReqId = "create-req-err" + val onSent = arg<((String) -> Unit)?>(2) + onSent?.invoke(createReqId) createReqId }