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()
This commit is contained in:
M57 authored and GitHub committed 2026-06-20 00:40:16 +03:00
1 parent 46b24593f9
commit 0fabad820d
3 files changed
+64 -27

No files matched your search

@@ -136,8 +136,10 @@ object HermesWsClient {
fun send( fun send(
method: String, method: String,
params: Map<String, Any> = emptyMap(), params: Map<String, Any> = emptyMap(),
onSent: ((String) -> Unit)? = null,
): String { ): String {
val id = requestId.incrementAndGet().toString() val id = requestId.incrementAndGet().toString()
onSent?.invoke(id)
val request = JsonRpcRequest(id = id, method = method, params = params) val request = JsonRpcRequest(id = id, method = method, params = params)
val json = gson.toJson(request) val json = gson.toJson(request)
// B2 (Jun 18 2026, kanban t_8884db16): outgoing JSON contains user // B2 (Jun 18 2026, kanban t_8884db16): outgoing JSON contains user
@@ -152,10 +154,12 @@ object HermesWsClient {
fun sendMessage( fun sendMessage(
sessionId: String, sessionId: String,
text: String, text: String,
onSent: ((String) -> Unit)? = null,
): String = ): String =
send( 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),
onSent = onSent,
) )
// ── Internal ───────────────────────────────────────────────────────── // ── Internal ─────────────────────────────────────────────────────────
@@ -21,6 +21,7 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.util.concurrent.ConcurrentHashMap
data class ChatUiState( data class ChatUiState(
val messages: List<ChatMessage> = emptyList(), val messages: List<ChatMessage> = emptyList(),
@@ -60,7 +61,7 @@ class ChatViewModel(
val uiState: StateFlow<ChatUiState> = _uiState.asStateFlow() val uiState: StateFlow<ChatUiState> = _uiState.asStateFlow()
private val wsClient = HermesWsClient private val wsClient = HermesWsClient
private val pendingRequests = mutableMapOf<String, String>() // requestId -> method private val pendingRequests = ConcurrentHashMap<String, String>() // requestId -> method
private val dao = HermesDatabase.get(application).chatMessageDao() private val dao = HermesDatabase.get(application).chatMessageDao()
init { init {
@@ -319,9 +320,17 @@ class ChatViewModel(
} }
WsMethods.SESSION_RESUME -> { WsMethods.SESSION_RESUME -> {
_uiState.update { it.copy(isLoading = false) } val resultMap = result as? Map<String, Any?>
// Load cached messages from Room before the REST call val sessionId =
val sessionId = _uiState.value.currentSessionId (resultMap?.get("session_id") as? String)
?: _uiState.value.currentSessionId
_uiState.update {
it.copy(
isLoading = false,
currentSessionId = sessionId,
)
}
if (sessionId != null) { if (sessionId != null) {
loadCachedMessages(sessionId) loadCachedMessages(sessionId)
} }
@@ -382,8 +391,11 @@ class ChatViewModel(
} }
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
val requestId = wsClient.sendMessage(sessionId, text) wsClient.sendMessage(
pendingRequests[requestId] = WsMethods.PROMPT_SUBMIT sessionId,
text,
onSent = { id -> pendingRequests[id] = WsMethods.PROMPT_SUBMIT },
)
} }
} }
@@ -530,27 +542,30 @@ class ChatViewModel(
fun interruptSession() { fun interruptSession() {
val sessionId = _uiState.value.currentSessionId ?: return val sessionId = _uiState.value.currentSessionId ?: return
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
val requestId =
wsClient.send( wsClient.send(
WsMethods.SESSION_INTERRUPT, WsMethods.SESSION_INTERRUPT,
mapOf("session_id" to sessionId), mapOf("session_id" to sessionId),
onSent = { id -> pendingRequests[id] = WsMethods.SESSION_INTERRUPT },
) )
pendingRequests[requestId] = WsMethods.SESSION_INTERRUPT
} }
} }
fun createNewSession() { fun createNewSession() {
_uiState.update { it.copy(isLoading = true, messages = emptyList()) } _uiState.update { it.copy(isLoading = true, messages = emptyList()) }
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
val requestId = wsClient.send(WsMethods.SESSION_CREATE) wsClient.send(
pendingRequests[requestId] = WsMethods.SESSION_CREATE WsMethods.SESSION_CREATE,
onSent = { id -> pendingRequests[id] = WsMethods.SESSION_CREATE },
)
} }
} }
fun loadSessions() { fun loadSessions() {
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
val requestId = wsClient.send(WsMethods.SESSION_LIST) wsClient.send(
pendingRequests[requestId] = WsMethods.SESSION_LIST WsMethods.SESSION_LIST,
onSent = { id -> pendingRequests[id] = WsMethods.SESSION_LIST },
)
} }
} }
@@ -562,18 +577,20 @@ class ChatViewModel(
messages = emptyList(), messages = emptyList(),
currentSessionId = sessionId, currentSessionId = sessionId,
showSessionPicker = false, showSessionPicker = false,
isAgentTyping = false,
isThinking = false,
thinkingText = "",
) )
} }
// Load cached messages first — instant display // Load cached messages first — instant display
loadCachedMessages(sessionId) loadCachedMessages(sessionId)
// Then resume session on server // Then resume session on server
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
val requestId =
wsClient.send( wsClient.send(
WsMethods.SESSION_RESUME, WsMethods.SESSION_RESUME,
mapOf("session_id" to sessionId), mapOf("session_id" to sessionId),
onSent = { id -> pendingRequests[id] = WsMethods.SESSION_RESUME },
) )
pendingRequests[requestId] = WsMethods.SESSION_RESUME
} }
loadSessionMessages(sessionId) loadSessionMessages(sessionId)
} }
@@ -72,13 +72,19 @@ class ChatViewModelTest {
// Default mock stubs for requests returning unique IDs // Default mock stubs for requests returning unique IDs
var reqCount = 0 var reqCount = 0
every { HermesWsClient.send(any(), any()) } answers { every { HermesWsClient.send(any(), any(), any()) } answers {
reqCount++ 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++ 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) assertEquals(0, state.messages.size)
// Verify that list sessions and create session requests are triggered // Verify that list sessions and create session requests are triggered
verify { HermesWsClient.send(WsMethods.SESSION_LIST) } verify { HermesWsClient.send(WsMethods.SESSION_LIST, any(), any()) }
verify { HermesWsClient.send(WsMethods.SESSION_CREATE) } verify { HermesWsClient.send(WsMethods.SESSION_CREATE, any(), any()) }
} }
@Test @Test
fun testSessionCreateRpcResult() = fun testSessionCreateRpcResult() =
runTest { runTest {
var createReqId = "" var createReqId = ""
every { HermesWsClient.send(WsMethods.SESSION_CREATE, any()) } answers { every { HermesWsClient.send(WsMethods.SESSION_CREATE, any(), any()) } answers {
createReqId = "custom-create-id" createReqId = "custom-create-id"
val onSent = arg<((String) -> Unit)?>(2)
onSent?.invoke(createReqId)
createReqId createReqId
} }
@@ -151,8 +159,10 @@ class ChatViewModelTest {
fun testSessionListRpcResult() = fun testSessionListRpcResult() =
runTest { runTest {
var listReqId = "" var listReqId = ""
every { HermesWsClient.send(WsMethods.SESSION_LIST, any()) } answers { every { HermesWsClient.send(WsMethods.SESSION_LIST, any(), any()) } answers {
listReqId = "custom-list-id" listReqId = "custom-list-id"
val onSent = arg<((String) -> Unit)?>(2)
onSent?.invoke(listReqId)
listReqId listReqId
} }
@@ -258,8 +268,10 @@ class ChatViewModelTest {
fun testClarifyRequestAndRespond() = fun testClarifyRequestAndRespond() =
runTest { runTest {
var createReqId = "" var createReqId = ""
every { HermesWsClient.send(WsMethods.SESSION_CREATE, any()) } answers { every { HermesWsClient.send(WsMethods.SESSION_CREATE, any(), any()) } answers {
createReqId = "create-req-clarify" createReqId = "create-req-clarify"
val onSent = arg<((String) -> Unit)?>(2)
onSent?.invoke(createReqId)
createReqId createReqId
} }
@@ -302,8 +314,10 @@ class ChatViewModelTest {
// Trigger GatewayReady -> triggers createSession -> feed result to set active session // Trigger GatewayReady -> triggers createSession -> feed result to set active session
var createReqId = "" var createReqId = ""
every { HermesWsClient.send(WsMethods.SESSION_CREATE, any()) } answers { every { HermesWsClient.send(WsMethods.SESSION_CREATE, any(), any()) } answers {
createReqId = "create-req" createReqId = "create-req"
val onSent = arg<((String) -> Unit)?>(2)
onSent?.invoke(createReqId)
createReqId createReqId
} }
mockEventsFlow.emit(WsEvent.GatewayReady(null)) mockEventsFlow.emit(WsEvent.GatewayReady(null))
@@ -324,7 +338,7 @@ class ChatViewModelTest {
assertEquals(MessageRole.USER, state.messages[1].role) assertEquals(MessageRole.USER, state.messages[1].role)
assertTrue(state.isAgentTyping) assertTrue(state.isAgentTyping)
verify { HermesWsClient.sendMessage("session-123", "Hello Hermes") } verify { HermesWsClient.sendMessage("session-123", "Hello Hermes", any()) }
} }
@Test @Test
@@ -348,15 +362,17 @@ class ChatViewModelTest {
assertTrue("messages should be cleared by switchSession", state.messages.isEmpty()) assertTrue("messages should be cleared by switchSession", state.messages.isEmpty())
assertNotNull("errorMessage should be set on load failure", state.errorMessage) 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 @Test
fun testRpcErrorHandling() = fun testRpcErrorHandling() =
runTest { runTest {
var createReqId = "" var createReqId = ""
every { HermesWsClient.send(WsMethods.SESSION_CREATE, any()) } answers { every { HermesWsClient.send(WsMethods.SESSION_CREATE, any(), any()) } answers {
createReqId = "create-req-err" createReqId = "create-req-err"
val onSent = arg<((String) -> Unit)?>(2)
onSent?.invoke(createReqId)
createReqId createReqId
} }