Merge pull request #151 from Hy4ri/refactor/chat-system-stability

refactor: stabilize chat system streaming, session loading, and connection state
This commit is contained in:
M57 authored and GitHub committed 2026-06-20 12:12:26 +03:00
commit 196883b5c1
4 files changed
+508 -219

No files matched your search

@@ -73,9 +73,9 @@ import com.m57.hermescontrol.theme.ToolChipColor
import com.m57.hermescontrol.theme.ToolChipColorLight import com.m57.hermescontrol.theme.ToolChipColorLight
import com.m57.hermescontrol.theme.UserBubble import com.m57.hermescontrol.theme.UserBubble
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import java.text.SimpleDateFormat import java.time.Instant
import java.util.Date import java.time.ZoneId
import java.util.Locale import java.time.format.DateTimeFormatter
@Composable @Composable
fun ChatBubble( fun ChatBubble(
@@ -811,9 +811,12 @@ private fun RichText(
) )
} }
private val TIMESTAMP_FORMATTER =
DateTimeFormatter.ofPattern("HH:mm")
.withZone(ZoneId.systemDefault())
private fun formatTimestamp(timestamp: Long): String { private fun formatTimestamp(timestamp: Long): String {
val sdf = SimpleDateFormat("HH:mm", Locale.getDefault()) return TIMESTAMP_FORMATTER.format(Instant.ofEpochMilli(timestamp))
return sdf.format(Date(timestamp))
} }
/** /**
@@ -92,6 +92,7 @@ import androidx.compose.ui.unit.sp
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.m57.hermescontrol.data.ws.ConnectionStatus
import com.m57.hermescontrol.notification.NotificationHelper import com.m57.hermescontrol.notification.NotificationHelper
import com.m57.hermescontrol.theme.StatusRed import com.m57.hermescontrol.theme.StatusRed
import com.m57.hermescontrol.ui.common.EmptyState import com.m57.hermescontrol.ui.common.EmptyState
@@ -169,9 +170,13 @@ fun ChatScreen(
} }
// Auto-scroll to bottom on new messages // Auto-scroll to bottom on new messages
LaunchedEffect(state.messages.size, state.currentStreamingText) { LaunchedEffect(state.messages.size, state.streamingMessage?.content?.length, state.isThinking) {
if (state.messages.isNotEmpty()) { val totalItems =
listState.animateScrollToItem(state.messages.lastIndex) state.messages.size +
(if (state.streamingMessage != null) 1 else 0) +
(if (state.isThinking) 1 else 0)
if (totalItems > 0) {
listState.animateScrollToItem(totalItems - 1)
} }
} }
@@ -349,6 +354,50 @@ fun ChatScreen(
.background(backgroundGradient) .background(backgroundGradient)
.imePadding(), .imePadding(),
) { ) {
androidx.compose.animation.AnimatedVisibility(
visible =
state.connectionStatus == ConnectionStatus.RECONNECTING ||
state.connectionStatus == ConnectionStatus.DISCONNECTED,
enter = androidx.compose.animation.expandVertically() + androidx.compose.animation.fadeIn(),
exit = androidx.compose.animation.shrinkVertically() + androidx.compose.animation.fadeOut(),
) {
androidx.compose.material3.Surface(
modifier = Modifier.fillMaxWidth(),
color = MaterialTheme.colorScheme.errorContainer,
contentColor = MaterialTheme.colorScheme.onErrorContainer,
) {
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text =
if (state.connectionStatus == ConnectionStatus.RECONNECTING) {
"Reconnecting to Hermes…"
} else {
"Disconnected from Hermes"
},
style = MaterialTheme.typography.bodyMedium,
)
if (state.connectionStatus == ConnectionStatus.DISCONNECTED) {
TextButton(
onClick = { viewModel.reconnect() },
colors =
androidx.compose.material3.ButtonDefaults.textButtonColors(
contentColor = MaterialTheme.colorScheme.onErrorContainer,
),
) {
Text("Reconnect")
}
}
}
}
}
androidx.compose.animation.AnimatedVisibility( androidx.compose.animation.AnimatedVisibility(
visible = state.isSearchActive, visible = state.isSearchActive,
enter = androidx.compose.animation.expandVertically() + androidx.compose.animation.fadeIn(), enter = androidx.compose.animation.expandVertically() + androidx.compose.animation.fadeIn(),
@@ -414,6 +463,18 @@ fun ChatScreen(
) )
} }
// Streaming message — rendered separately for O(1) updates
state.streamingMessage?.let { streaming ->
item(key = "streaming-${streaming.id}") {
ChatBubble(
message = streaming,
isDarkTheme = isDark,
searchQuery = "",
isCurrentMatch = false,
)
}
}
// Thinking indicator // Thinking indicator
if (state.isThinking) { if (state.isThinking) {
item(key = "thinking") { item(key = "thinking") {
@@ -1,6 +1,7 @@
package com.m57.hermescontrol.ui.chat package com.m57.hermescontrol.ui.chat
import android.app.Application import android.app.Application
import android.util.Log
import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.m57.hermescontrol.data.local.AuthManager import com.m57.hermescontrol.data.local.AuthManager
@@ -10,30 +11,43 @@ import com.m57.hermescontrol.data.local.toUiModel
import com.m57.hermescontrol.data.remote.ApiClient import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.NetworkResult import com.m57.hermescontrol.data.remote.NetworkResult
import com.m57.hermescontrol.data.remote.safeApiCall import com.m57.hermescontrol.data.remote.safeApiCall
import com.m57.hermescontrol.data.ws.ConnectionStatus
import com.m57.hermescontrol.data.ws.HermesWsClient import com.m57.hermescontrol.data.ws.HermesWsClient
import com.m57.hermescontrol.data.ws.WsEvent import com.m57.hermescontrol.data.ws.WsEvent
import com.m57.hermescontrol.data.ws.WsMethods import com.m57.hermescontrol.data.ws.WsMethods
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
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 import java.util.concurrent.ConcurrentHashMap
private const val TAG = "ChatViewModel"
/**
* Pending request timeout — if the server doesn't respond within this
* window, the request is pruned and an error is surfaced.
*/
private const val PENDING_REQUEST_TIMEOUT_MS = 30_000L
data class ChatUiState( data class ChatUiState(
val messages: List<ChatMessage> = emptyList(), val messages: List<ChatMessage> = emptyList(),
val currentSessionId: String? = null, val currentSessionId: String? = null,
val sessions: List<SessionUi> = emptyList(), val sessions: List<SessionUi> = emptyList(),
val chatTitle: String = "Hermes", val chatTitle: String = "Hermes",
val isConnected: Boolean = false, val connectionStatus: ConnectionStatus = ConnectionStatus.DISCONNECTED,
val isAgentTyping: Boolean = false, val isAgentTyping: Boolean = false,
val isThinking: Boolean = false, val isThinking: Boolean = false,
val thinkingText: String = "", val thinkingText: String = "",
val isLoading: Boolean = false, val isLoading: Boolean = false,
val currentStreamingText: String = "", /** Standalone streaming message — rendered after the main list. */
val streamingMessage: ChatMessage? = null,
val errorMessage: String? = null, val errorMessage: String? = null,
val clarifyRequest: ClarifyUi? = null, val clarifyRequest: ClarifyUi? = null,
val showSessionPicker: Boolean = false, val showSessionPicker: Boolean = false,
@@ -42,7 +56,10 @@ data class ChatUiState(
val searchQuery: String = "", val searchQuery: String = "",
val searchMatchIndices: List<Int> = emptyList(), val searchMatchIndices: List<Int> = emptyList(),
val currentSearchMatchIndex: Int = -1, val currentSearchMatchIndex: Int = -1,
) ) {
/** Convenience — derived from [connectionStatus]. */
val isConnected: Boolean get() = connectionStatus == ConnectionStatus.CONNECTED
}
data class SessionUi( data class SessionUi(
val id: String, val id: String,
@@ -57,17 +74,49 @@ data class ClarifyUi(
class ChatViewModel( class ChatViewModel(
application: Application, application: Application,
private val startCleanup: Boolean,
) : AndroidViewModel(application) { ) : AndroidViewModel(application) {
constructor(application: Application) : this(application, startCleanup = true)
// ── Internal state ───────────────────────────────────────────────────
private val _uiState = MutableStateFlow(ChatUiState()) private val _uiState = MutableStateFlow(ChatUiState())
val uiState: StateFlow<ChatUiState> = _uiState.asStateFlow()
private val wsClient = HermesWsClient private val wsClient = HermesWsClient
private val pendingRequests = ConcurrentHashMap<String, String>() // requestId -> method private val pendingRequests = ConcurrentHashMap<String, PendingRequest>()
private val dao = HermesDatabase.get(application).chatMessageDao() private val dao = HermesDatabase.get(application).chatMessageDao()
/** Tracks the ID of the currently streaming assistant message. */
@Volatile
private var streamingMessageId: String? = null
private var pendingCleanupJob: Job? = null
// ── Public state ─────────────────────────────────────────────────────
/**
* Combined UI state: merges internal state with the WS connection status
* flow so there is a single source of truth for connection state.
*/
val uiState: StateFlow<ChatUiState> =
combine(
_uiState,
wsClient.connectionStatus,
) { state, connStatus ->
state.copy(connectionStatus = connStatus)
}.stateIn(
viewModelScope,
SharingStarted.Eagerly,
_uiState.value,
)
init { init {
connectWebSocket() connectWebSocket()
if (startCleanup) {
startPendingRequestCleanup()
} }
}
// ── Connection ───────────────────────────────────────────────────────
private fun connectWebSocket() { private fun connectWebSocket() {
val token = AuthManager.getToken() ?: return val token = AuthManager.getToken() ?: return
@@ -83,21 +132,14 @@ class ChatViewModel(
handleWsEvent(event) handleWsEvent(event)
} }
} }
}
if (wsClient.isConnected) { // ── WS Event Handling ────────────────────────────────────────────────
_uiState.update { it.copy(isConnected = true, isLoading = false) }
addSystemMessage("Connected to Hermes")
loadSessions()
if (_uiState.value.currentSessionId == null) {
createNewSession()
}
}
}
private fun handleWsEvent(event: WsEvent) { private fun handleWsEvent(event: WsEvent) {
when (event) { when (event) {
is WsEvent.GatewayReady -> { is WsEvent.GatewayReady -> {
_uiState.update { it.copy(isConnected = true, isLoading = false) } _uiState.update { it.copy(isLoading = false) }
addSystemMessage("Connected to Hermes") addSystemMessage("Connected to Hermes")
loadSessions() loadSessions()
if (_uiState.value.currentSessionId == null) { if (_uiState.value.currentSessionId == null) {
@@ -107,152 +149,13 @@ class ChatViewModel(
is WsEvent.SessionInfo -> {} is WsEvent.SessionInfo -> {}
is WsEvent.MessageStart -> { is WsEvent.MessageStart -> handleMessageStart(event)
_uiState.update { is WsEvent.MessageToken -> handleMessageToken(event)
it.copy( is WsEvent.ThinkingDelta -> handleThinkingDelta(event)
isAgentTyping = true, is WsEvent.MessageComplete -> handleMessageComplete(event)
currentStreamingText = "", is WsEvent.MessageDone -> handleMessageDone(event)
isThinking = false, is WsEvent.ToolStart -> handleToolStart(event)
thinkingText = "", is WsEvent.ToolComplete -> handleToolComplete(event)
)
}
}
is WsEvent.MessageToken -> {
_uiState.update { state ->
val newText = state.currentStreamingText + event.token
val messages = state.messages.toMutableList()
val streamingIdx =
messages.indexOfLast {
it.role == MessageRole.ASSISTANT && it.isStreaming
}
if (streamingIdx >= 0) {
messages[streamingIdx] = messages[streamingIdx].copy(content = newText)
} else {
messages.add(
ChatMessage(
role = MessageRole.ASSISTANT,
content = newText,
isStreaming = true,
),
)
}
// Persist streaming message to DB
val sessionId = state.currentSessionId
if (sessionId != null) {
val msg = messages.last()
viewModelScope.launch(Dispatchers.IO) {
dao.upsert(msg.toEntity(sessionId))
}
}
state.copy(
messages = messages,
currentStreamingText = newText,
isThinking = false,
)
}
}
is WsEvent.ThinkingDelta -> {
_uiState.update { state ->
state.copy(
isThinking = true,
thinkingText = state.thinkingText + event.token,
)
}
}
is WsEvent.MessageComplete -> {
_uiState.update { state ->
val messages = state.messages.toMutableList()
val streamingIdx =
messages.indexOfLast {
it.role == MessageRole.ASSISTANT && it.isStreaming
}
val finalMessage: ChatMessage
if (streamingIdx >= 0) {
finalMessage =
messages[streamingIdx].copy(
content = event.text,
isStreaming = false,
)
messages[streamingIdx] = finalMessage
} else {
finalMessage =
ChatMessage(
role = MessageRole.ASSISTANT,
content = event.text,
)
messages.add(finalMessage)
}
// Persist finalized message
val sessionId = state.currentSessionId
if (sessionId != null) {
viewModelScope.launch(Dispatchers.IO) {
dao.finalizeMessage(finalMessage.id, event.text)
}
}
state.copy(
messages = messages,
isAgentTyping = false,
currentStreamingText = "",
isThinking = false,
thinkingText = "",
)
}
}
is WsEvent.MessageDone -> {
_uiState.update {
it.copy(isAgentTyping = false, isThinking = false, thinkingText = "")
}
}
is WsEvent.ToolStart -> {
val toolMessage =
ChatMessage(
role = MessageRole.TOOL,
content = event.data.toString(),
toolName = event.name,
toolStatus = ToolStatus.RUNNING,
)
_uiState.update { state ->
val sessionId = state.currentSessionId
if (sessionId != null) {
viewModelScope.launch(Dispatchers.IO) {
dao.upsert(toolMessage.toEntity(sessionId))
}
}
state.copy(messages = state.messages + toolMessage)
}
}
is WsEvent.ToolComplete -> {
_uiState.update { state ->
val messages = state.messages.toMutableList()
val toolIdx =
messages.indexOfLast {
it.role == MessageRole.TOOL &&
it.toolName == event.name &&
it.toolStatus == ToolStatus.RUNNING
}
if (toolIdx >= 0) {
messages[toolIdx] =
messages[toolIdx].copy(
toolStatus = ToolStatus.COMPLETED,
content = event.data.toString(),
)
// Persist tool completion
val sessionId = state.currentSessionId
if (sessionId != null) {
viewModelScope.launch(Dispatchers.IO) {
dao.upsert(messages[toolIdx].toEntity(sessionId))
}
}
}
state.copy(messages = messages)
}
}
is WsEvent.ClarifyRequest -> { is WsEvent.ClarifyRequest -> {
_uiState.update { _uiState.update {
@@ -284,12 +187,227 @@ class ChatViewModel(
} }
} }
// ── Message streaming ────────────────────────────────────────────────
/**
* Checks if an incoming WS event belongs to the currently active
* session. Returns true if the event should be processed.
*/
private fun isCurrentSession(eventSessionId: String?): Boolean {
// If the event has no session ID, process it (legacy compatibility)
if (eventSessionId == null) return true
return eventSessionId == _uiState.value.currentSessionId
}
private fun handleMessageStart(event: WsEvent.MessageStart) {
if (!isCurrentSession(event.sessionId)) return
// Create the streaming message as a standalone state field.
val msg =
ChatMessage(
role = MessageRole.ASSISTANT,
content = "",
isStreaming = true,
)
streamingMessageId = msg.id
_uiState.update {
it.copy(
isAgentTyping = true,
streamingMessage = msg,
isThinking = false,
thinkingText = "",
)
}
}
private fun handleMessageToken(event: WsEvent.MessageToken) {
if (!isCurrentSession(event.sessionId)) return
_uiState.update { state ->
val current = state.streamingMessage
if (current != null) {
state.copy(
streamingMessage =
current.copy(
content = current.content + event.token,
),
isThinking = false,
)
} else {
// Fallback: no MessageStart was received — create one now
val msg =
ChatMessage(
role = MessageRole.ASSISTANT,
content = event.token,
isStreaming = true,
)
streamingMessageId = msg.id
state.copy(
streamingMessage = msg,
isAgentTyping = true,
isThinking = false,
)
}
}
}
private fun handleThinkingDelta(event: WsEvent.ThinkingDelta) {
if (!isCurrentSession(event.sessionId)) return
_uiState.update { state ->
state.copy(
isThinking = true,
thinkingText = state.thinkingText + event.token,
)
}
}
private fun handleMessageComplete(event: WsEvent.MessageComplete) {
if (!isCurrentSession(event.sessionId)) return
var finalizedMsg: ChatMessage? = null
var sessionId: String? = null
_uiState.update { state ->
val streaming = state.streamingMessage
val msg =
if (streaming != null) {
streaming.copy(
content = event.text,
isStreaming = false,
)
} else {
ChatMessage(
role = MessageRole.ASSISTANT,
content = event.text,
)
}
finalizedMsg = msg
sessionId = state.currentSessionId
state.copy(
messages = state.messages + msg,
streamingMessage = null,
isAgentTyping = false,
isThinking = false,
thinkingText = "",
)
}
streamingMessageId = null
// Persist finalized message — OUTSIDE update{} to avoid CAS retry
val msgToPersist = finalizedMsg
val sid = sessionId ?: _uiState.value.currentSessionId
if (msgToPersist != null && sid != null) {
viewModelScope.launch(Dispatchers.IO) {
dao.upsert(msgToPersist.toEntity(sid))
}
}
}
private fun handleMessageDone(event: WsEvent.MessageDone) {
if (!isCurrentSession(event.sessionId)) return
// If we still have an un-finalized streaming message (no MessageComplete
// was sent, which happens on interrupts), fold it into the list.
var orphan: ChatMessage? = null
var sessionId: String? = null
_uiState.update { state ->
val streaming = state.streamingMessage
val msg = streaming?.copy(isStreaming = false)
orphan = msg
sessionId = state.currentSessionId
if (msg != null) {
state.copy(
messages = state.messages + msg,
streamingMessage = null,
isAgentTyping = false,
isThinking = false,
thinkingText = "",
)
} else {
state.copy(
isAgentTyping = false,
isThinking = false,
thinkingText = "",
)
}
}
streamingMessageId = null
// Persist orphaned streaming message
val msgToPersist = orphan
val sid = sessionId ?: _uiState.value.currentSessionId
if (msgToPersist != null && sid != null) {
viewModelScope.launch(Dispatchers.IO) {
dao.upsert(msgToPersist.toEntity(sid))
}
}
}
// ── Tool events ──────────────────────────────────────────────────────
private fun handleToolStart(event: WsEvent.ToolStart) {
val toolMessage =
ChatMessage(
role = MessageRole.TOOL,
content = event.data.toString(),
toolName = event.name,
toolStatus = ToolStatus.RUNNING,
)
_uiState.update { state ->
state.copy(messages = state.messages + toolMessage)
}
// Persist — OUTSIDE update{}
val sessionId = _uiState.value.currentSessionId
if (sessionId != null) {
viewModelScope.launch(Dispatchers.IO) {
dao.upsert(toolMessage.toEntity(sessionId))
}
}
}
private fun handleToolComplete(event: WsEvent.ToolComplete) {
var completedTool: ChatMessage? = null
_uiState.update { state ->
val messages = state.messages.toMutableList()
val toolIdx =
messages.indexOfLast {
it.role == MessageRole.TOOL &&
it.toolName == event.name &&
it.toolStatus == ToolStatus.RUNNING
}
if (toolIdx >= 0) {
val updated =
messages[toolIdx].copy(
toolStatus = ToolStatus.COMPLETED,
content = event.data.toString(),
)
messages[toolIdx] = updated
completedTool = updated
}
state.copy(messages = messages)
}
// Persist — OUTSIDE update{}
val tool = completedTool
val sessionId = _uiState.value.currentSessionId
if (tool != null && sessionId != null) {
viewModelScope.launch(Dispatchers.IO) {
dao.upsert(tool.toEntity(sessionId))
}
}
}
// ── RPC response handling ────────────────────────────────────────────
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
private fun handleRpcResult( private fun handleRpcResult(
id: String, id: String,
result: Any?, result: Any?,
) { ) {
val method = pendingRequests.remove(id) ?: return val pending = pendingRequests.remove(id) ?: return
val method = pending.method
when (method) { when (method) {
WsMethods.SESSION_CREATE -> { WsMethods.SESSION_CREATE -> {
@@ -300,6 +418,7 @@ class ChatViewModel(
currentSessionId = sessionId, currentSessionId = sessionId,
isLoading = false, isLoading = false,
messages = emptyList(), messages = emptyList(),
streamingMessage = null,
) )
} }
addSystemMessage("Session created", persist = true) addSystemMessage("Session created", persist = true)
@@ -347,8 +466,13 @@ class ChatViewModel(
WsMethods.SESSION_INTERRUPT -> { WsMethods.SESSION_INTERRUPT -> {
_uiState.update { _uiState.update {
it.copy(isAgentTyping = false, isThinking = false) it.copy(
isAgentTyping = false,
isThinking = false,
streamingMessage = null,
)
} }
streamingMessageId = null
addSystemMessage("Session interrupted") addSystemMessage("Session interrupted")
} }
} }
@@ -358,7 +482,8 @@ class ChatViewModel(
id: String, id: String,
error: Any?, error: Any?,
) { ) {
val method = pendingRequests.remove(id) val pending = pendingRequests.remove(id)
val method = pending?.method
val errorMsg = val errorMsg =
when (error) { when (error) {
is Map<*, *> -> error["message"] as? String ?: error.toString() is Map<*, *> -> error["message"] as? String ?: error.toString()
@@ -372,6 +497,8 @@ class ChatViewModel(
} }
} }
// ── Send message ─────────────────────────────────────────────────────
fun sendMessage(text: String) { fun sendMessage(text: String) {
if (text.isBlank()) return if (text.isBlank()) return
val sessionId = _uiState.value.currentSessionId ?: return val sessionId = _uiState.value.currentSessionId ?: return
@@ -387,22 +514,25 @@ class ChatViewModel(
role = MessageRole.USER, role = MessageRole.USER,
content = text, content = text,
) )
// Update UI — no DB writes inside update{}
_uiState.update { state -> _uiState.update { state ->
val messages = state.messages + userMessage
viewModelScope.launch(Dispatchers.IO) {
dao.upsert(userMessage.toEntity(sessionId))
}
state.copy( state.copy(
messages = messages, messages = state.messages + userMessage,
isAgentTyping = true, isAgentTyping = true,
) )
} }
// Persist to Room — OUTSIDE update{}
viewModelScope.launch(Dispatchers.IO) {
dao.upsert(userMessage.toEntity(sessionId))
}
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
wsClient.sendMessage( wsClient.sendMessage(
sessionId, sessionId,
text, text,
onSent = { id -> pendingRequests[id] = WsMethods.PROMPT_SUBMIT }, onSent = { id -> trackRequest(id, WsMethods.PROMPT_SUBMIT) },
) )
} }
} }
@@ -410,7 +540,10 @@ class ChatViewModel(
private fun handleSlashCommand(command: String) { private fun handleSlashCommand(command: String) {
val userMsg = ChatMessage(role = MessageRole.USER, content = command) val userMsg = ChatMessage(role = MessageRole.USER, content = command)
val sessionId = _uiState.value.currentSessionId val sessionId = _uiState.value.currentSessionId
_uiState.update { it.copy(messages = it.messages + userMsg) } _uiState.update { it.copy(messages = it.messages + userMsg) }
// Persist — OUTSIDE update{}
if (sessionId != null) { if (sessionId != null) {
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
dao.upsert(userMsg.toEntity(sessionId)) dao.upsert(userMsg.toEntity(sessionId))
@@ -538,8 +671,10 @@ class ChatViewModel(
private fun addAssistantMessage(text: String) { private fun addAssistantMessage(text: String) {
val msg = ChatMessage(role = MessageRole.ASSISTANT, content = text) val msg = ChatMessage(role = MessageRole.ASSISTANT, content = text)
val sessionId = _uiState.value.currentSessionId
_uiState.update { it.copy(messages = it.messages + msg) } _uiState.update { it.copy(messages = it.messages + msg) }
// Persist — OUTSIDE update{}
val sessionId = _uiState.value.currentSessionId
if (sessionId != null) { if (sessionId != null) {
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
dao.upsert(msg.toEntity(sessionId)) dao.upsert(msg.toEntity(sessionId))
@@ -547,23 +682,32 @@ class ChatViewModel(
} }
} }
// ── Session management ───────────────────────────────────────────────
fun interruptSession() { fun interruptSession() {
val sessionId = _uiState.value.currentSessionId ?: return val sessionId = _uiState.value.currentSessionId ?: return
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
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 }, onSent = { id -> trackRequest(id, WsMethods.SESSION_INTERRUPT) },
) )
} }
} }
fun createNewSession() { fun createNewSession() {
_uiState.update { it.copy(isLoading = true, messages = emptyList()) } _uiState.update {
it.copy(
isLoading = true,
messages = emptyList(),
streamingMessage = null,
)
}
streamingMessageId = null
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
wsClient.send( wsClient.send(
WsMethods.SESSION_CREATE, WsMethods.SESSION_CREATE,
onSent = { id -> pendingRequests[id] = WsMethods.SESSION_CREATE }, onSent = { id -> trackRequest(id, WsMethods.SESSION_CREATE) },
) )
} }
} }
@@ -572,18 +716,23 @@ class ChatViewModel(
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
wsClient.send( wsClient.send(
WsMethods.SESSION_LIST, WsMethods.SESSION_LIST,
onSent = { id -> pendingRequests[id] = WsMethods.SESSION_LIST }, onSent = { id -> trackRequest(id, WsMethods.SESSION_LIST) },
) )
} }
} }
fun switchSession(sessionId: String) { fun switchSession(sessionId: String) {
if (sessionId == _uiState.value.currentSessionId) return if (sessionId == _uiState.value.currentSessionId) return
// Reset streaming state
streamingMessageId = null
_uiState.update { _uiState.update {
val title = it.sessions.find { s -> s.id == sessionId }?.title ?: "Hermes" val title = it.sessions.find { s -> s.id == sessionId }?.title ?: "Hermes"
it.copy( it.copy(
isLoading = true, isLoading = true,
messages = emptyList(), messages = emptyList(),
streamingMessage = null,
currentSessionId = sessionId, currentSessionId = sessionId,
chatTitle = title, chatTitle = title,
showSessionPicker = false, showSessionPicker = false,
@@ -592,16 +741,17 @@ class ChatViewModel(
thinkingText = "", thinkingText = "",
) )
} }
// Load cached messages first — instant display // Step 1: Load cached messages first — instant display
loadCachedMessages(sessionId) loadCachedMessages(sessionId)
// Then resume session on server // Step 2: Resume session on server
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
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 }, onSent = { id -> trackRequest(id, WsMethods.SESSION_RESUME) },
) )
} }
// Step 3: Load fresh messages from REST and merge
loadSessionMessages(sessionId) loadSessionMessages(sessionId)
} }
@@ -610,7 +760,7 @@ class ChatViewModel(
val cached = dao.getMessagesForSession(sessionId) val cached = dao.getMessagesForSession(sessionId)
val cachedMessages = cached.map { it.toUiModel() } val cachedMessages = cached.map { it.toUiModel() }
_uiState.update { state -> _uiState.update { state ->
// Only replace if still showing this session and messages are empty/newer // Only replace if still showing this session
if (state.currentSessionId == sessionId) { if (state.currentSessionId == sessionId) {
state.copy(messages = cachedMessages, isLoading = false) state.copy(messages = cachedMessages, isLoading = false)
} else { } else {
@@ -630,7 +780,7 @@ class ChatViewModel(
is NetworkResult.Success -> { is NetworkResult.Success -> {
val messagesList = result.data.messages.orEmpty() val messagesList = result.data.messages.orEmpty()
val chatMessages = val chatMessages =
messagesList.map { msg -> messagesList.mapIndexed { index, msg ->
val role = val role =
when (msg.role?.lowercase()) { when (msg.role?.lowercase()) {
"user" -> MessageRole.USER "user" -> MessageRole.USER
@@ -638,28 +788,45 @@ class ChatViewModel(
"tool" -> MessageRole.TOOL "tool" -> MessageRole.TOOL
else -> MessageRole.ASSISTANT else -> MessageRole.ASSISTANT
} }
// Use stable IDs based on session + index to prevent
// Room duplicates when re-loading the same session.
val stableId = "rest-$sessionId-$index"
// Preserve original timestamp from the API when available
val ts = msg.timestamp?.toLongOrNull() ?: System.currentTimeMillis()
ChatMessage( ChatMessage(
id = stableId,
role = role, role = role,
content = msg.content.orEmpty(), content = msg.content.orEmpty(),
timestamp = ts,
isStreaming = false, isStreaming = false,
) )
} }
// Persist loaded messages to Room for offline access // Persist loaded messages to Room for offline access
val entities = chatMessages.map { it.toEntity(sessionId) } val entities = chatMessages.map { it.toEntity(sessionId) }
viewModelScope.launch(Dispatchers.IO) { withContext(Dispatchers.IO) {
dao.upsertAll(entities) dao.upsertAll(entities)
} }
_uiState.update { state -> _uiState.update { state ->
val streamingTail = state.messages.filter { it.isStreaming } // Only update if still on the same session
if (state.currentSessionId != sessionId) return@update state
// Merge: keep any user messages sent between cache load
// and REST response (they won't be in the REST response
// yet), plus preserve any active streaming message.
val existingIds = chatMessages.map { it.id }.toSet()
val localOnly = state.messages.filter { it.id !in existingIds }
state.copy( state.copy(
messages = chatMessages + streamingTail, messages = chatMessages + localOnly,
isLoading = false, isLoading = false,
) )
} }
} }
is NetworkResult.Failure -> { is NetworkResult.Failure -> {
// Don't overwrite messages on REST failure — cached messages
// are still valid.
_uiState.update { _uiState.update {
if (it.currentSessionId != sessionId) return@update it
it.copy( it.copy(
isLoading = false, isLoading = false,
errorMessage = "Failed to load messages: ${result.error.message}", errorMessage = "Failed to load messages: ${result.error.message}",
@@ -670,6 +837,8 @@ class ChatViewModel(
} }
} }
// ── UI actions ───────────────────────────────────────────────────────
fun toggleSessionPicker() { fun toggleSessionPicker() {
_uiState.update { it.copy(showSessionPicker = !it.showSessionPicker) } _uiState.update { it.copy(showSessionPicker = !it.showSessionPicker) }
} }
@@ -690,7 +859,6 @@ class ChatViewModel(
fun reconnect() { fun reconnect() {
_uiState.update { _uiState.update {
it.copy( it.copy(
isConnected = false,
isLoading = true, isLoading = true,
errorMessage = null, errorMessage = null,
) )
@@ -709,18 +877,55 @@ class ChatViewModel(
persist: Boolean = false, persist: Boolean = false,
) { ) {
val msg = ChatMessage(role = MessageRole.SYSTEM, content = text) val msg = ChatMessage(role = MessageRole.SYSTEM, content = text)
_uiState.update { it.copy(messages = it.messages + msg) }
if (persist) {
val sessionId = _uiState.value.currentSessionId val sessionId = _uiState.value.currentSessionId
if (sessionId != null) {
_uiState.update { it.copy(messages = it.messages + msg) }
// Persist — OUTSIDE update{}
if (persist && sessionId != null) {
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
dao.upsert(msg.toEntity(sessionId)) dao.upsert(msg.toEntity(sessionId))
} }
} }
} }
// ── Pending request tracking ─────────────────────────────────────────
private data class PendingRequest(
val method: String,
val createdAt: Long = System.currentTimeMillis(),
)
private fun trackRequest(
id: String,
method: String,
) {
pendingRequests[id] = PendingRequest(method)
} }
// ── Search ──────────────────────────────────────────────────────── /**
* Periodically prunes stale pending requests that the server never
* responded to. Surfaces a timeout error for the user.
*/
private fun startPendingRequestCleanup() {
pendingCleanupJob =
viewModelScope.launch {
while (true) {
delay(PENDING_REQUEST_TIMEOUT_MS)
val now = System.currentTimeMillis()
val stale =
pendingRequests.entries.filter {
now - it.value.createdAt > PENDING_REQUEST_TIMEOUT_MS
}
for (entry in stale) {
pendingRequests.remove(entry.key)
Log.w(TAG, "Request timed out: ${entry.value.method} (id=${entry.key})")
}
}
}
}
// ── Search ────────────────────────────────────────────────────────────
fun toggleSearch() { fun toggleSearch() {
val current = _uiState.value val current = _uiState.value
@@ -776,8 +981,11 @@ class ChatViewModel(
} }
} }
// ── Lifecycle ─────────────────────────────────────────────────────────
override fun onCleared() { override fun onCleared() {
super.onCleared() super.onCleared()
pendingCleanupJob?.cancel()
wsClient.disconnect() wsClient.disconnect()
} }
} }
@@ -4,6 +4,7 @@ import android.app.Application
import com.m57.hermescontrol.data.local.AuthManager import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.local.ChatMessageDao import com.m57.hermescontrol.data.local.ChatMessageDao
import com.m57.hermescontrol.data.local.HermesDatabase import com.m57.hermescontrol.data.local.HermesDatabase
import com.m57.hermescontrol.data.ws.ConnectionStatus
import com.m57.hermescontrol.data.ws.HermesWsClient import com.m57.hermescontrol.data.ws.HermesWsClient
import com.m57.hermescontrol.data.ws.JsonRpcError import com.m57.hermescontrol.data.ws.JsonRpcError
import com.m57.hermescontrol.data.ws.WsEvent import com.m57.hermescontrol.data.ws.WsEvent
@@ -18,6 +19,7 @@ import io.mockk.verify
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.advanceUntilIdle
@@ -37,6 +39,7 @@ import org.junit.Test
class ChatViewModelTest { class ChatViewModelTest {
private val testDispatcher = StandardTestDispatcher() private val testDispatcher = StandardTestDispatcher()
private val mockEventsFlow = MutableSharedFlow<WsEvent>(extraBufferCapacity = 64) private val mockEventsFlow = MutableSharedFlow<WsEvent>(extraBufferCapacity = 64)
private val mockConnectionStatus = MutableStateFlow(ConnectionStatus.DISCONNECTED)
private lateinit var app: Application private lateinit var app: Application
private val mockDao: ChatMessageDao = mockk(relaxed = true) private val mockDao: ChatMessageDao = mockk(relaxed = true)
private val mockDb: HermesDatabase = mockk(relaxed = true) private val mockDb: HermesDatabase = mockk(relaxed = true)
@@ -56,8 +59,11 @@ class ChatViewModelTest {
app = mockk(relaxed = true) app = mockk(relaxed = true)
mockConnectionStatus.value = ConnectionStatus.DISCONNECTED
every { AuthManager.getToken() } returns "test-token" every { AuthManager.getToken() } returns "test-token"
every { HermesWsClient.events } returns mockEventsFlow every { HermesWsClient.events } returns mockEventsFlow
every { HermesWsClient.connectionStatus } returns mockConnectionStatus
every { HermesWsClient.connect() } returns Unit every { HermesWsClient.connect() } returns Unit
every { HermesWsClient.disconnect() } returns Unit every { HermesWsClient.disconnect() } returns Unit
every { mockDb.chatMessageDao() } returns mockDao every { mockDb.chatMessageDao() } returns mockDao
@@ -97,7 +103,7 @@ class ChatViewModelTest {
@Test @Test
fun testInitialStateAndConnection() = fun testInitialStateAndConnection() =
runTest { runTest {
val viewModel = ChatViewModel(app) val viewModel = ChatViewModel(app, startCleanup = false)
advanceUntilIdle() advanceUntilIdle()
verify { HermesWsClient.connect() } verify { HermesWsClient.connect() }
@@ -108,9 +114,10 @@ class ChatViewModelTest {
@Test @Test
fun testGatewayReady_createsSessionIfNoneExists() = fun testGatewayReady_createsSessionIfNoneExists() =
runTest { runTest {
val viewModel = ChatViewModel(app) val viewModel = ChatViewModel(app, startCleanup = false)
advanceUntilIdle() advanceUntilIdle()
mockConnectionStatus.value = ConnectionStatus.CONNECTED
mockEventsFlow.emit(WsEvent.GatewayReady(null)) mockEventsFlow.emit(WsEvent.GatewayReady(null))
advanceUntilIdle() advanceUntilIdle()
@@ -136,7 +143,7 @@ class ChatViewModelTest {
createReqId createReqId
} }
val viewModel = ChatViewModel(app) val viewModel = ChatViewModel(app, startCleanup = false)
advanceUntilIdle() advanceUntilIdle()
// Trigger GatewayReady -> triggers createNewSession // Trigger GatewayReady -> triggers createNewSession
@@ -166,7 +173,7 @@ class ChatViewModelTest {
listReqId listReqId
} }
val viewModel = ChatViewModel(app) val viewModel = ChatViewModel(app, startCleanup = false)
advanceUntilIdle() advanceUntilIdle()
mockEventsFlow.emit(WsEvent.GatewayReady(null)) mockEventsFlow.emit(WsEvent.GatewayReady(null))
@@ -200,20 +207,31 @@ class ChatViewModelTest {
@Test @Test
fun testMessageStreamingFlow() = fun testMessageStreamingFlow() =
runTest { runTest {
val viewModel = ChatViewModel(app) val viewModel = ChatViewModel(app, startCleanup = false)
advanceUntilIdle() advanceUntilIdle()
// Set active session ID first // Trigger GatewayReady -> triggers createSession -> feed result to set active session to session-123
var createReqId = ""
every { HermesWsClient.send(WsMethods.SESSION_CREATE, any(), any()) } answers {
createReqId = "create-req-stream"
val onSent = arg<((String) -> Unit)?>(2)
onSent?.invoke(createReqId)
createReqId
}
mockEventsFlow.emit(WsEvent.GatewayReady(null)) mockEventsFlow.emit(WsEvent.GatewayReady(null))
advanceUntilIdle() advanceUntilIdle()
mockEventsFlow.emit(WsEvent.RpcResult(createReqId, mapOf("session_id" to "session-123")))
advanceUntilIdle()
// Stream Start // Stream Start
mockEventsFlow.emit(WsEvent.MessageStart("session-123")) mockEventsFlow.emit(WsEvent.MessageStart("session-123"))
advanceUntilIdle() advanceUntilIdle()
var state = viewModel.uiState.value var state = viewModel.uiState.value
assertTrue(state.isAgentTyping) assertTrue(state.isAgentTyping)
assertEquals("", state.currentStreamingText) assertNotNull(state.streamingMessage)
assertEquals("", state.streamingMessage?.content)
assertFalse(state.isThinking) assertFalse(state.isThinking)
assertEquals("", state.thinkingText) assertEquals("", state.thinkingText)
@@ -236,32 +254,31 @@ class ChatViewModelTest {
advanceUntilIdle() advanceUntilIdle()
state = viewModel.uiState.value state = viewModel.uiState.value
assertFalse(state.isThinking) assertFalse(state.isThinking)
assertEquals("Hello", state.currentStreamingText) assertNotNull(state.streamingMessage)
// System message was cleared when createNewSession() was called, so only Assistant streaming message is present assertEquals("Hello", state.streamingMessage?.content)
// Streaming message is not in the main messages list yet, only "Session created" exists
assertEquals(1, state.messages.size) assertEquals(1, state.messages.size)
assertEquals("Hello", state.messages[0].content)
assertTrue(state.messages[0].isStreaming)
// Token 2 // Token 2
mockEventsFlow.emit(WsEvent.MessageToken(" world", "session-123")) mockEventsFlow.emit(WsEvent.MessageToken(" world", "session-123"))
advanceUntilIdle() advanceUntilIdle()
state = viewModel.uiState.value state = viewModel.uiState.value
assertEquals("Hello world", state.currentStreamingText) assertNotNull(state.streamingMessage)
assertEquals("Hello world", state.streamingMessage?.content)
assertEquals(1, state.messages.size) assertEquals(1, state.messages.size)
assertEquals("Hello world", state.messages[0].content)
assertTrue(state.messages[0].isStreaming)
// Complete // Complete
mockEventsFlow.emit(WsEvent.MessageComplete("Hello world!", "session-123")) mockEventsFlow.emit(WsEvent.MessageComplete("Hello world!", "session-123"))
advanceUntilIdle() advanceUntilIdle()
state = viewModel.uiState.value state = viewModel.uiState.value
assertFalse(state.isAgentTyping) assertFalse(state.isAgentTyping)
assertEquals("", state.currentStreamingText) assertNull(state.streamingMessage)
assertFalse(state.isThinking) assertFalse(state.isThinking)
assertEquals("", state.thinkingText) assertEquals("", state.thinkingText)
assertEquals(1, state.messages.size) assertEquals(2, state.messages.size)
assertEquals("Hello world!", state.messages[0].content) assertEquals("Session created", state.messages[0].content)
assertFalse(state.messages[0].isStreaming) assertEquals("Hello world!", state.messages[1].content)
assertFalse(state.messages[1].isStreaming)
} }
@Test @Test
@@ -275,7 +292,7 @@ class ChatViewModelTest {
createReqId createReqId
} }
val viewModel = ChatViewModel(app) val viewModel = ChatViewModel(app, startCleanup = false)
advanceUntilIdle() advanceUntilIdle()
// Set session ID // Set session ID
@@ -309,7 +326,7 @@ class ChatViewModelTest {
@Test @Test
fun testSendMessage() = fun testSendMessage() =
runTest { runTest {
val viewModel = ChatViewModel(app) val viewModel = ChatViewModel(app, startCleanup = false)
advanceUntilIdle() advanceUntilIdle()
// Trigger GatewayReady -> triggers createSession -> feed result to set active session // Trigger GatewayReady -> triggers createSession -> feed result to set active session
@@ -344,7 +361,7 @@ class ChatViewModelTest {
@Test @Test
fun testSwitchSession() = fun testSwitchSession() =
runTest { runTest {
val viewModel = ChatViewModel(app) val viewModel = ChatViewModel(app, startCleanup = false)
advanceUntilIdle() advanceUntilIdle()
viewModel.switchSession("session-456") viewModel.switchSession("session-456")
@@ -376,7 +393,7 @@ class ChatViewModelTest {
createReqId createReqId
} }
val viewModel = ChatViewModel(app) val viewModel = ChatViewModel(app, startCleanup = false)
advanceUntilIdle() advanceUntilIdle()
mockEventsFlow.emit(WsEvent.GatewayReady(null)) mockEventsFlow.emit(WsEvent.GatewayReady(null))