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:
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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user