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
+72 -35

No files matched your search

@@ -136,8 +136,10 @@ object HermesWsClient {
fun send(
method: String,
params: Map<String, Any> = 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 ─────────────────────────────────────────────────────────
@@ -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<ChatMessage> = emptyList(),
@@ -60,7 +61,7 @@ class ChatViewModel(
val uiState: StateFlow<ChatUiState> = _uiState.asStateFlow()
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()
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<String, Any?>
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)
}
@@ -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
}