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:
14 files changed
+435
-169
No files matched your search
@@ -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
|
||||||
|
|||||||
Reference in new issue
Block a user