perf: optimize startup, database, and ui rendering (#405)

* perf: optimize startup, database, and ui rendering

* fix(#405): address code review feedback

* test: wait for async prefs initialization to prevent coroutine leaks

* test: stub mockViewModel.streamingState in ChatScreenTest to prevent ClassCastException on Android
This commit is contained in:
M57 authored and GitHub committed 2026-06-27 01:38:44 +03:00
1 parent c0ebc8ec64
commit 8f14aca647
14 files changed
+435 -169

No files matched your search

+5
View File
@@ -19,3 +19,8 @@ $PWD/
ktlint ktlint
.hermes/plans/ .hermes/plans/
# Local workspace artifacts and logs
*.py
.kotlin/
@@ -0,0 +1,71 @@
{
"formatVersion": 1,
"database": {
"version": 2,
"identityHash": "80650cb2405e04876925851f326bda5c",
"entities": [
{
"tableName": "chat_messages",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `session_id` TEXT NOT NULL, `role` TEXT NOT NULL, `content` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `tool_name` TEXT, `tool_status` TEXT, `is_streaming` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "sessionId",
"columnName": "session_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "role",
"columnName": "role",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "content",
"columnName": "content",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "timestamp",
"columnName": "timestamp",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "toolName",
"columnName": "tool_name",
"affinity": "TEXT"
},
{
"fieldPath": "toolStatus",
"columnName": "tool_status",
"affinity": "TEXT"
},
{
"fieldPath": "isStreaming",
"columnName": "is_streaming",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
}
}
],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '80650cb2405e04876925851f326bda5c')"
]
}
}
@@ -0,0 +1,83 @@
{
"formatVersion": 1,
"database": {
"version": 3,
"identityHash": "b0a33c5fd32118982236698f395f519b",
"entities": [
{
"tableName": "chat_messages",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `session_id` TEXT NOT NULL, `role` TEXT NOT NULL, `content` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `tool_name` TEXT, `tool_status` TEXT, `is_streaming` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "sessionId",
"columnName": "session_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "role",
"columnName": "role",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "content",
"columnName": "content",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "timestamp",
"columnName": "timestamp",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "toolName",
"columnName": "tool_name",
"affinity": "TEXT"
},
{
"fieldPath": "toolStatus",
"columnName": "tool_status",
"affinity": "TEXT"
},
{
"fieldPath": "isStreaming",
"columnName": "is_streaming",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_chat_messages_session_id_timestamp",
"unique": false,
"columnNames": [
"session_id",
"timestamp"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_chat_messages_session_id_timestamp` ON `${TABLE_NAME}` (`session_id`, `timestamp`)"
}
]
}
],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'b0a33c5fd32118982236698f395f519b')"
]
}
}
@@ -54,6 +54,7 @@ class ChatScreenTest {
) )
val mockViewModel = mockk<ChatViewModel>(relaxed = true) val mockViewModel = mockk<ChatViewModel>(relaxed = true)
every { mockViewModel.uiState } returns MutableStateFlow(uiState).asStateFlow() every { mockViewModel.uiState } returns MutableStateFlow(uiState).asStateFlow()
every { mockViewModel.streamingState } returns MutableStateFlow(StreamingState()).asStateFlow()
composeTestRule.setContent { composeTestRule.setContent {
ChatScreen( ChatScreen(
@@ -7,9 +7,14 @@ import androidx.security.crypto.MasterKeys
import com.m57.hermescontrol.theme.BottomNavDisplayMode import com.m57.hermescontrol.theme.BottomNavDisplayMode
import com.m57.hermescontrol.theme.ThemePreference import com.m57.hermescontrol.theme.ThemePreference
import com.m57.hermescontrol.theme.ThemePreset import com.m57.hermescontrol.theme.ThemePreset
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.runBlocking
/** /**
* Singleton that manages encrypted storage of the Hermes dashboard token * Singleton that manages encrypted storage of the Hermes dashboard token
@@ -43,7 +48,7 @@ object AuthManager {
private const val DEFAULT_TYPING_EFFECT_DELAY_MS = 30 private const val DEFAULT_TYPING_EFFECT_DELAY_MS = 30
@Volatile @Volatile
private var prefs: SharedPreferences? = null private var prefsDeferred: Deferred<SharedPreferences>? = null
private val _bottomNavItemsFlow = MutableStateFlow<List<String>>(emptyList()) private val _bottomNavItemsFlow = MutableStateFlow<List<String>>(emptyList())
val bottomNavItemsFlow: StateFlow<List<String>> = _bottomNavItemsFlow.asStateFlow() val bottomNavItemsFlow: StateFlow<List<String>> = _bottomNavItemsFlow.asStateFlow()
@@ -70,32 +75,54 @@ object AuthManager {
* Call this once from Application.onCreate() or MainActivity.onCreate(). * Call this once from Application.onCreate() or MainActivity.onCreate().
*/ */
fun init(context: Context) { fun init(context: Context) {
if (prefs != null) return if (prefsDeferred != null) return
synchronized(this) { synchronized(this) {
if (prefs != null) return if (prefsDeferred != null) return
val masterKey = prefsDeferred =
MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC) CoroutineScope(Dispatchers.IO).async {
val masterKey =
MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC)
prefs = val p =
EncryptedSharedPreferences.create( EncryptedSharedPreferences.create(
PREFS_FILE, PREFS_FILE,
masterKey, masterKey,
context.applicationContext, context.applicationContext,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM, EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
) )
_bottomNavItemsFlow.value = getBottomNavItems() // Initialize flows with values from prefs once loaded
_themePreferenceFlow.value = getThemePreference() _bottomNavItemsFlow.value = getBottomNavItemsInternal(p)
_useDynamicColorsFlow.value = isUseDynamicColors() _themePreferenceFlow.value = getThemePreferenceInternal(p)
_themePresetFlow.value = getThemePreset() _useDynamicColorsFlow.value = isUseDynamicColorsInternal(p)
_bottomNavDisplayModeFlow.value = getBottomNavDisplayMode() _themePresetFlow.value = getThemePresetInternal(p)
_tokenFlow.value = getToken() _bottomNavDisplayModeFlow.value = getBottomNavDisplayModeInternal(p)
_tokenFlow.value = getTokenInternal(p)
p
}
} }
} }
/**
* Retrieves the initialized [SharedPreferences] instance.
*
* WARNING: This method is synchronous and will block the caller thread (using [runBlocking])
* if the asynchronous initialization is still in progress. Callers should avoid invoking this
* on the main thread during early startup to prevent frame drops or potential ANRs.
*
* Times out and throws [IllegalStateException] if initialization takes longer than 2 seconds.
*/
private fun requirePrefs(): SharedPreferences = private fun requirePrefs(): SharedPreferences =
prefs ?: throw IllegalStateException("AuthManager not initialized. Call init(context) first.") runBlocking {
val deferred =
prefsDeferred ?: throw IllegalStateException(
"AuthManager not initialized. Call init(context) first.",
)
kotlinx.coroutines.withTimeoutOrNull(2000) {
deferred.await()
} ?: throw IllegalStateException("AuthManager initialization timed out after 2 seconds.")
}
fun setWsAuthParam(param: String) { fun setWsAuthParam(param: String) {
requirePrefs().edit().putString(KEY_WS_AUTH_PARAM, param).apply() requirePrefs().edit().putString(KEY_WS_AUTH_PARAM, param).apply()
@@ -209,6 +236,15 @@ object AuthManager {
} }
} }
private fun getTokenInternal(p: SharedPreferences): String? {
val selectedId = p.getString(KEY_SELECTED_PROFILE_ID, null)?.takeIf { it.isNotBlank() }
return if (selectedId != null) {
p.getString("token_$selectedId", null) ?: p.getString(KEY_TOKEN, null)
} else {
p.getString(KEY_TOKEN, null)
}
}
fun setToken(token: String?) { fun setToken(token: String?) {
val selectedId = getSelectedProfileId() val selectedId = getSelectedProfileId()
if (selectedId != null) { if (selectedId != null) {
@@ -289,8 +325,10 @@ object AuthManager {
// start reset the theme to SYSTEM. Fix: round-trip via EncryptedSharedPreferences // start reset the theme to SYSTEM. Fix: round-trip via EncryptedSharedPreferences
// (the same store already used by token / host / port / auto_reconnect). // (the same store already used by token / host / port / auto_reconnect).
fun getThemePreference(): ThemePreference = fun getThemePreference(): ThemePreference = getThemePreferenceInternal(requirePrefs())
requirePrefs().getString(KEY_THEME_PREFERENCE, ThemePreference.SYSTEM.name)?.let { name ->
private fun getThemePreferenceInternal(p: SharedPreferences): ThemePreference =
p.getString(KEY_THEME_PREFERENCE, ThemePreference.SYSTEM.name)?.let { name ->
runCatching { ThemePreference.valueOf(name) }.getOrNull() runCatching { ThemePreference.valueOf(name) }.getOrNull()
} ?: ThemePreference.SYSTEM } ?: ThemePreference.SYSTEM
@@ -299,15 +337,19 @@ object AuthManager {
_themePreferenceFlow.value = theme _themePreferenceFlow.value = theme
} }
fun isUseDynamicColors(): Boolean = requirePrefs().getBoolean(KEY_USE_DYNAMIC_COLORS, true) fun isUseDynamicColors(): Boolean = isUseDynamicColorsInternal(requirePrefs())
private fun isUseDynamicColorsInternal(p: SharedPreferences): Boolean = p.getBoolean(KEY_USE_DYNAMIC_COLORS, true)
fun setUseDynamicColors(value: Boolean) { fun setUseDynamicColors(value: Boolean) {
requirePrefs().edit().putBoolean(KEY_USE_DYNAMIC_COLORS, value).apply() requirePrefs().edit().putBoolean(KEY_USE_DYNAMIC_COLORS, value).apply()
_useDynamicColorsFlow.value = value _useDynamicColorsFlow.value = value
} }
fun getThemePreset(): ThemePreset = fun getThemePreset(): ThemePreset = getThemePresetInternal(requirePrefs())
requirePrefs().getString(KEY_THEME_PRESET, ThemePreset.DEFAULT.name)?.let { name ->
private fun getThemePresetInternal(p: SharedPreferences): ThemePreset =
p.getString(KEY_THEME_PRESET, ThemePreset.DEFAULT.name)?.let { name ->
runCatching { ThemePreset.valueOf(name) }.getOrNull() runCatching { ThemePreset.valueOf(name) }.getOrNull()
} ?: ThemePreset.DEFAULT } ?: ThemePreset.DEFAULT
@@ -337,8 +379,10 @@ object AuthManager {
listOf("ChatScreen", "SkillsScreen", "CronJobsScreen", "SystemScreen", "SettingsScreen") listOf("ChatScreen", "SkillsScreen", "CronJobsScreen", "SystemScreen", "SettingsScreen")
/** Returns the list of selected bottom-nav item keys (data-object names). */ /** Returns the list of selected bottom-nav item keys (data-object names). */
fun getBottomNavItems(): List<String> { fun getBottomNavItems(): List<String> = getBottomNavItemsInternal(requirePrefs())
val raw = requirePrefs().getString(KEY_BOTTOM_NAV_ITEMS, null) ?: return DEFAULT_BOTTOM_NAV_ITEMS
private fun getBottomNavItemsInternal(p: SharedPreferences): List<String> {
val raw = p.getString(KEY_BOTTOM_NAV_ITEMS, null) ?: return DEFAULT_BOTTOM_NAV_ITEMS
return raw.split(",").filter { it.isNotBlank() } return raw.split(",").filter { it.isNotBlank() }
} }
@@ -366,8 +410,10 @@ object AuthManager {
// ── Bottom Nav Display Mode ────────────────────────────────────────── // ── Bottom Nav Display Mode ──────────────────────────────────────────
fun getBottomNavDisplayMode(): BottomNavDisplayMode = fun getBottomNavDisplayMode(): BottomNavDisplayMode = getBottomNavDisplayModeInternal(requirePrefs())
requirePrefs().getString(KEY_BOTTOM_NAV_DISPLAY_MODE, BottomNavDisplayMode.ICON_AND_TEXT.name)?.let { name ->
private fun getBottomNavDisplayModeInternal(p: SharedPreferences): BottomNavDisplayMode =
p.getString(KEY_BOTTOM_NAV_DISPLAY_MODE, BottomNavDisplayMode.ICON_AND_TEXT.name)?.let { name ->
runCatching { BottomNavDisplayMode.valueOf(name) }.getOrNull() runCatching { BottomNavDisplayMode.valueOf(name) }.getOrNull()
} ?: BottomNavDisplayMode.ICON_AND_TEXT } ?: BottomNavDisplayMode.ICON_AND_TEXT
@@ -2,6 +2,7 @@ package com.m57.hermescontrol.data.local
import androidx.room.ColumnInfo import androidx.room.ColumnInfo
import androidx.room.Entity import androidx.room.Entity
import androidx.room.Index
import androidx.room.PrimaryKey import androidx.room.PrimaryKey
/** /**
@@ -12,7 +13,12 @@ import androidx.room.PrimaryKey
* thread. The [timestamp] field preserves original ordering even if Room * thread. The [timestamp] field preserves original ordering even if Room
* reorders internally. * reorders internally.
*/ */
@Entity(tableName = "chat_messages") @Entity(
tableName = "chat_messages",
indices = [
Index(value = ["session_id", "timestamp"]),
],
)
data class ChatMessageEntity( data class ChatMessageEntity(
@PrimaryKey @PrimaryKey
val id: String, val id: String,
@@ -4,12 +4,14 @@ import android.content.Context
import androidx.room.Database import androidx.room.Database
import androidx.room.Room import androidx.room.Room
import androidx.room.RoomDatabase import androidx.room.RoomDatabase
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
import net.zetetic.database.sqlcipher.SupportOpenHelperFactory import net.zetetic.database.sqlcipher.SupportOpenHelperFactory
import java.io.File import java.io.File
@Database( @Database(
entities = [ChatMessageEntity::class], entities = [ChatMessageEntity::class],
version = 2, version = 3,
exportSchema = true, exportSchema = true,
) )
abstract class HermesDatabase : RoomDatabase() { abstract class HermesDatabase : RoomDatabase() {
@@ -33,20 +35,31 @@ abstract class HermesDatabase : RoomDatabase() {
System.loadLibrary("sqlcipher") System.loadLibrary("sqlcipher")
val factory = SupportOpenHelperFactory(AuthManager.getDatabasePassword()) val factory = SupportOpenHelperFactory(AuthManager.getDatabasePassword())
val migration2to3 =
object : Migration(2, 3) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL(
"CREATE INDEX IF NOT EXISTS `index_chat_messages_session_id_timestamp` " +
"ON `chat_messages` (`session_id`, `timestamp`)",
)
}
}
instance ?: Room instance ?: Room
.databaseBuilder( .databaseBuilder(
context.applicationContext, context.applicationContext,
HermesDatabase::class.java, HermesDatabase::class.java,
"hermes_control.db", "hermes_control.db",
).openHelperFactory(factory) ).openHelperFactory(factory)
.addMigrations(migration2to3)
.fallbackToDestructiveMigration(false) .fallbackToDestructiveMigration(false)
.build() .build()
.also { instance = it } .also { instance = it }
} }
/** Returns true if the database file starts with the SQLCipher magic header. */ /** Returns true if the database file starts with the SQLCipher magic header. */
private fun isSqlCipherDatabase(file: File): Boolean { private fun isSqlCipherDatabase(file: File): Boolean =
return try { try {
val header = ByteArray(16) val header = ByteArray(16)
file.inputStream().use { it.read(header) } file.inputStream().use { it.read(header) }
// SQLCipher 4.x databases start with bytes that differ from // SQLCipher 4.x databases start with bytes that differ from
@@ -56,7 +69,6 @@ abstract class HermesDatabase : RoomDatabase() {
} catch (_: Exception) { } catch (_: Exception) {
false // if we can't read it, treat as plaintext and delete false // if we can't read it, treat as plaintext and delete
} }
}
/** For testing — inject a custom instance. */ /** For testing — inject a custom instance. */
fun setForTest(db: HermesDatabase?) { fun setForTest(db: HermesDatabase?) {
@@ -123,6 +123,7 @@ fun ChatScreen(
viewModel: ChatViewModel = viewModel(), viewModel: ChatViewModel = viewModel(),
) { ) {
val state by viewModel.uiState.collectAsStateWithLifecycle() val state by viewModel.uiState.collectAsStateWithLifecycle()
val streamingState by viewModel.streamingState.collectAsStateWithLifecycle()
val listState = rememberLazyListState() val listState = rememberLazyListState()
val showScrollToBottom by remember { val showScrollToBottom by remember {
derivedStateOf { derivedStateOf {
@@ -142,8 +143,8 @@ fun ChatScreen(
connectionStatus = state.connectionStatus, connectionStatus = state.connectionStatus,
currentSessionId = state.currentSessionId, currentSessionId = state.currentSessionId,
messages = state.messages, messages = state.messages,
streamingMessage = state.streamingMessage, streamingMessage = streamingState.streamingMessage,
isThinking = state.isThinking, isThinking = streamingState.isThinking,
errorMessage = state.errorMessage, errorMessage = state.errorMessage,
isSearchActive = state.isSearchActive, isSearchActive = state.isSearchActive,
currentSearchMatchIndex = state.currentSearchMatchIndex, currentSearchMatchIndex = state.currentSearchMatchIndex,
@@ -246,9 +247,9 @@ fun ChatScreen(
) { ) {
ChatMessageList( ChatMessageList(
messages = state.messages, messages = state.messages,
streamingMessage = state.streamingMessage, streamingMessage = streamingState.streamingMessage,
isThinking = state.isThinking, isThinking = streamingState.isThinking,
thinkingText = state.thinkingText, thinkingText = streamingState.thinkingText,
isSearchActive = state.isSearchActive, isSearchActive = state.isSearchActive,
searchQuery = state.searchQuery, searchQuery = state.searchQuery,
currentSearchMatchIndex = state.currentSearchMatchIndex, currentSearchMatchIndex = state.currentSearchMatchIndex,
@@ -272,8 +273,8 @@ fun ChatScreen(
scrollScope = scrollScope, scrollScope = scrollScope,
listState = listState, listState = listState,
messages = state.messages, messages = state.messages,
streamingMessage = state.streamingMessage, streamingMessage = streamingState.streamingMessage,
isThinking = state.isThinking, isThinking = streamingState.isThinking,
) )
} }
@@ -286,8 +287,8 @@ fun ChatScreen(
scrollScope.launch { scrollScope.launch {
val totalItems = val totalItems =
state.messages.size + state.messages.size +
(if (state.streamingMessage != null) 1 else 0) + (if (streamingState.streamingMessage != null) 1 else 0) +
(if (state.isThinking) 1 else 0) (if (streamingState.isThinking) 1 else 0)
if (totalItems > 0) { if (totalItems > 0) {
listState.animateScrollToItem(totalItems - 1) listState.animateScrollToItem(totalItems - 1)
} }
@@ -557,8 +558,7 @@ private fun ClarifyDialog(
role = Role.Button, role = Role.Button,
onClick = { typedText = option }, onClick = { typedText = option },
onLongClick = { onOptionSelected(option) }, onLongClick = { onOptionSelected(option) },
) ).padding(horizontal = 24.dp, vertical = 10.dp),
.padding(horizontal = 24.dp, vertical = 10.dp),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
Text( Text(
@@ -865,10 +865,12 @@ private fun ChatLifecycleEffects(
viewModel.refreshSettings() viewModel.refreshSettings()
viewModel.refreshCurrentSession() viewModel.refreshCurrentSession()
} }
androidx.lifecycle.Lifecycle.Event.ON_STOP -> { androidx.lifecycle.Lifecycle.Event.ON_STOP -> {
NotificationHelper.setAppForeground(context, false) NotificationHelper.setAppForeground(context, false)
NotificationHelper.start(context) NotificationHelper.start(context)
} }
else -> {} else -> {}
} }
} }
@@ -21,6 +21,7 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted 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.combine
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
@@ -87,6 +88,9 @@ class ChatViewModel(
// ── Internal state ─────────────────────────────────────────────────── // ── Internal state ───────────────────────────────────────────────────
private val _uiState = MutableStateFlow(ChatUiState()) private val _uiState = MutableStateFlow(ChatUiState())
private val _streamingState = MutableStateFlow(StreamingState())
val streamingState: StateFlow<StreamingState> = _streamingState.asStateFlow()
private val wsClient = HermesWsClient private val wsClient = HermesWsClient
private val pendingRequests = ConcurrentHashMap<String, PendingRequest>() private val pendingRequests = ConcurrentHashMap<String, PendingRequest>()
private val repo = private val repo =
@@ -165,10 +169,17 @@ class ChatViewModel(
private fun handleWsEvent(event: WsEvent) { private fun handleWsEvent(event: WsEvent) {
// First, let the reducer compute the new state and any effects // First, let the reducer compute the new state and any effects
val result = ChatWsEventReducer.reduce(_uiState.value, event, _uiState.value.currentSessionId) val result =
ChatWsEventReducer.reduce(
_uiState.value,
_streamingState.value,
event,
_uiState.value.currentSessionId,
)
// Apply the new state // Apply the new state
_uiState.update { result.state } _uiState.update { result.state }
_streamingState.update { result.streamingState }
// Process side-effects from the reducer // Process side-effects from the reducer
for (effect in result.effects) { for (effect in result.effects) {
@@ -267,6 +278,7 @@ class ChatViewModel(
isAgentTyping = false, isAgentTyping = false,
) )
} }
_streamingState.update { StreamingState() }
} }
is WsEvent.ApprovalRequest -> { is WsEvent.ApprovalRequest -> {
@@ -300,7 +312,7 @@ class ChatViewModel(
if (shouldFlush) { if (shouldFlush) {
val currentContent = streamingBuffer.toString() val currentContent = streamingBuffer.toString()
lastFlushMs = now lastFlushMs = now
_uiState.update { state -> _streamingState.update { state ->
val current = state.streamingMessage val current = state.streamingMessage
if (current != null) { if (current != null) {
state.copy( state.copy(
@@ -321,11 +333,11 @@ class ChatViewModel(
streamingMessageId = msg.id streamingMessageId = msg.id
state.copy( state.copy(
streamingMessage = msg, streamingMessage = msg,
isAgentTyping = true,
isThinking = false, isThinking = false,
) )
} }
} }
_uiState.update { it.copy(isAgentTyping = true) }
} }
} }
@@ -340,7 +352,7 @@ class ChatViewModel(
if (shouldFlush) { if (shouldFlush) {
val currentContent = thinkingBuffer.toString() val currentContent = thinkingBuffer.toString()
lastThinkingFlushMs = now lastThinkingFlushMs = now
_uiState.update { state -> _streamingState.update { state ->
state.copy( state.copy(
isThinking = true, isThinking = true,
thinkingText = currentContent, thinkingText = currentContent,
@@ -368,10 +380,10 @@ class ChatViewModel(
currentSessionId = sessionId, currentSessionId = sessionId,
isLoading = false, isLoading = false,
messages = emptyList(), messages = emptyList(),
streamingMessage = null,
chatTitle = "Hermes", chatTitle = "Hermes",
) )
} }
_streamingState.update { StreamingState() }
addSystemMessage("Session created", persist = true) addSystemMessage("Session created", persist = true)
loadSessions() loadSessions()
} }
@@ -422,10 +434,9 @@ class ChatViewModel(
_uiState.update { _uiState.update {
it.copy( it.copy(
isAgentTyping = false, isAgentTyping = false,
isThinking = false,
streamingMessage = null,
) )
} }
_streamingState.update { StreamingState() }
streamingMessageId = null streamingMessageId = null
addSystemMessage("Session interrupted") addSystemMessage("Session interrupted")
} }
@@ -728,10 +739,10 @@ class ChatViewModel(
it.copy( it.copy(
isLoading = true, isLoading = true,
messages = emptyList(), messages = emptyList(),
streamingMessage = null,
chatTitle = "Hermes", chatTitle = "Hermes",
) )
} }
_streamingState.update { StreamingState() }
streamingMessageId = null streamingMessageId = null
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
wsClient.send( wsClient.send(
@@ -777,8 +788,8 @@ class ChatViewModel(
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
private fun parseCommandCatalog(map: Map<*, *>): CommandCatalog? = private fun parseCommandCatalog(map: Map<*, *>): CommandCatalog? =
try { try {
val json = gson.toJson(map) val json = OkHttpProvider.gson.toJson(map)
gson.fromJson(json, CommandCatalog::class.java) OkHttpProvider.gson.fromJson(json, CommandCatalog::class.java)
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Failed to parse command catalog", e) Log.e(TAG, "Failed to parse command catalog", e)
null null
@@ -791,21 +802,18 @@ class ChatViewModel(
streamingMessageId = null streamingMessageId = null
streamingBuffer.clear() streamingBuffer.clear()
thinkingBuffer.clear() thinkingBuffer.clear()
_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(),
streamingMessage = null,
currentSessionId = sessionId, currentSessionId = sessionId,
messages = emptyList(),
chatTitle = title, chatTitle = title,
showSessionPicker = false, showSessionPicker = false,
isAgentTyping = false, isAgentTyping = false,
isThinking = false,
thinkingText = "",
) )
} }
_streamingState.update { StreamingState() }
// Step 1: Load cached messages first — instant display // Step 1: Load cached messages first — instant display
loadCachedMessages(sessionId) loadCachedMessages(sessionId)
// Step 2: Resume session on server // Step 2: Resume session on server
@@ -1184,6 +1192,5 @@ class ChatViewModel(
} }
companion object { companion object {
private val gson = OkHttpProvider.gson
} }
} }
@@ -1,5 +1,6 @@
package com.m57.hermescontrol.ui.chat package com.m57.hermescontrol.ui.chat
import com.m57.hermescontrol.data.remote.OkHttpProvider
import com.m57.hermescontrol.data.ws.WsEvent import com.m57.hermescontrol.data.ws.WsEvent
/** /**
@@ -15,6 +16,7 @@ import com.m57.hermescontrol.data.ws.WsEvent
object ChatWsEventReducer { object ChatWsEventReducer {
fun reduce( fun reduce(
state: ChatUiState, state: ChatUiState,
streamingState: StreamingState,
event: WsEvent, event: WsEvent,
currentSessionId: String? = null, currentSessionId: String? = null,
): ReducerResult { ): ReducerResult {
@@ -31,60 +33,65 @@ object ChatWsEventReducer {
else -> null else -> null
} }
if (eventSessionId != null && currentSessionId != null && eventSessionId != currentSessionId) { if (eventSessionId != null && currentSessionId != null && eventSessionId != currentSessionId) {
return ReducerResult(state) return ReducerResult(state = state, streamingState = streamingState)
} }
return reduceInternal(state, event) return reduceInternal(state, streamingState, event)
} }
private fun reduceInternal( private fun reduceInternal(
state: ChatUiState, state: ChatUiState,
streamingState: StreamingState,
event: WsEvent, event: WsEvent,
): ReducerResult = ): ReducerResult =
when (event) { when (event) {
is WsEvent.GatewayReady -> onGatewayReady(state) is WsEvent.GatewayReady -> onGatewayReady(state, streamingState)
is WsEvent.MessageStart -> onMessageStart(state, event) is WsEvent.MessageStart -> onMessageStart(state, streamingState, event)
is WsEvent.MessageToken -> onMessageToken(state, event) is WsEvent.MessageToken -> onMessageToken(state, streamingState, event)
is WsEvent.ThinkingDelta -> onThinkingDelta(state, event) is WsEvent.ThinkingDelta -> onThinkingDelta(state, streamingState, event)
is WsEvent.MessageComplete -> onMessageComplete(state, event) is WsEvent.MessageComplete -> onMessageComplete(state, streamingState, event)
is WsEvent.MessageDone -> onMessageDone(state) is WsEvent.MessageDone -> onMessageDone(state, streamingState)
is WsEvent.ToolStart -> onToolStart(state, event) is WsEvent.ToolStart -> onToolStart(state, streamingState, event)
is WsEvent.ToolComplete -> onToolComplete(state, event) is WsEvent.ToolComplete -> onToolComplete(state, streamingState, event)
is WsEvent.ClarifyRequest -> onClarifyRequest(state, event) is WsEvent.ClarifyRequest -> onClarifyRequest(state, streamingState, event)
is WsEvent.RpcError -> onRpcError(state, event) is WsEvent.RpcError -> onRpcError(state, streamingState, event)
is WsEvent.SessionUpdated -> onSessionUpdated(state) is WsEvent.SessionUpdated -> onSessionUpdated(state, streamingState)
is WsEvent.StatusUpdate -> onStatusUpdate(state) is WsEvent.StatusUpdate -> onStatusUpdate(state, streamingState)
is WsEvent.Unknown -> onUnknown(state) is WsEvent.Unknown -> onUnknown(state, streamingState)
// SessionInfo is a no-op in the original code // SessionInfo is a no-op in the original code
is WsEvent.SessionInfo -> ReducerResult(state) is WsEvent.SessionInfo -> ReducerResult(state = state, streamingState = streamingState)
// RpcResult is handled by the ViewModel (needs pending request context) // RpcResult is handled by the ViewModel (needs pending request context)
is WsEvent.RpcResult -> ReducerResult(state) is WsEvent.RpcResult -> ReducerResult(state = state, streamingState = streamingState)
// ApprovalRequest is handled by the ViewModel (needs active session + WS client) // ApprovalRequest is handled by the ViewModel (needs active session + WS client)
is WsEvent.ApprovalRequest -> ReducerResult(state) is WsEvent.ApprovalRequest -> ReducerResult(state = state, streamingState = streamingState)
} }
// ── GatewayReady ────────────────────────────────────────────────── // ── GatewayReady ──────────────────────────────────────────────────
private fun onGatewayReady(state: ChatUiState): ReducerResult = ReducerResult(state) private fun onGatewayReady(
state: ChatUiState,
streamingState: StreamingState,
): ReducerResult = ReducerResult(state = state, streamingState = streamingState)
// ── MessageStart ────────────────────────────────────────────────── // ── MessageStart ──────────────────────────────────────────────────
private fun onMessageStart( private fun onMessageStart(
state: ChatUiState, state: ChatUiState,
streamingState: StreamingState,
event: WsEvent.MessageStart, event: WsEvent.MessageStart,
): ReducerResult { ): ReducerResult {
val msg = val msg =
@@ -98,53 +105,52 @@ object ChatWsEventReducer {
// Build new state: finalize any orphan streaming message, then set the new one // Build new state: finalize any orphan streaming message, then set the new one
var orphan: ChatMessage? = null var orphan: ChatMessage? = null
val preState = val preState =
if (state.streamingMessage?.content?.isNotEmpty() == true) { if (streamingState.streamingMessage?.content?.isNotEmpty() == true) {
val finalized = state.streamingMessage!!.copy(isStreaming = false) val finalized = streamingState.streamingMessage.copy(isStreaming = false)
orphan = finalized orphan = finalized
state.copy( state.copy(
messages = state.messages + finalized, messages = state.messages + finalized,
streamingMessage = null,
isAgentTyping = true, isAgentTyping = true,
isThinking = false,
thinkingText = "",
) )
} else { } else {
state.copy( state.copy(
isAgentTyping = true, isAgentTyping = true,
isThinking = false,
thinkingText = "",
) )
} }
val newState = preState.copy(streamingMessage = msg) val newStreamingState = StreamingState(streamingMessage = msg)
val newState = preState
val sid = newState.currentSessionId val sid = newState.currentSessionId
if (orphan != null && sid != null) { if (orphan != null && sid != null) {
effects.add(ReducerEffect.PersistMessage(orphan, sid)) effects.add(ReducerEffect.PersistMessage(orphan, sid))
} }
return ReducerResult(newState, effects) return ReducerResult(state = newState, streamingState = newStreamingState, effects = effects)
} }
// ── MessageToken ────────────────────────────────────────────────── // ── MessageToken ──────────────────────────────────────────────────
private fun onMessageToken( private fun onMessageToken(
state: ChatUiState, state: ChatUiState,
streamingState: StreamingState,
event: WsEvent.MessageToken, event: WsEvent.MessageToken,
): ReducerResult { ): ReducerResult {
// Token accumulation is done by the ViewModel (streaming buffer + flush timer). // Token accumulation is done by the ViewModel (streaming buffer + flush timer).
// The reducer only signals that new content arrived — ViewModel owns the timer. // The reducer only signals that new content arrived — ViewModel owns the timer.
return ReducerResult(state) return ReducerResult(state = state, streamingState = streamingState)
} }
// ── ThinkingDelta ───────────────────────────────────────────────── // ── ThinkingDelta ─────────────────────────────────────────────────
private fun onThinkingDelta( private fun onThinkingDelta(
state: ChatUiState, state: ChatUiState,
streamingState: StreamingState,
event: WsEvent.ThinkingDelta, event: WsEvent.ThinkingDelta,
): ReducerResult { ): ReducerResult {
val currentContent = state.thinkingText + event.token val currentContent = streamingState.thinkingText + event.token
return ReducerResult( return ReducerResult(
state = state = state,
state.copy( streamingState =
streamingState.copy(
isThinking = true, isThinking = true,
thinkingText = currentContent, thinkingText = currentContent,
), ),
@@ -155,9 +161,10 @@ object ChatWsEventReducer {
private fun onMessageComplete( private fun onMessageComplete(
state: ChatUiState, state: ChatUiState,
streamingState: StreamingState,
event: WsEvent.MessageComplete, event: WsEvent.MessageComplete,
): ReducerResult { ): ReducerResult {
val streaming = state.streamingMessage val streaming = streamingState.streamingMessage
val msg = val msg =
streaming?.copy( streaming?.copy(
content = event.text ?: streaming.content, content = event.text ?: streaming.content,
@@ -176,10 +183,7 @@ object ChatWsEventReducer {
state = state =
state.copy( state.copy(
messages = state.messages + msg, messages = state.messages + msg,
streamingMessage = null,
isAgentTyping = false, isAgentTyping = false,
isThinking = false,
thinkingText = "",
), ),
effects = effects, effects = effects,
) )
@@ -187,8 +191,15 @@ object ChatWsEventReducer {
// ── MessageDone ─────────────────────────────────────────────────── // ── MessageDone ───────────────────────────────────────────────────
private fun onMessageDone(state: ChatUiState): ReducerResult { private fun onMessageDone(
val streaming = state.streamingMessage ?: return ReducerResult(state) state: ChatUiState,
streamingState: StreamingState,
): ReducerResult {
val streaming =
streamingState.streamingMessage ?: return ReducerResult(
state = state,
streamingState = streamingState,
)
val msg = streaming.copy(isStreaming = false) val msg = streaming.copy(isStreaming = false)
val effects = mutableListOf<ReducerEffect>() val effects = mutableListOf<ReducerEffect>()
val sid = state.currentSessionId val sid = state.currentSessionId
@@ -199,10 +210,7 @@ object ChatWsEventReducer {
state = state =
state.copy( state.copy(
messages = state.messages + msg, messages = state.messages + msg,
streamingMessage = null,
isAgentTyping = false, isAgentTyping = false,
isThinking = false,
thinkingText = "",
), ),
effects = effects, effects = effects,
) )
@@ -212,13 +220,12 @@ object ChatWsEventReducer {
private fun onToolStart( private fun onToolStart(
state: ChatUiState, state: ChatUiState,
streamingState: StreamingState,
event: WsEvent.ToolStart, event: WsEvent.ToolStart,
): ReducerResult { ): ReducerResult {
val contentJson = val contentJson =
event.data?.let { event.data?.let {
com.m57.hermescontrol.data.remote.OkHttpProvider OkHttpProvider.gson.toJson(it)
.gson
.toJson(it)
} ?: "" } ?: ""
val toolMessage = val toolMessage =
ChatMessage( ChatMessage(
@@ -231,15 +238,14 @@ object ChatWsEventReducer {
// Finalize any orphan streaming message // Finalize any orphan streaming message
var orphanToPersist: ChatMessage? = null var orphanToPersist: ChatMessage? = null
val newState = val newState =
if (state.streamingMessage?.content?.isNotEmpty() == true) { if (streamingState.streamingMessage?.content?.isNotEmpty() == true) {
val finalized = state.streamingMessage!!.copy(isStreaming = false) val finalized = streamingState.streamingMessage.copy(isStreaming = false)
orphanToPersist = finalized orphanToPersist = finalized
state.copy( state.copy(
messages = state.messages + finalized, messages = state.messages + finalized,
streamingMessage = null,
) )
} else { } else {
state.copy(streamingMessage = null) state
} }
val sid = newState.currentSessionId val sid = newState.currentSessionId
val effects = mutableListOf<ReducerEffect>() val effects = mutableListOf<ReducerEffect>()
@@ -260,13 +266,12 @@ object ChatWsEventReducer {
private fun onToolComplete( private fun onToolComplete(
state: ChatUiState, state: ChatUiState,
streamingState: StreamingState,
event: WsEvent.ToolComplete, event: WsEvent.ToolComplete,
): ReducerResult { ): ReducerResult {
val contentJson = val contentJson =
event.data?.let { event.data?.let {
com.m57.hermescontrol.data.remote.OkHttpProvider OkHttpProvider.gson.toJson(it)
.gson
.toJson(it)
} ?: "" } ?: ""
val messages = state.messages.toMutableList() val messages = state.messages.toMutableList()
val toolIdx = val toolIdx =
@@ -275,7 +280,7 @@ object ChatWsEventReducer {
it.toolName == event.name && it.toolName == event.name &&
it.toolStatus == ToolStatus.RUNNING it.toolStatus == ToolStatus.RUNNING
} }
if (toolIdx < 0) return ReducerResult(state) if (toolIdx < 0) return ReducerResult(state = state, streamingState = streamingState)
val updated = val updated =
messages[toolIdx].copy( messages[toolIdx].copy(
@@ -291,6 +296,7 @@ object ChatWsEventReducer {
} }
return ReducerResult( return ReducerResult(
state = state.copy(messages = messages), state = state.copy(messages = messages),
streamingState = streamingState,
effects = effects, effects = effects,
) )
} }
@@ -299,6 +305,7 @@ object ChatWsEventReducer {
private fun onClarifyRequest( private fun onClarifyRequest(
state: ChatUiState, state: ChatUiState,
streamingState: StreamingState,
event: WsEvent.ClarifyRequest, event: WsEvent.ClarifyRequest,
): ReducerResult = ): ReducerResult =
ReducerResult( ReducerResult(
@@ -318,6 +325,7 @@ object ChatWsEventReducer {
private fun onRpcError( private fun onRpcError(
state: ChatUiState, state: ChatUiState,
streamingState: StreamingState,
event: WsEvent.RpcError, event: WsEvent.RpcError,
): ReducerResult = ): ReducerResult =
ReducerResult( ReducerResult(
@@ -326,52 +334,37 @@ object ChatWsEventReducer {
isLoading = false, isLoading = false,
errorMessage = "Error: ${event.error}", errorMessage = "Error: ${event.error}",
), ),
streamingState = streamingState,
) )
// ── SessionUpdated / StatusUpdate / Unknown ─────────────────────── // ── SessionUpdated / StatusUpdate / Unknown ───────────────────────
private fun onSessionUpdated(state: ChatUiState): ReducerResult = ReducerResult(state) private fun onSessionUpdated(
private fun onStatusUpdate(state: ChatUiState): ReducerResult = ReducerResult(state)
private fun onUnknown(state: ChatUiState): ReducerResult = ReducerResult(state)
// ── Helpers ───────────────────────────────────────────────────────
/**
* If there's a non-empty streaming message, finalize it into the message
* list and return it as an orphan that should be persisted.
*/
private fun finalizeStreamingMessage(
state: ChatUiState, state: ChatUiState,
newMessage: ChatMessage, streamingState: StreamingState,
): Pair<ChatUiState, ChatMessage?> { ): ReducerResult = ReducerResult(state = state, streamingState = streamingState)
val existing = state.streamingMessage
if (existing != null && existing.content.isNotEmpty()) { private fun onStatusUpdate(
val finalized = existing.copy(isStreaming = false) state: ChatUiState,
return Pair( streamingState: StreamingState,
state.copy( ): ReducerResult = ReducerResult(state = state, streamingState = streamingState)
messages = state.messages + finalized,
streamingMessage = newMessage, private fun onUnknown(
), state: ChatUiState,
finalized, streamingState: StreamingState,
) ): ReducerResult = ReducerResult(state = state, streamingState = streamingState)
}
return Pair(
state.copy(streamingMessage = newMessage),
null,
)
}
} }
/** /**
* Result of reducing one event. * Result of reducing one event.
* *
* @param state The new UI state after applying the event. * @param state The new UI state after applying the event.
* @param streamingState The new streaming state after applying the event.
* @param effects Side-effects the ViewModel should execute. * @param effects Side-effects the ViewModel should execute.
*/ */
data class ReducerResult( data class ReducerResult(
val state: ChatUiState, val state: ChatUiState,
val streamingState: StreamingState = StreamingState(),
val effects: List<ReducerEffect> = emptyList(), val effects: List<ReducerEffect> = emptyList(),
) )
@@ -0,0 +1,7 @@
package com.m57.hermescontrol.ui.chat
data class StreamingState(
val streamingMessage: ChatMessage? = null,
val isThinking: Boolean = false,
val thinkingText: String = "",
)
@@ -43,12 +43,19 @@ class AuthManagerTest {
every { MasterKeys.getOrCreate(any()) } returns "mockMasterKey" every { MasterKeys.getOrCreate(any()) } returns "mockMasterKey"
// Reset prefs singleton instance using reflection // Reset prefs singleton instance using reflection
val field = AuthManager::class.java.getDeclaredField("prefs") val field = AuthManager::class.java.getDeclaredField("prefsDeferred")
field.isAccessible = true field.isAccessible = true
field.set(AuthManager, null) field.set(AuthManager, null)
// Initialise AuthManager // Initialise AuthManager
AuthManager.init(mockContext) AuthManager.init(mockContext)
// Wait for async initialization to complete to prevent coroutine leaks
kotlinx.coroutines.runBlocking {
val deferred = field.get(AuthManager) as? kotlinx.coroutines.Deferred<*>
deferred?.await()
}
AuthManager.resetTokenCacheForTest() AuthManager.resetTokenCacheForTest()
} }
@@ -263,8 +270,10 @@ class AuthManagerTest {
fun testConnectionProfiles_roundTrip() { fun testConnectionProfiles_roundTrip() {
val profiles = val profiles =
listOf( listOf(
com.m57.hermescontrol.data.model.ConnectionProfile("a", "A", "10.0.0.1", 9119), com.m57.hermescontrol.data.model
com.m57.hermescontrol.data.model.ConnectionProfile("b", "B", "10.0.0.2", 9220), .ConnectionProfile("a", "A", "10.0.0.1", 9119),
com.m57.hermescontrol.data.model
.ConnectionProfile("b", "B", "10.0.0.2", 9220),
) )
AuthManager.saveConnectionProfiles(profiles) AuthManager.saveConnectionProfiles(profiles)
@@ -42,6 +42,7 @@ class HermesWsClientTest {
mockkObject(AuthManager) mockkObject(AuthManager)
every { AuthManager.wsUrl() } returns mockWebServer.url("/").toString().replace("http://", "ws://") every { AuthManager.wsUrl() } returns mockWebServer.url("/").toString().replace("http://", "ws://")
every { AuthManager.isAutoReconnect() } returns false every { AuthManager.isAutoReconnect() } returns false
every { AuthManager.getSessionCookie() } returns null
// Reset state // Reset state
val connectedField = HermesWsClient::class.java.getDeclaredField("connected") val connectedField = HermesWsClient::class.java.getDeclaredField("connected")
@@ -542,32 +542,40 @@ class ChatViewModelTest {
var state = viewModel.uiState.value var state = viewModel.uiState.value
assertTrue(state.isAgentTyping) assertTrue(state.isAgentTyping)
assertNotNull(state.streamingMessage) assertNotNull(viewModel.streamingState.value.streamingMessage)
assertEquals("", state.streamingMessage?.content) assertEquals(
assertFalse(state.isThinking) "",
assertEquals("", state.thinkingText) viewModel.streamingState.value.streamingMessage
?.content,
)
assertFalse(viewModel.streamingState.value.isThinking)
assertEquals("", viewModel.streamingState.value.thinkingText)
// Thinking Delta 1 // Thinking Delta 1
mockEventsFlow.emit(WsEvent.ThinkingDelta("Thinking...", "session-123")) mockEventsFlow.emit(WsEvent.ThinkingDelta("Thinking...", "session-123"))
advanceUntilIdle() advanceUntilIdle()
state = viewModel.uiState.value state = viewModel.uiState.value
assertTrue(state.isThinking) assertTrue(viewModel.streamingState.value.isThinking)
assertEquals("Thinking...", state.thinkingText) assertEquals("Thinking...", viewModel.streamingState.value.thinkingText)
// Thinking Delta 2 // Thinking Delta 2
mockEventsFlow.emit(WsEvent.ThinkingDelta(" deeper", "session-123")) mockEventsFlow.emit(WsEvent.ThinkingDelta(" deeper", "session-123"))
advanceUntilIdle() advanceUntilIdle()
state = viewModel.uiState.value state = viewModel.uiState.value
assertTrue(state.isThinking) assertTrue(viewModel.streamingState.value.isThinking)
assertEquals("Thinking... deeper", state.thinkingText) assertEquals("Thinking... deeper", viewModel.streamingState.value.thinkingText)
// Token 1 // Token 1
mockEventsFlow.emit(WsEvent.MessageToken("Hello", "session-123")) mockEventsFlow.emit(WsEvent.MessageToken("Hello", "session-123"))
advanceUntilIdle() advanceUntilIdle()
state = viewModel.uiState.value state = viewModel.uiState.value
assertFalse(state.isThinking) assertFalse(viewModel.streamingState.value.isThinking)
assertNotNull(state.streamingMessage) assertNotNull(viewModel.streamingState.value.streamingMessage)
assertEquals("Hello", state.streamingMessage?.content) assertEquals(
"Hello",
viewModel.streamingState.value.streamingMessage
?.content,
)
// Streaming message is not in the main messages list yet, only "Session created" exists // Streaming message is not in the main messages list yet, only "Session created" exists
assertEquals(1, state.messages.size) assertEquals(1, state.messages.size)
@@ -575,8 +583,12 @@ class ChatViewModelTest {
mockEventsFlow.emit(WsEvent.MessageToken(" world", "session-123")) mockEventsFlow.emit(WsEvent.MessageToken(" world", "session-123"))
advanceUntilIdle() advanceUntilIdle()
state = viewModel.uiState.value state = viewModel.uiState.value
assertNotNull(state.streamingMessage) assertNotNull(viewModel.streamingState.value.streamingMessage)
assertEquals("Hello world", state.streamingMessage?.content) assertEquals(
"Hello world",
viewModel.streamingState.value.streamingMessage
?.content,
)
assertEquals(1, state.messages.size) assertEquals(1, state.messages.size)
// Complete // Complete
@@ -584,9 +596,9 @@ class ChatViewModelTest {
advanceUntilIdle() advanceUntilIdle()
state = viewModel.uiState.value state = viewModel.uiState.value
assertFalse(state.isAgentTyping) assertFalse(state.isAgentTyping)
assertNull(state.streamingMessage) assertNull(viewModel.streamingState.value.streamingMessage)
assertFalse(state.isThinking) assertFalse(viewModel.streamingState.value.isThinking)
assertEquals("", state.thinkingText) assertEquals("", viewModel.streamingState.value.thinkingText)
assertEquals(2, state.messages.size) assertEquals(2, state.messages.size)
assertEquals("Session created", state.messages[0].content) assertEquals("Session created", state.messages[0].content)
assertEquals("Hello world!", state.messages[1].content) assertEquals("Hello world!", state.messages[1].content)
@@ -838,7 +850,7 @@ class ChatViewModelTest {
assertEquals(MessageRole.ASSISTANT, state.messages[1].role) assertEquals(MessageRole.ASSISTANT, state.messages[1].role)
assertFalse(state.messages[1].isStreaming) assertFalse(state.messages[1].isStreaming)
assertEquals(MessageRole.TOOL, state.messages[2].role) assertEquals(MessageRole.TOOL, state.messages[2].role)
assertNull(state.streamingMessage) assertNull(viewModel.streamingState.value.streamingMessage)
} }
@Test @Test
@@ -878,9 +890,16 @@ class ChatViewModelTest {
assertFalse(state.messages[1].isStreaming) assertFalse(state.messages[1].isStreaming)
// Active streaming message should be the second segment // Active streaming message should be the second segment
assertNotNull(state.streamingMessage) assertNotNull(viewModel.streamingState.value.streamingMessage)
assertEquals("Second response segment", state.streamingMessage?.content) assertEquals(
assertTrue(state.streamingMessage?.isStreaming == true) "Second response segment",
viewModel.streamingState.value.streamingMessage
?.content,
)
assertTrue(
viewModel.streamingState.value.streamingMessage
?.isStreaming == true,
)
} }
@Test @Test
@@ -974,7 +993,7 @@ class ChatViewModelTest {
// Only the "Session created" message should be present (mismatch events ignored) // Only the "Session created" message should be present (mismatch events ignored)
assertEquals(1, state.messages.size) assertEquals(1, state.messages.size)
assertEquals("Session created", state.messages[0].content) assertEquals("Session created", state.messages[0].content)
assertNull(state.streamingMessage) assertNull(viewModel.streamingState.value.streamingMessage)
assertNull(state.clarifyRequest) assertNull(state.clarifyRequest)
} }
@@ -1008,7 +1027,11 @@ class ChatViewModelTest {
val state = viewModel.uiState.value val state = viewModel.uiState.value
// Content should be "Hello", not "HelloHello" (no duplicate collectors running) // Content should be "Hello", not "HelloHello" (no duplicate collectors running)
assertEquals("Hello", state.streamingMessage?.content) assertEquals(
"Hello",
viewModel.streamingState.value.streamingMessage
?.content,
)
} }
@Test @Test