From 7d00d7b81bb741f3f7990d913c81c599cd86011e Mon Sep 17 00:00:00 2001 From: M57 <41645758+Hy4ri@users.noreply.github.com> Date: Fri, 7 Aug 2026 04:18:50 +0300 Subject: [PATCH] feat(memory): dedicated Memory screen (moved out of System tab) (#835) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(memory): dedicated Memory screen — providers, builtin files & reset (moved from System tab) * feat(memory): move self-improvement & learning activity section from System tab to Memory screen * fix(memory): remove top padding, single-line reset buttons, providers spacing * fix(memory): tighten provider card padding * fix(memory): distinct AutoStories icon for Memory tab * chore: ignore local debug apk * feat(ui): fresh icon set for drawer screens * feat(ui): custom Neurology icon for Memory tab * fix(ui): theme-driven fill for NeurologyIcon (checkColorLiterals) The custom Neurology vector used SolidColor(Color.Black), tripping the hardcoded-color guard (issue #622). The fill only contributes alpha — Icon()'s tint replaces the RGB at draw time, mirroring Material's own icon set — so the literal belongs in theme/ like every other hardcoded color: new IconDefaultFill token, scoped there by the guard's design. --------- Co-authored-by: M57 --- .gitignore | 1 + .../com/m57/hermescontrol/NavigationKeys.kt | 2 + .../com/m57/hermescontrol/ScreenRegistry.kt | 28 +- .../java/com/m57/hermescontrol/theme/Color.kt | 7 + .../hermescontrol/ui/common/NeurologyIcon.kt | 57 +++ .../hermescontrol/ui/memory/MemoryScreen.kt | 434 ++++++++++++++++++ .../ui/memory/MemoryViewModel.kt | 94 ++++ .../ui/plugins/MemoryProviderDetailScreen.kt | 2 +- .../hermescontrol/ui/system/SystemScreen.kt | 242 ---------- .../ui/system/SystemViewModel.kt | 43 +- app/src/main/res/values/strings.xml | 29 +- .../ui/memory/MemoryViewModelTest.kt | 131 ++++++ .../ui/system/SystemViewModelTest.kt | 7 - 13 files changed, 764 insertions(+), 313 deletions(-) create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/common/NeurologyIcon.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/memory/MemoryScreen.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/memory/MemoryViewModel.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/ui/memory/MemoryViewModelTest.kt diff --git a/.gitignore b/.gitignore index 65f8279b..bc73f2c8 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,4 @@ ktlint # direnv .direnv/ hermes-chat-redesign.html +app-debug-835.apk diff --git a/app/src/main/java/com/m57/hermescontrol/NavigationKeys.kt b/app/src/main/java/com/m57/hermescontrol/NavigationKeys.kt index 5dd09c0e..e54bc57a 100644 --- a/app/src/main/java/com/m57/hermescontrol/NavigationKeys.kt +++ b/app/src/main/java/com/m57/hermescontrol/NavigationKeys.kt @@ -39,6 +39,8 @@ import kotlinx.serialization.Serializable @Serializable data object McpServersScreen : NavKey +@Serializable data object MemoryScreen : NavKey + @Serializable data object WebhooksScreen : NavKey @Serializable data object ModelScreen : NavKey diff --git a/app/src/main/java/com/m57/hermescontrol/ScreenRegistry.kt b/app/src/main/java/com/m57/hermescontrol/ScreenRegistry.kt index a36361eb..e8de9905 100644 --- a/app/src/main/java/com/m57/hermescontrol/ScreenRegistry.kt +++ b/app/src/main/java/com/m57/hermescontrol/ScreenRegistry.kt @@ -3,7 +3,6 @@ package com.m57.hermescontrol import androidx.annotation.StringRes import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.Chat -import androidx.compose.material.icons.automirrored.filled.ListAlt import androidx.compose.material.icons.filled.AccountBalanceWallet import androidx.compose.material.icons.filled.AccountCircle import androidx.compose.material.icons.filled.BarChart @@ -11,23 +10,27 @@ import androidx.compose.material.icons.filled.Bolt import androidx.compose.material.icons.filled.Build import androidx.compose.material.icons.filled.Cloud import androidx.compose.material.icons.filled.Code -import androidx.compose.material.icons.filled.Dashboard import androidx.compose.material.icons.filled.Devices +import androidx.compose.material.icons.filled.Dns import androidx.compose.material.icons.filled.EmojiEvents import androidx.compose.material.icons.filled.Extension import androidx.compose.material.icons.filled.Folder import androidx.compose.material.icons.filled.History import androidx.compose.material.icons.filled.HistoryEdu -import androidx.compose.material.icons.filled.Info +import androidx.compose.material.icons.filled.Inventory2 import androidx.compose.material.icons.filled.Key import androidx.compose.material.icons.filled.Memory +import androidx.compose.material.icons.filled.Power import androidx.compose.material.icons.filled.Psychology import androidx.compose.material.icons.filled.Schedule +import androidx.compose.material.icons.filled.Sensors import androidx.compose.material.icons.filled.Settings +import androidx.compose.material.icons.filled.ViewKanban import androidx.compose.material.icons.filled.Webhook import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.vector.ImageVector import androidx.navigation3.runtime.NavKey +import com.m57.hermescontrol.ui.common.NeurologyIcon import com.m57.hermescontrol.ui.achievements.AchievementsScreen as AchievementsScreenContent import com.m57.hermescontrol.ui.analytics.AnalyticsScreen as AnalyticsScreenContent import com.m57.hermescontrol.ui.billing.BillingScreen as BillingScreenContent @@ -41,6 +44,7 @@ import com.m57.hermescontrol.ui.kanban.KanbanScreen as KanbanScreenContent import com.m57.hermescontrol.ui.keys.KeysScreen as KeysScreenContent import com.m57.hermescontrol.ui.logs.LogsScreen as LogsScreenContent import com.m57.hermescontrol.ui.mcp.McpServersScreen as McpServersScreenContent +import com.m57.hermescontrol.ui.memory.MemoryScreen as MemoryScreenContent import com.m57.hermescontrol.ui.model.ModelScreen as ModelScreenContent import com.m57.hermescontrol.ui.pairing.PairingScreen as PairingScreenContent import com.m57.hermescontrol.ui.plugins.PluginsScreen as PluginsScreenContent @@ -107,13 +111,13 @@ object ScreenRegistry { ScreenDefinition( KanbanScreen, R.string.screen_kanban, - Icons.Filled.Dashboard, + Icons.Filled.ViewKanban, DrawerSection.AUTOMATE, ) { sessionId, openDrawer -> KanbanScreenContent(onOpenDrawer = openDrawer) }, ScreenDefinition( SkillsScreen, R.string.screen_skills, - Icons.Filled.Extension, + Icons.Filled.Inventory2, DrawerSection.CONFIGURE, ) { sessionId, openDrawer -> SkillsScreenContent(onOpenDrawer = openDrawer) }, ScreenDefinition( @@ -125,7 +129,7 @@ object ScreenRegistry { ScreenDefinition( PluginsScreen, R.string.screen_plugins, - Icons.Filled.Memory, + Icons.Filled.Extension, DrawerSection.CONFIGURE, ) { sessionId, openDrawer -> PluginsScreenContent(onOpenDrawer = openDrawer) }, ScreenDefinition( @@ -137,9 +141,15 @@ object ScreenRegistry { ScreenDefinition( McpServersScreen, R.string.screen_mcp_servers, - Icons.Filled.Dashboard, + Icons.Filled.Power, DrawerSection.CONFIGURE, ) { sessionId, openDrawer -> McpServersScreenContent(onOpenDrawer = openDrawer) }, + ScreenDefinition( + MemoryScreen, + R.string.screen_memory, + NeurologyIcon, + DrawerSection.CONFIGURE, + ) { sessionId, openDrawer -> MemoryScreenContent(onOpenDrawer = openDrawer) }, ScreenDefinition( ModelScreen, R.string.screen_models, @@ -161,7 +171,7 @@ object ScreenRegistry { ScreenDefinition( ChannelsScreen, R.string.screen_channels, - Icons.AutoMirrored.Filled.ListAlt, + Icons.Filled.Sensors, DrawerSection.CONFIGURE, ) { sessionId, openDrawer -> ChannelsScreenContent(onOpenDrawer = openDrawer) }, ScreenDefinition( @@ -179,7 +189,7 @@ object ScreenRegistry { ScreenDefinition( SystemScreen, R.string.screen_system, - Icons.Filled.Info, + Icons.Filled.Dns, DrawerSection.INSPECT, ) { sessionId, openDrawer -> SystemScreenContent(onOpenDrawer = openDrawer) }, ScreenDefinition( diff --git a/app/src/main/java/com/m57/hermescontrol/theme/Color.kt b/app/src/main/java/com/m57/hermescontrol/theme/Color.kt index bd817b9b..01bdc16e 100644 --- a/app/src/main/java/com/m57/hermescontrol/theme/Color.kt +++ b/app/src/main/java/com/m57/hermescontrol/theme/Color.kt @@ -106,3 +106,10 @@ val CodeDiffDeleteBg = Color(0x26FF5C5C) val CodeDiffDeleteText = Color(0xFFFF5C5C) val CodeDiffHunkBg = Color(0x264DA8FF) val CodeDiffHunkText = Color(0xFF4DA8FF) + +// ── Custom vector icons ───────────────────────────────────────────── +// Default fill for hand-declared ImageVector icons (e.g. NeurologyIcon). +// Mirrors Material's own icon set: vectors are drawn opaque black and +// Icon()'s tint (LocalContentColor) replaces the RGB at draw time — only +// the alpha channel survives, so this must stay opaque. +val IconDefaultFill = Color.Black diff --git a/app/src/main/java/com/m57/hermescontrol/ui/common/NeurologyIcon.kt b/app/src/main/java/com/m57/hermescontrol/ui/common/NeurologyIcon.kt new file mode 100644 index 00000000..f76aa9be --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/common/NeurologyIcon.kt @@ -0,0 +1,57 @@ +package com.m57.hermescontrol.ui.common + +import androidx.compose.ui.graphics.PathFillType +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.StrokeJoin +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.addPathNodes +import androidx.compose.ui.unit.dp +import com.m57.hermescontrol.theme.IconDefaultFill + +/** + * Material Symbols "neurology" (outlined, 24dp) — the glyph ships on + * fonts.google.com but is NOT in the material-icons-extended snapshot the + * app depends on, so it is declared here as a custom vector from Google's + * original path data (viewBox 0 -960 960 960, translated into compose space). + */ +val NeurologyIcon: ImageVector by lazy { + ImageVector.Builder( + name = "Neurology", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 960f, + viewportHeight = 960f, + ).apply { + addGroup( + name = "translate", + rotate = 0f, + pivotX = 0f, + pivotY = 0f, + scaleX = 1f, + scaleY = 1f, + translationX = 0f, + translationY = 960f, + clipPathData = emptyList(), + ) + addPath( + pathData = addPathNodes(PATH), + pathFillType = PathFillType.NonZero, + name = "NeurologyPath", + fill = SolidColor(IconDefaultFill), + fillAlpha = 1f, + stroke = null, + strokeAlpha = 1f, + strokeLineWidth = 1f, + strokeLineCap = StrokeCap.Butt, + strokeLineJoin = StrokeJoin.Miter, + strokeLineMiter = 4f, + trimPathStart = 0f, + trimPathEnd = 1f, + trimPathOffset = 0f, + ) + }.build() +} + +private const val PATH = + "M390-120q-51 0-88-35.5T260-241q-60-8-100-53t-40-106q0-21 5.5-41.5T142-480q-11-18-16.5-38t-5.5-42q0-61 40-105.5t99-52.5q3-51 41-86.5t90-35.5q26 0 48.5 10t41.5 27q18-17 41-27t49-10q52 0 89.5 35t40.5 86q59 8 99.5 53T840-560q0 22-5.5 42T818-480q11 18 16.5 38.5T840-400q0 62-40.5 106.5T699-241q-5 50-41.5 85.5T570-120q-25 0-48.5-9.5T480-156q-19 17-42 26.5t-48 9.5Zm130-590v460q0 21 14.5 35.5T570-200q20 0 34.5-16t15.5-36q-21-8-38.5-21.5T550-306q-10-14-7.5-30t16.5-26q14-10 30-7.5t26 16.5q11 16 28 24.5t37 8.5q33 0 56.5-23.5T760-400q0-5-.5-10t-2.5-10q-17 10-36.5 15t-40.5 5q-17 0-28.5-11.5T640-440q0-17 11.5-28.5T680-480q33 0 56.5-23.5T760-560q0-33-23.5-56T680-640q-11 18-28.5 31.5T613-587q-16 6-31-1t-20-23q-5-16 1.5-31t22.5-20q15-5 24.5-18t9.5-30q0-21-14.5-35.5T570-760q-21 0-35.5 14.5T520-710Zm-80 460v-460q0-21-14.5-35.5T390-760q-21 0-35.5 14.5T340-710q0 16 9 29.5t24 18.5q16 5 23 20t2 31q-6 16-21 23t-31 1q-21-8-38.5-21.5T279-640q-32 1-55.5 24.5T200-560q0 33 23.5 56.5T280-480q17 0 28.5 11.5T320-440q0 17-11.5 28.5T280-400q-21 0-40.5-5T203-420q-2 5-2.5 10t-.5 10q0 33 23.5 56.5T280-320q20 0 37-8.5t28-24.5q10-14 26-16.5t30 7.5q14 10 16.5 26t-7.5 30q-14 19-32 33t-39 22q1 20 16 35.5t35 15.5q21 0 35.5-14.5T440-250Zm40-230Z" diff --git a/app/src/main/java/com/m57/hermescontrol/ui/memory/MemoryScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/memory/MemoryScreen.kt new file mode 100644 index 00000000..3e4229d4 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/memory/MemoryScreen.kt @@ -0,0 +1,434 @@ +@file:OptIn( + androidx.compose.material3.ExperimentalMaterial3Api::class, + androidx.compose.foundation.layout.ExperimentalLayoutApi::class, +) + +package com.m57.hermescontrol.ui.memory + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import com.m57.hermescontrol.MemoryProviderDetailKey +import com.m57.hermescontrol.NavigationController +import com.m57.hermescontrol.R +import com.m57.hermescontrol.data.model.LearningGraphResponse +import com.m57.hermescontrol.data.model.MemoryProviderStatusRow +import com.m57.hermescontrol.ui.common.EmptyState +import com.m57.hermescontrol.ui.common.ErrorState +import com.m57.hermescontrol.ui.common.HermesScaffold +import com.m57.hermescontrol.ui.common.NavIcon +import com.m57.hermescontrol.ui.common.SectionHeader +import com.m57.hermescontrol.ui.common.SkeletonListState +import com.m57.hermescontrol.ui.common.StatCard +import com.m57.hermescontrol.ui.common.StatusBadge +import com.m57.hermescontrol.ui.common.StatusBadgeType +import com.m57.hermescontrol.ui.common.ToastEffect +import com.m57.hermescontrol.ui.common.listContentPadding +import com.m57.hermescontrol.ui.common.listItemSpacing +import com.m57.hermescontrol.ui.plugins.memoryProviderStatusLabel +import com.m57.hermescontrol.ui.plugins.memoryProviderStatusType + +/** + * Memory management home — owns the memory surface that used to live in the + * System tab (active provider, builtin files + reset) plus the provider list + * that drills into per-provider config/setup (issue #783). + */ +@Composable +fun MemoryScreen( + modifier: Modifier = Modifier, + onOpenDrawer: (() -> Unit)? = null, + viewModel: MemoryViewModel = viewModel { MemoryViewModel() }, +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + + var resetTarget by remember { mutableStateOf(null) } + + LaunchedEffect(Unit) { + viewModel.load() + } + + ToastEffect(toastMessage = state.toastMessage, onClearToast = viewModel::clearToast) + + resetTarget?.let { target -> + AlertDialog( + onDismissRequest = { resetTarget = null }, + title = { Text(stringResource(R.string.memory_reset_confirm_title)) }, + text = { Text(stringResource(R.string.memory_reset_confirm_desc, target)) }, + confirmButton = { + TextButton( + onClick = { + resetTarget = null + viewModel.resetMemory(target) + }, + ) { + Text(stringResource(R.string.action_ok)) + } + }, + dismissButton = { + TextButton(onClick = { resetTarget = null }) { + Text(stringResource(R.string.system_confirm_cancel)) + } + }, + ) + } + + HermesScaffold( + title = { Text(stringResource(R.string.screen_memory)) }, + navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) }, + isRefreshing = state.isLoading, + onRefresh = { viewModel.load() }, + modifier = modifier, + ) { paddingValues -> + when { + state.isLoading && state.memory == null -> { + SkeletonListState(modifier = Modifier) + } + + state.errorMessage != null && state.memory == null -> { + ErrorState( + message = state.errorMessage ?: "", + onRetry = { viewModel.load() }, + modifier = Modifier, + ) + } + + state.memory == null -> { + EmptyState( + title = stringResource(R.string.memory_empty_title), + subtitle = stringResource(R.string.memory_empty_desc), + onAction = { viewModel.load() }, + actionLabel = stringResource(R.string.content_desc_refresh), + modifier = Modifier, + ) + } + + else -> { + MemoryContent( + memory = state.memory!!, + learningGraph = state.learningGraph, + resetting = state.resetting, + onResetRequest = { resetTarget = it }, + modifier = Modifier, + ) + } + } + } +} + +@Composable +private fun MemoryContent( + memory: com.m57.hermescontrol.data.model.MemoryResponse, + learningGraph: LearningGraphResponse?, + resetting: String?, + onResetRequest: (String) -> Unit, + modifier: Modifier = Modifier, +) { + LazyColumn( + modifier = modifier.fillMaxSize(), + contentPadding = listContentPadding, + verticalArrangement = listItemSpacing, + ) { + // Active provider + builtin files + item { + Card( + modifier = Modifier.fillMaxWidth(), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + ) { + Column(modifier = Modifier.padding(16.dp)) { + if (memory.active.isNullOrBlank()) { + Text( + text = stringResource(R.string.memory_builtin), + style = MaterialTheme.typography.titleMedium, + ) + } else { + Text( + text = stringResource(R.string.memory_active, memory.active), + style = MaterialTheme.typography.titleMedium, + ) + } + + memory.builtin_files?.let { files -> + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = + stringResource( + R.string.memory_builtin_sizes, + files.memory?.let { formatBytes(it) } ?: "?", + files.user?.let { formatBytes(it) } ?: "?", + ), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + Spacer(modifier = Modifier.height(12.dp)) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + FilledTonalButton( + onClick = { onResetRequest("memory") }, + enabled = resetting == null, + ) { + Text( + text = stringResource(R.string.memory_reset_memory), + maxLines = 1, + softWrap = false, + ) + } + FilledTonalButton( + onClick = { onResetRequest("user") }, + enabled = resetting == null, + ) { + Text( + text = stringResource(R.string.memory_reset_user), + maxLines = 1, + softWrap = false, + ) + } + FilledTonalButton( + onClick = { onResetRequest("all") }, + enabled = resetting == null, + ) { + Text( + text = stringResource(R.string.memory_reset_all), + maxLines = 1, + softWrap = false, + ) + } + } + } + } + } + + // Providers + if (memory.providers.isNotEmpty()) { + item { + Text( + text = stringResource(R.string.memory_providers_heading).uppercase(), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontWeight = androidx.compose.ui.text.font.FontWeight.SemiBold, + modifier = Modifier.padding(start = 16.dp, end = 16.dp), + ) + } + items(memory.providers, key = { it.name }) { provider -> + MemoryProviderRow( + provider = provider, + isActive = provider.name == memory.active, + onClick = { + NavigationController.navigateTo( + MemoryProviderDetailKey( + name = provider.name, + label = provider.name, + ), + ) + }, + ) + } + } + + // Self-Improvement & learning activity (moved from System tab) + item { + SelfImprovementSection(graph = learningGraph) + } + } +} + +@Composable +private fun SelfImprovementSection(graph: LearningGraphResponse?) { + SectionHeader(title = stringResource(R.string.memory_sec_self_improvement)) + Card( + modifier = Modifier.fillMaxWidth(), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + ) { + Column(modifier = Modifier.padding(16.dp)) { + Text( + text = stringResource(R.string.memory_self_improvement_desc), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.height(8.dp)) + + if (graph == null || graph.nodes.isEmpty()) { + Text( + text = stringResource(R.string.memory_self_improvement_no_activity), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), + ) + } else { + val skillNodes = graph.nodes.filter { it.kind == "skill" } + val memoryNodes = graph.nodes.filter { it.kind == "memory" } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + StatCard( + label = "Learned Skills", + value = "${skillNodes.size}", + modifier = Modifier.weight(1f), + ) + StatCard( + label = "Memories & Facts", + value = "${memoryNodes.size}", + modifier = Modifier.weight(1f), + ) + } + + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = "Recent Agent Self-Improvement Events", + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface, + ) + Spacer(modifier = Modifier.height(4.dp)) + + val sortedNodes = + graph.nodes + .filter { it.timestamp != null || it.kind == "skill" } + .sortedByDescending { it.timestamp ?: 0L } + .take(8) + + sortedNodes.forEach { node -> + Surface( + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + shape = RoundedCornerShape(8.dp), + color = MaterialTheme.colorScheme.surfaceContainerHigh, + ) { + Row( + modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + val icon = if (node.kind == "skill") "⚡" else "🧠" + Text(text = icon, fontSize = 16.sp) + Spacer(modifier = Modifier.width(4.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = node.label, + style = + MaterialTheme.typography.bodyMedium.copy( + fontWeight = FontWeight.SemiBold, + ), + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + val cat = node.category ?: node.kind + val creator = node.createdBy?.let { " • by $it" } ?: "" + Text( + text = "[$cat]$creator", + style = MaterialTheme.typography.bodySmall.copy(fontSize = 11.sp), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + node.useCount.takeIf { it > 0 }?.let { uses -> + StatusBadge( + text = "$uses uses", + status = StatusBadgeType.INFO, + ) + } + } + } + } + } + } + } +} + +@Composable +private fun MemoryProviderRow( + provider: MemoryProviderStatusRow, + isActive: Boolean, + onClick: () -> Unit, +) { + Card( + modifier = + Modifier + .fillMaxWidth() + .clickable(onClick = onClick), + ) { + Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = provider.name, + style = MaterialTheme.typography.titleSmall, + modifier = Modifier.weight(1f), + ) + if (isActive) { + StatusBadge( + text = stringResource(R.string.memory_provider_active), + status = StatusBadgeType.SUCCESS, + ) + } + StatusBadge( + text = memoryProviderStatusLabel(provider.status), + status = memoryProviderStatusType(provider.status), + ) + } + if (provider.description.isNotBlank()) { + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = provider.description, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} + +private fun formatBytes(bytes: Long): String = + when { + bytes >= 1024 * 1024 -> "%.1f MB".format(bytes / (1024.0 * 1024.0)) + bytes >= 1024 -> "%.1f KB".format(bytes / 1024.0) + else -> "$bytes B" + } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/memory/MemoryViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/memory/MemoryViewModel.kt new file mode 100644 index 00000000..85cb7657 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/memory/MemoryViewModel.kt @@ -0,0 +1,94 @@ +package com.m57.hermescontrol.ui.memory + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.m57.hermescontrol.data.model.LearningGraphResponse +import com.m57.hermescontrol.data.model.MemoryResponse +import com.m57.hermescontrol.data.remote.ApiClient +import com.m57.hermescontrol.data.remote.NetworkResult +import com.m57.hermescontrol.data.remote.safeApiCall +import com.m57.hermescontrol.ui.common.ToastHost +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +data class MemoryUiState( + val isLoading: Boolean = false, + val memory: MemoryResponse? = null, + val learningGraph: LearningGraphResponse? = null, + val errorMessage: String? = null, + val toastMessage: String? = null, + val resetting: String? = null, +) + +/** + * Memory management home (moved out of the System tab) — active provider, + * builtin memory files with reset, and the provider list that drills into + * per-provider config/setup (issue #783). Deliberately no explicit IO hop: + * Retrofit suspend calls already run off the caller thread, keeping tests + * free of the static Dispatchers mock that bleeds across test classes. + */ +class MemoryViewModel : ViewModel(), ToastHost { + private val _uiState = MutableStateFlow(MemoryUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + fun load() { + if (_uiState.value.isLoading) return + _uiState.update { it.copy(isLoading = true, errorMessage = null) } + viewModelScope.launch { + val result = safeApiCall { ApiClient.hermesApi.getMemory() } + when (result) { + is NetworkResult.Success -> + _uiState.update { it.copy(isLoading = false, memory = result.data) } + is NetworkResult.Failure -> + _uiState.update { + it.copy( + isLoading = false, + errorMessage = result.error.message, + ) + } + } + loadLearningGraph() + } + } + + private fun loadLearningGraph() { + viewModelScope.launch { + val result = safeApiCall { ApiClient.hermesApi.getLearningGraph() } + if (result is NetworkResult.Success) { + _uiState.update { it.copy(learningGraph = result.data) } + } + } + } + + fun resetMemory(target: String) { + if (_uiState.value.resetting != null) return + _uiState.update { it.copy(resetting = target) } + viewModelScope.launch { + val result = safeApiCall { ApiClient.hermesApi.resetMemory(mapOf("target" to target)) } + when (result) { + is NetworkResult.Success -> + _uiState.update { + it.copy( + resetting = null, + toastMessage = "Memory ($target) reset successfully", + ) + } + is NetworkResult.Failure -> + _uiState.update { + it.copy( + resetting = null, + toastMessage = "Failed to reset memory: ${result.error.message}", + ) + } + } + load() + } + } + + override fun clearToast() { + _uiState.update { it.copy(toastMessage = null) } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/plugins/MemoryProviderDetailScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/plugins/MemoryProviderDetailScreen.kt index b79c7519..29d87924 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/plugins/MemoryProviderDetailScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/plugins/MemoryProviderDetailScreen.kt @@ -455,7 +455,7 @@ private fun SetupStepRow(step: MemoryProviderSetupResult) { } } -private fun memoryProviderStatusType(status: String): StatusBadgeType = +fun memoryProviderStatusType(status: String): StatusBadgeType = when (status) { "ready" -> StatusBadgeType.SUCCESS "needs_config" -> StatusBadgeType.WARNING diff --git a/app/src/main/java/com/m57/hermescontrol/ui/system/SystemScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/system/SystemScreen.kt index 77c4a96a..e9706a7b 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/system/SystemScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/system/SystemScreen.kt @@ -21,7 +21,6 @@ import androidx.compose.material.icons.filled.Build import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.CloudDownload import androidx.compose.material.icons.filled.ContentCopy -import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.DeleteForever import androidx.compose.material.icons.filled.Devices import androidx.compose.material.icons.filled.HealthAndSafety @@ -49,7 +48,6 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable @@ -70,7 +68,6 @@ import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import com.m57.hermescontrol.R @@ -152,29 +149,6 @@ fun SystemScreen( ) } - // Memory reset confirmation - var resetTarget by remember { mutableStateOf(null) } - if (resetTarget != null) { - AlertDialog( - onDismissRequest = { resetTarget = null }, - title = { Text(stringResource(R.string.system_memory_reset_confirm_title)) }, - text = { Text(stringResource(R.string.system_memory_reset_confirm_desc)) }, - confirmButton = { - TextButton(onClick = { - resetTarget?.let { viewModel.resetMemory(it) } - resetTarget = null - }) { - Text(stringResource(R.string.action_ok)) - } - }, - dismissButton = { - TextButton(onClick = { resetTarget = null }) { - Text(stringResource(R.string.system_confirm_cancel)) - } - }, - ) - } - // Credential remove confirmation var credToRemove by remember { mutableStateOf?>(null) } if (credToRemove != null) { @@ -290,15 +264,9 @@ fun SystemScreen( // ── 3. Curator ───────────────────────────────────────── curatorSection(state, spacing, statusColors, viewModel) - // ── 3.5 Self-Improvement & Learning ─────────────────── - selfImprovementSection(state, spacing, statusColors) - // ── 4. Gateway ───────────────────────────────────────── gatewaySection(state, spacing, statusColors, viewModel) - // ── 5. Memory ────────────────────────────────────────── - memorySection(state, spacing, resetTarget, { resetTarget = it }, viewModel) - // ── 6. Credentials ───────────────────────────────────── credentialsSection(state, spacing, viewModel, credToRemove, { credToRemove = it }) @@ -712,122 +680,6 @@ private fun LazyListScope.curatorSection( } } -private fun LazyListScope.selfImprovementSection( - state: SystemUiState, - spacing: com.m57.hermescontrol.theme.Spacing, - statusColors: com.m57.hermescontrol.theme.HermesStatusColors, -) { - val graph = state.learningGraph - item { - SectionHeader(title = stringResource(R.string.system_sec_self_improvement)) - } - item { - Card( - modifier = Modifier.fillMaxWidth(), - colors = - CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainer, - ), - ) { - Column(modifier = Modifier.padding(spacing.md)) { - Text( - text = stringResource(R.string.system_self_improvement_desc), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Spacer(modifier = Modifier.height(spacing.sm)) - - if (graph == null || graph.nodes.isEmpty()) { - Text( - text = stringResource(R.string.system_self_improvement_no_activity), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), - ) - } else { - val skillNodes = graph.nodes.filter { it.kind == "skill" } - val memoryNodes = graph.nodes.filter { it.kind == "memory" } - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(spacing.sm), - ) { - StatCard( - label = "Learned Skills", - value = "${skillNodes.size}", - modifier = Modifier.weight(1f), - ) - StatCard( - label = "Memories & Facts", - value = "${memoryNodes.size}", - modifier = Modifier.weight(1f), - ) - } - - Spacer(modifier = Modifier.height(spacing.md)) - Text( - text = "Recent Agent Self-Improvement Events", - style = MaterialTheme.typography.labelLarge, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onSurface, - ) - Spacer(modifier = Modifier.height(spacing.xs)) - - val sortedNodes = - graph.nodes - .filter { it.timestamp != null || it.kind == "skill" } - .sortedByDescending { it.timestamp ?: 0L } - .take(8) - - sortedNodes.forEach { node -> - Surface( - modifier = - Modifier - .fillMaxWidth() - .padding(vertical = 4.dp), - shape = androidx.compose.foundation.shape.RoundedCornerShape(8.dp), - color = MaterialTheme.colorScheme.surfaceContainerHigh, - ) { - Row( - modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - val icon = if (node.kind == "skill") "⚡" else "🧠" - Text(text = icon, fontSize = 16.sp) - Spacer(modifier = Modifier.width(spacing.xs)) - Column(modifier = Modifier.weight(1f)) { - Text( - text = node.label, - style = - MaterialTheme.typography.bodyMedium.copy( - fontWeight = FontWeight.SemiBold, - ), - color = MaterialTheme.colorScheme.onSurface, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - val cat = node.category ?: node.kind - val creator = node.createdBy?.let { " • by $it" } ?: "" - Text( - text = "[$cat]$creator", - style = MaterialTheme.typography.bodySmall.copy(fontSize = 11.sp), - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - node.useCount.takeIf { it > 0 }?.let { uses -> - StatusBadge( - text = "$uses uses", - status = StatusBadgeType.INFO, - ) - } - } - } - } - } - } - } - } -} - private fun LazyListScope.gatewaySection( state: SystemUiState, spacing: com.m57.hermescontrol.theme.Spacing, @@ -980,100 +832,6 @@ private fun LazyListScope.gatewaySection( } } -private fun LazyListScope.memorySection( - state: SystemUiState, - spacing: com.m57.hermescontrol.theme.Spacing, - resetTarget: String?, - onResetRequest: (String) -> Unit, - viewModel: SystemViewModel, -) { - state.memory?.let { memory -> - item { - SectionHeader(title = stringResource(R.string.system_sec_memory)) - } - item { - Card( - modifier = Modifier.fillMaxWidth(), - colors = - CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainer, - ), - ) { - Column(modifier = Modifier.padding(spacing.md)) { - // Active provider - memory.active?.let { active -> - InfoRow( - label = stringResource(R.string.system_memory_external, active), - value = "", - ) - } ?: InfoRow( - label = stringResource(R.string.system_memory_builtin), - value = "", - ) - - // Change in Plugins link - InfoRow( - label = stringResource(R.string.system_memory_change_plugins), - value = "", - ) - - // Builtin file sizes - memory.builtin_files?.let { files -> - HorizontalDivider(modifier = Modifier.padding(vertical = spacing.sm)) - InfoRow( - label = - stringResource( - R.string.system_memory_builtin_label, - files.memory?.let { formatBytes(it) } ?: "?", - files.user?.let { formatBytes(it) } ?: "?", - ), - value = "", - ) - - // Reset buttons - Spacer(modifier = Modifier.height(spacing.sm)) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(spacing.sm), - ) { - FilledTonalButton( - onClick = { onResetRequest("memory") }, - modifier = Modifier.weight(1f), - contentPadding = actionButtonPadding, - ) { - ActionButtonContent( - icon = Icons.Filled.Delete, - text = stringResource(R.string.system_memory_reset_memory), - ) - } - FilledTonalButton( - onClick = { onResetRequest("user") }, - modifier = Modifier.weight(1f), - contentPadding = actionButtonPadding, - ) { - ActionButtonContent( - icon = Icons.Filled.Delete, - text = stringResource(R.string.system_memory_reset_user), - ) - } - FilledTonalButton( - onClick = { onResetRequest("all") }, - modifier = Modifier.weight(1f), - contentPadding = actionButtonPadding, - ) { - ActionButtonContent( - icon = Icons.Filled.DeleteForever, - text = stringResource(R.string.system_memory_reset_all), - ) - } - } - } - } - } - } - } -} - private fun LazyListScope.credentialsSection( state: SystemUiState, spacing: com.m57.hermescontrol.theme.Spacing, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/system/SystemViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/system/SystemViewModel.kt index 377c7c3c..a2a87614 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/system/SystemViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/system/SystemViewModel.kt @@ -12,8 +12,6 @@ import com.m57.hermescontrol.data.model.CuratorResponse import com.m57.hermescontrol.data.model.DebugShareResponse import com.m57.hermescontrol.data.model.DoctorResponse import com.m57.hermescontrol.data.model.HookResponse -import com.m57.hermescontrol.data.model.LearningGraphResponse -import com.m57.hermescontrol.data.model.MemoryResponse import com.m57.hermescontrol.data.model.PortalResponse import com.m57.hermescontrol.data.model.StatusResponse import com.m57.hermescontrol.data.model.SystemStatsResponse @@ -40,8 +38,6 @@ data class SystemUiState( val stats: SystemStatsResponse? = null, val portal: PortalResponse? = null, val curator: CuratorResponse? = null, - val memory: MemoryResponse? = null, - val learningGraph: LearningGraphResponse? = null, val credentials: List = emptyList(), val checkpoints: CheckpointsResponse? = null, val hooks: HookResponse? = null, @@ -101,8 +97,7 @@ class SystemViewModel : val statusDeferred = async(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.getStatus() } } val portalDeferred = async(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.getPortal() } } val curatorDeferred = async(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.getCurator() } } - val memoryDeferred = async(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.getMemory() } } - val learningDeferred = async(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.getLearningGraph() } } + val credDeferred = async(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.getCredentialPool() } } val checkpointsDeferred = async(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.getCheckpoints() } } val hooksDeferred = async(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.getHooks() } } @@ -114,8 +109,7 @@ class SystemViewModel : val statusResult = statusDeferred.await() val portalResult = portalDeferred.await() val curatorResult = curatorDeferred.await() - val memoryResult = memoryDeferred.await() - val learningResult = learningDeferred.await() + val credResult = credDeferred.await() val checkpointsResult = checkpointsDeferred.await() val hooksResult = hooksDeferred.await() @@ -129,8 +123,6 @@ class SystemViewModel : status = (statusResult as? NetworkResult.Success)?.data, portal = (portalResult as? NetworkResult.Success)?.data, curator = (curatorResult as? NetworkResult.Success)?.data, - memory = (memoryResult as? NetworkResult.Success)?.data, - learningGraph = (learningResult as? NetworkResult.Success)?.data, credentials = ((credResult as? NetworkResult.Success)?.data)?.providers ?: emptyList(), checkpoints = (checkpointsResult as? NetworkResult.Success)?.data, @@ -148,7 +140,6 @@ class SystemViewModel : "status" to statusResult, "portal" to portalResult, "curator" to curatorResult, - "memory" to memoryResult, "credentials" to credResult, "checkpoints" to checkpointsResult, "hooks" to hooksResult, @@ -193,15 +184,6 @@ class SystemViewModel : } } - fun loadMemory() { - viewModelScope.launch { - val result = withContext(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.getMemory() } } - if (result is NetworkResult.Success) { - _uiState.update { it.copy(memory = result.data) } - } - } - } - fun loadStatus() { viewModelScope.launch { val result = withContext(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.getStatus() } } @@ -318,27 +300,6 @@ class SystemViewModel : ) } - // ── Memory actions ───────────────────────────────────────────────── - - fun resetMemory(target: String) { - viewModelScope.launch { - val result = - withContext(Dispatchers.IO) { - safeApiCall { ApiClient.hermesApi.resetMemory(mapOf("target" to target)) } - } - when (result) { - is NetworkResult.Success -> { - _uiState.update { it.copy(toastMessage = "Memory ($target) reset successfully") } - loadMemory() - } - - is NetworkResult.Failure -> { - _uiState.update { it.copy(toastMessage = "Failed to reset memory: ${result.error.message}") } - } - } - } - } - // ── Credential pool actions ──────────────────────────────────────── fun updateCredProvider(v: String) { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d6395de6..fec2cca5 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -81,6 +81,7 @@ Plugins Config MCP Servers + Memory Models Pairing Keys @@ -366,9 +367,9 @@ Skill curator - Self-Improvement & Learning Activity - Chronological log of agent skills, memories, and fact store updates learned over time. - No self-improvement events recorded yet. + Self-Improvement & Learning Activity + Chronological log of agent skills, memories, and fact store updates learned over time. + No self-improvement events recorded yet. active paused disabled @@ -388,16 +389,18 @@ Stop - Memory - External provider: %1$s - built-in only - Change in Plugins - Built-in files — MEMORY.md: %1$s · USER.md: %2$s - Reset MEMORY.md - Reset USER.md - Reset all - Reset memory - This permanently erases the selected built-in memory files. This cannot be undone. + Active: %1$s + Built-in memory + MEMORY.md: %1$s · USER.md: %2$s + Reset MEMORY.md + Reset USER.md + Reset all + Reset memory + This permanently erases the %1$s built-in memory file(s). This cannot be undone. + No memory data + Could not load memory status. + Providers + Active Credential pool diff --git a/app/src/test/java/com/m57/hermescontrol/ui/memory/MemoryViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/memory/MemoryViewModelTest.kt new file mode 100644 index 00000000..b8de1cf5 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/memory/MemoryViewModelTest.kt @@ -0,0 +1,131 @@ +package com.m57.hermescontrol.ui.memory + +import com.m57.hermescontrol.data.model.MemoryProviderStatusRow +import com.m57.hermescontrol.data.model.MemoryResponse +import com.m57.hermescontrol.data.remote.ApiClient +import com.m57.hermescontrol.data.remote.HermesApiService +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.unmockkAll +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import okhttp3.ResponseBody.Companion.toResponseBody +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import retrofit2.Response + +/** + * Memory management home (moved out of the System tab). No + * mockkStatic(Dispatchers) — the ViewModel does no explicit IO hop, so + * setMain(StandardTestDispatcher) alone is deterministic. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class MemoryViewModelTest { + private val testDispatcher = StandardTestDispatcher() + private lateinit var mockApi: HermesApiService + + private val memoryResponse = + MemoryResponse( + active = "web", + builtin_files = + com.m57.hermescontrol.data.model.BuiltinFileSizes( + memory = 1024, + user = 2048, + ), + providers = + listOf( + MemoryProviderStatusRow(name = "web", status = "ready"), + MemoryProviderStatusRow(name = "tts", status = "needs_config"), + ), + ) + + @Before + fun setUp() { + Dispatchers.setMain(testDispatcher) + mockkObject(ApiClient) + mockApi = mockk(relaxed = true) + every { ApiClient.hermesApi } returns mockApi + coEvery { mockApi.getMemory() } returns Response.success(memoryResponse) + } + + @After + fun tearDown() { + unmockkAll() + Dispatchers.resetMain() + } + + @Test + fun `load fetches memory status with providers`() { + val vm = MemoryViewModel() + vm.load() + testDispatcher.scheduler.advanceUntilIdle() + + val state = vm.uiState.value + assertEquals("web", state.memory?.active) + assertEquals(2, state.memory?.providers?.size) + assertEquals(1024L, state.memory?.builtin_files?.memory) + } + + @Test + fun `load also fetches learning graph for self-improvement section`() { + val graph = com.m57.hermescontrol.data.model.LearningGraphResponse(nodes = emptyList()) + coEvery { mockApi.getLearningGraph() } returns Response.success(graph) + val vm = MemoryViewModel() + vm.load() + testDispatcher.scheduler.advanceUntilIdle() + + coVerify { mockApi.getLearningGraph() } + assertEquals(graph, vm.uiState.value.learningGraph) + } + + @Test + fun `load failure surfaces error message`() { + coEvery { mockApi.getMemory() } returns + Response.error(500, "boom".toResponseBody()) + val vm = MemoryViewModel() + vm.load() + testDispatcher.scheduler.advanceUntilIdle() + + assertNull(vm.uiState.value.memory) + assertTrue(vm.uiState.value.errorMessage.orEmpty().isNotBlank()) + } + + @Test + fun `resetMemory posts target and reloads`() { + coEvery { mockApi.resetMemory(mapOf("target" to "all")) } returns + Response.success(emptyMap()) + val vm = MemoryViewModel() + vm.load() + testDispatcher.scheduler.advanceUntilIdle() + vm.resetMemory("all") + testDispatcher.scheduler.advanceUntilIdle() + + coVerify { mockApi.resetMemory(mapOf("target" to "all")) } + assertEquals("Memory (all) reset successfully", vm.uiState.value.toastMessage) + // One load + one reload after reset. + coVerify(exactly = 2) { mockApi.getMemory() } + } + + @Test + fun `resetMemory failure shows error toast`() { + coEvery { mockApi.resetMemory(mapOf("target" to "memory")) } returns + Response.error(500, "boom".toResponseBody()) + val vm = MemoryViewModel() + vm.resetMemory("memory") + testDispatcher.scheduler.advanceUntilIdle() + + assertTrue(vm.uiState.value.toastMessage.orEmpty().contains("Failed to reset memory")) + assertNull(vm.uiState.value.resetting) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/ui/system/SystemViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/system/SystemViewModelTest.kt index 21853671..16369a97 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/system/SystemViewModelTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/system/SystemViewModelTest.kt @@ -48,11 +48,4 @@ class SystemViewModelTest { viewModel.closeUpdateConfirm() assertFalse(viewModel.uiState.value.updateConfirmOpen) } - - @Test - fun `initial uiState has null learningGraph`() { - val viewModel = SystemViewModel() - - org.junit.Assert.assertNull(viewModel.uiState.value.learningGraph) - } }