From 1b596746af4e23af7587812c9b50aa7ac2f63ecc Mon Sep 17 00:00:00 2001 From: M57 <41645758+Hy4ri@users.noreply.github.com> Date: Wed, 29 Jul 2026 21:43:08 +0300 Subject: [PATCH] feat(chat/activity): Self-improvement event badges for memory & skill updates (#744) * feat(chat/activity): Self-improvement event badges for memory & skill updates (#737) * feat(chat): Handle background review.summary WS events for self-improvement notices --------- Co-authored-by: M57 --- .../data/model/LearningGraphModels.kt | 42 ++++++ .../data/remote/HermesApiService.kt | 6 + .../m57/hermescontrol/data/ws/EventParser.kt | 5 + .../com/m57/hermescontrol/data/ws/WsEvent.kt | 10 ++ .../m57/hermescontrol/ui/chat/ChatBubble.kt | 108 +++++++++++++++- .../ui/chat/ChatWsEventReducer.kt | 22 ++++ .../hermescontrol/ui/system/SystemScreen.kt | 121 ++++++++++++++++++ .../ui/system/SystemViewModel.kt | 5 + app/src/main/res/values/strings.xml | 4 + .../ui/chat/ChatWsEventReducerTest.kt | 23 ++++ .../ui/chat/ToolBubbleParsingTest.kt | 29 +++++ .../ui/system/SystemViewModelTest.kt | 7 + 12 files changed, 381 insertions(+), 1 deletion(-) create mode 100644 app/src/main/java/com/m57/hermescontrol/data/model/LearningGraphModels.kt diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/LearningGraphModels.kt b/app/src/main/java/com/m57/hermescontrol/data/model/LearningGraphModels.kt new file mode 100644 index 00000000..4057fd50 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/model/LearningGraphModels.kt @@ -0,0 +1,42 @@ +package com.m57.hermescontrol.data.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * Data structures for the Hermes agent self-improvement / learning graph endpoint + * GET /api/learning/graph + */ +@Serializable +data class LearningGraphNode( + val id: String, + val label: String, + val kind: String, // "skill" | "memory" + val timestamp: Long? = null, + val category: String? = null, + @SerialName("useCount") val useCount: Int = 0, + val state: String? = null, + @SerialName("createdBy") val createdBy: String? = null, + val pinned: Boolean = false, +) + +@Serializable +data class LearningGraphEdge( + val source: String, + val target: String, +) + +@Serializable +data class LearningGraphMemoryCard( + val source: String, + val timestamp: Long? = null, + val title: String, + val body: String, +) + +@Serializable +data class LearningGraphResponse( + val nodes: List = emptyList(), + val edges: List = emptyList(), + val memory: List = emptyList(), +) diff --git a/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt b/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt index ad4b9f7d..75ed12f8 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt @@ -34,6 +34,7 @@ import com.m57.hermescontrol.data.model.HookResponse import com.m57.hermescontrol.data.model.KanbanBoardResponse import com.m57.hermescontrol.data.model.KanbanBoardsResponse import com.m57.hermescontrol.data.model.KanbanTask +import com.m57.hermescontrol.data.model.LearningGraphResponse import com.m57.hermescontrol.data.model.LogResponse import com.m57.hermescontrol.data.model.ManagedDirectoryCreate import com.m57.hermescontrol.data.model.ManagedFileActionResponse @@ -667,6 +668,11 @@ interface HermesApiService { suspend fun getPortal(): Response // ── Admin: Curator ──────────────────────────────────────────────── + @GET("api/learning/graph") + suspend fun getLearningGraph( + @Query("profile") profile: String? = null, + ): Response + @GET("api/curator") suspend fun getCurator(): Response diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/EventParser.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/EventParser.kt index ac5f34bb..1a979cd6 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/EventParser.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/EventParser.kt @@ -149,6 +149,11 @@ object EventParser { WsEvent.BackgroundComplete(payload) } + "review.summary" -> { + val text = (payload?.get("text") as? String)?.trim() ?: "" + WsEvent.ReviewSummary(text, sessionId) + } + "session.updated" -> { WsEvent.SessionUpdated(payload) } diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/WsEvent.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/WsEvent.kt index dd43ad7e..19e86973 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/WsEvent.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/WsEvent.kt @@ -123,6 +123,16 @@ sealed class WsEvent { val sessionId: String? = null, ) : WsEvent() + /** + * Self-improvement background review summary event. + * Emitted when background review patches a skill or saves a memory. + * Payload: `{ text: "💾 Self-improvement review: ..." }` + */ + data class ReviewSummary( + val text: String, + val sessionId: String? = null, + ) : WsEvent() + // ── Status ─────────────────────────────────────────────────────────── data class StatusUpdate( diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatBubble.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatBubble.kt index b36daac9..1ae9df0d 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatBubble.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatBubble.kt @@ -471,12 +471,70 @@ private fun AssistantBubble( } } +@Composable +private fun SelfImprovementReviewCard( + content: String, + modifier: Modifier = Modifier, +) { + val cleanText = + content + .removePrefix("💾") + .replace(Regex("^\\s*Self-improvement review:\\s*", RegexOption.IGNORE_CASE), "") + .trim() + val isSkill = + cleanText.contains("skill", ignoreCase = true) || + cleanText.contains("SKILL.md", ignoreCase = true) + val icon = if (isSkill) "⚡" else "🧠" + val title = + if (isSkill) { + "Self-Improvement Review • Skill Patched" + } else { + "Self-Improvement Review • Memory Updated" + } + + Card( + modifier = + modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 4.dp) + .testTag("self_improvement_review_card"), + shape = RoundedCornerShape(10.dp), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + ), + ) { + Column(modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text(text = icon, fontSize = 16.sp) + Spacer(Modifier.width(8.dp)) + Text( + text = title, + style = MaterialTheme.typography.labelMedium.copy(fontWeight = FontWeight.Bold), + color = MaterialTheme.colorScheme.primary, + ) + } + Spacer(Modifier.height(4.dp)) + Text( + text = cleanText, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + } + } +} + @Composable private fun SystemBubble( message: ChatMessage, onRespondApproval: (String) -> Unit = {}, modifier: Modifier = Modifier, ) { + if (message.content.contains("Self-improvement review:", ignoreCase = true)) { + SelfImprovementReviewCard(content = message.content, modifier = modifier) + return + } + Column( modifier = modifier @@ -941,7 +999,13 @@ fun parseToolOutput( val errorMsg = dataSource.get("error")?.takeIf { !it.isJsonNull }?.asString val usage = dataSource.get("usage")?.takeIf { !it.isJsonNull }?.asString - val summaryText = "💾 $action${target?.let { " ($it)" } ?: ""}" + val summaryText = + when (action.lowercase()) { + "add" -> "🧠 Memory Saved${target?.let { " ($it)" } ?: ""}" + "replace", "update" -> "🧠 Memory Updated${target?.let { " ($it)" } ?: ""}" + "remove", "delete" -> "🧠 Memory Removed${target?.let { " ($it)" } ?: ""}" + else -> "🧠 Memory $action${target?.let { " ($it)" } ?: ""}" + } val mainOutput = when { errorMsg != null -> "❌ $errorMsg" @@ -972,6 +1036,48 @@ fun parseToolOutput( ) } + // ── Skill Manage-specific formatting (Self-Improvement) ── + if (resolvedToolName == "skill_manage") { + val action = argsObj?.get("action")?.takeIf { !it.isJsonNull }?.asString ?: "" + val name = argsObj?.get("name")?.takeIf { !it.isJsonNull }?.asString + val category = argsObj?.get("category")?.takeIf { !it.isJsonNull }?.asString + val errorMsg = dataSource.get("error")?.takeIf { !it.isJsonNull }?.asString + val success = dataSource.get("success")?.takeIf { !it.isJsonNull }?.asBoolean ?: true + val msg = dataSource.get("message")?.takeIf { !it.isJsonNull }?.asString + + val summaryText = + when (action.lowercase()) { + "create" -> "⚡ Skill Created${name?.let { ": $it" } ?: ""}" + "patch" -> "⚡ Skill Patched${name?.let { ": $it" } ?: ""}" + "edit" -> "⚡ Skill Edited${name?.let { ": $it" } ?: ""}" + "delete" -> "⚡ Skill Archived${name?.let { ": $it" } ?: ""}" + "write_file" -> "⚡ Skill File Written${name?.let { ": $it" } ?: ""}" + "remove_file" -> "⚡ Skill File Removed${name?.let { ": $it" } ?: ""}" + else -> + "⚡ Skill ${action.replaceFirstChar { + if (it.isLowerCase()) it.titlecase(java.util.Locale.getDefault()) else it.toString() + }}${name?.let { ": $it" } ?: ""}" + } + val mainOutput = + when { + errorMsg != null -> "❌ $errorMsg" + !success -> "❌ Skill operation failed" + msg != null -> "✅ $msg" + name != null -> "✅ Skill '$name' $action succeeded${category?.let { " [$it]" } ?: ""}" + else -> "✅ Skill $action done" + } + val duration = obj.get("duration_s")?.takeIf { !it.isJsonNull }?.asDouble + return ParsedToolData( + toolName = resolvedToolName, + args = args, + result = dataSource.entrySet().associate { it.key to it.value.toString() }, + summaryText = summaryText, + mainOutput = mainOutput, + durationSec = duration, + isRunning = isRunning, + ) + } + // ── Cronjob-specific formatting ── if (resolvedToolName == "cronjob") { val action = argsObj?.get("action")?.takeIf { !it.isJsonNull }?.asString ?: "" diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducer.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducer.kt index 3abb8b0f..ae802ecc 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducer.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducer.kt @@ -40,6 +40,7 @@ object ChatWsEventReducer { is WsEvent.ToolProgress -> event.sessionId is WsEvent.ToolGenerating -> event.sessionId is WsEvent.SubagentEvent -> event.sessionId + is WsEvent.ReviewSummary -> event.sessionId else -> null } if (eventSessionId != null && currentSessionId != null && eventSessionId != currentSessionId) { @@ -95,6 +96,8 @@ object ChatWsEventReducer { is WsEvent.ClarifyRequest -> onClarifyRequest(state, streamingState, event) + is WsEvent.ReviewSummary -> onReviewSummary(state, streamingState, event) + is WsEvent.RpcError -> onRpcError(state, streamingState, event) is WsEvent.GatewayError -> onGatewayError(state, streamingState, event) @@ -506,6 +509,25 @@ object ChatWsEventReducer { ), ) + // ── ReviewSummary (Self-improvement background review) ────────────── + + private fun onReviewSummary( + state: ChatUiState, + streamingState: StreamingState, + event: WsEvent.ReviewSummary, + ): ReducerResult { + if (event.text.isBlank()) return ReducerResult(state = state, streamingState = streamingState) + val systemMessage = + ChatMessage( + role = MessageRole.SYSTEM, + content = event.text, + ) + return ReducerResult( + state = state.copy(messages = state.messages + systemMessage), + streamingState = streamingState, + ) + } + // ── RpcError ────────────────────────────────────────────────────── private fun onRpcError( 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 10bb5f08..77c4a96a 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 @@ -49,6 +49,7 @@ 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 @@ -69,6 +70,7 @@ 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 @@ -288,6 +290,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) @@ -707,6 +712,122 @@ 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, 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 3579ac5c..377c7c3c 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,6 +12,7 @@ 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 @@ -40,6 +41,7 @@ data class SystemUiState( 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, @@ -100,6 +102,7 @@ class SystemViewModel : 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() } } @@ -112,6 +115,7 @@ class SystemViewModel : 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() @@ -126,6 +130,7 @@ class SystemViewModel : 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, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 71738f16..fc273fe0 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -325,6 +325,10 @@ 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. active paused disabled diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducerTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducerTest.kt index 8042004e..9aeb5ccc 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducerTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducerTest.kt @@ -375,4 +375,27 @@ class ChatWsEventReducerTest { assertEquals("Write tests", result.state.todos[0].content) assertTrue(result.state.todos[0].isCompleted) } + + @Test + fun testReviewSummary_addsSystemMessage() { + val state = ChatUiState(currentSessionId = "session-1") + val event = + WsEvent.ReviewSummary( + text = "💾 Self-improvement review: Skill 'android-ci' patched", + sessionId = "session-1", + ) + + val result = + ChatWsEventReducer.reduce( + state = state, + streamingState = StreamingState(), + event = event, + currentSessionId = "session-1", + ) + + assertEquals(1, result.state.messages.size) + val msg = result.state.messages.first() + assertEquals(MessageRole.SYSTEM, msg.role) + assertEquals("💾 Self-improvement review: Skill 'android-ci' patched", msg.content) + } } diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ToolBubbleParsingTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ToolBubbleParsingTest.kt index f7fdfc46..59c47222 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/ToolBubbleParsingTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ToolBubbleParsingTest.kt @@ -319,4 +319,33 @@ class ToolBubbleParsingTest { assertTrue(editDiff!!.contains("-val x = 1")) assertTrue(editDiff.contains("+val x = 2")) } + + @Test + fun testParseSkillManage_createAndPatch() { + val createJson = + """{ + "tool_id": "call_sm_1", + "name": "skill_manage", + "args": {"action": "patch", "name": "our-workflow"}, + "result": {"success": true, "message": "Patched skill our-workflow"} + }""" + val parsed = parseToolOutput(createJson, "skill_manage", false) + assertNotNull(parsed) + assertEquals("⚡ Skill Patched: our-workflow", parsed!!.summaryText) + assertEquals("✅ Patched skill our-workflow", parsed.mainOutput) + } + + @Test + fun testParseMemory_selfImprovementBadge() { + val memJson = + """{ + "tool_id": "call_mem_1", + "name": "memory", + "args": {"action": "add", "target": "user"}, + "result": {"success": true, "usage": "1,200 / 2,200 chars"} + }""" + val parsed = parseToolOutput(memJson, "memory", false) + assertNotNull(parsed) + assertEquals("🧠 Memory Saved (user)", parsed!!.summaryText) + } } 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 16369a97..21853671 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,4 +48,11 @@ 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) + } }