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 a9f21913..25c07a0b 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 @@ -105,6 +105,15 @@ object EventParser { WsEvent.SessionUpdated(payload) } + "approval.request" -> { + val command = payload?.get("command") as? String + val description = payload?.get("description") as? String + + @Suppress("UNCHECKED_CAST") + val patternKeys = (payload?.get("pattern_keys") as? List<*>)?.filterIsInstance() + WsEvent.ApprovalRequest(command, description, patternKeys, sessionId) + } + else -> { Log.w(TAG, "Unknown event type: $eventType") WsEvent.Unknown(rawJson) 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 487c7261..e6888de6 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 @@ -85,6 +85,15 @@ sealed class WsEvent { val error: JsonRpcError, ) : WsEvent() + // ── Approval request ──────────────────────────────────────────────── + + data class ApprovalRequest( + val command: String?, + val description: String?, + val patternKeys: List?, + val sessionId: String?, + ) : WsEvent() + // ── Fallback ───────────────────────────────────────────────────────── data class Unknown( 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 e2d9fb30..acc45302 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 @@ -26,17 +26,22 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Build +import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.ContentCopy import androidx.compose.material.icons.filled.Error +import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -95,6 +100,7 @@ fun ChatBubble( isDarkTheme: Boolean, searchQuery: String = "", isCurrentMatch: Boolean = false, + onRespondApproval: (String) -> Unit = {}, modifier: Modifier = Modifier, ) { val screenWidth = LocalConfiguration.current.screenWidthDp.dp @@ -125,7 +131,11 @@ fun ChatBubble( } MessageRole.SYSTEM -> { - SystemBubble(message, modifier) + SystemBubble( + message = message, + onRespondApproval = onRespondApproval, + modifier = modifier, + ) } MessageRole.TOOL -> { @@ -415,14 +425,15 @@ private fun AssistantBubble( @Composable private fun SystemBubble( message: ChatMessage, + onRespondApproval: (String) -> Unit = {}, modifier: Modifier = Modifier, ) { - Box( + Column( modifier = modifier .fillMaxWidth() .padding(horizontal = 16.dp, vertical = 4.dp), - contentAlignment = Alignment.Center, + horizontalAlignment = Alignment.CenterHorizontally, ) { Text( text = message.content, @@ -432,6 +443,54 @@ private fun SystemBubble( color = SystemMessageColor, ), ) + + // Approval action buttons + if (message.approvalInfo != null) { + Spacer(Modifier.height(8.dp)) + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + FilledTonalButton( + onClick = { onRespondApproval("approve") }, + modifier = + Modifier + .height(36.dp) + .testTag("approve_button"), + colors = + ButtonDefaults.filledTonalButtonColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + ), + ) { + Icon( + imageVector = Icons.Filled.Check, + contentDescription = null, + modifier = Modifier.size(16.dp), + ) + Spacer(Modifier.width(4.dp)) + Text("Approve") + } + + FilledTonalButton( + onClick = { onRespondApproval("deny") }, + modifier = + Modifier + .height(36.dp) + .testTag("deny_button"), + colors = + ButtonDefaults.filledTonalButtonColors( + containerColor = MaterialTheme.colorScheme.errorContainer, + ), + ) { + Icon( + imageVector = Icons.Filled.Close, + contentDescription = null, + modifier = Modifier.size(16.dp), + ) + Spacer(Modifier.width(4.dp)) + Text("Deny") + } + } + } } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatMessage.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatMessage.kt index b3b78155..8bc7d566 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatMessage.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatMessage.kt @@ -2,6 +2,17 @@ package com.m57.hermescontrol.ui.chat import java.util.UUID +/** + * Metadata for a [ChatMessage] that represents an approval request. + * When present, the UI renders Approve/Deny buttons inline. + * Transient — not persisted to SQLite. + */ +data class ApprovalInfo( + val command: String?, + val description: String?, + val patternKeys: List?, +) + data class ChatMessage( val id: String = UUID.randomUUID().toString(), val role: MessageRole, @@ -10,6 +21,7 @@ data class ChatMessage( val isStreaming: Boolean = false, val toolName: String? = null, val toolStatus: ToolStatus? = null, + val approvalInfo: ApprovalInfo? = null, ) enum class MessageRole { diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt index b2d63791..059a80c8 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt @@ -1119,6 +1119,7 @@ private fun ChatMessageList( isDarkTheme = isDark, searchQuery = if (isSearchActive) searchQuery else "", isCurrentMatch = isCurrentMatch, + onRespondApproval = viewModel::respondToApproval, ) } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt index fcf27358..c62ff99c 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt @@ -269,6 +269,10 @@ class ChatViewModel( } } + is WsEvent.ApprovalRequest -> { + handleApprovalRequest(event) + } + else -> { /* reducer handles these */ } } } @@ -437,6 +441,14 @@ class ChatViewModel( WsMethods.COMMAND_DISPATCH -> { handleDispatchResult(result) } + + WsMethods.APPROVAL_RESPOND -> { + val map = result as? Map<*, *> + val resolved = (map?.get("resolved") as? Number)?.toInt() ?: 0 + if (resolved > 0) { + addSystemMessage("✅ Approval submitted") + } + } } } @@ -947,6 +959,63 @@ class ChatViewModel( _uiState.update { it.copy(errorMessage = null) } } + // ── Approval flow ─────────────────────────────────────────────────── + + private fun handleApprovalRequest(event: WsEvent.ApprovalRequest) { + val description = event.description ?: event.command ?: "Unknown command" + val content = "⚠️ **Approval Required**\n$description" + val msg = + ChatMessage( + role = MessageRole.SYSTEM, + content = content, + approvalInfo = + ApprovalInfo( + command = event.command, + description = event.description, + patternKeys = event.patternKeys, + ), + ) + _uiState.update { state -> + state.copy( + messages = state.messages + msg, + isAgentTyping = false, + ) + } + } + + fun respondToApproval(action: String) { + val state = _uiState.value + val approvalMsg = state.messages.lastOrNull { it.approvalInfo != null } ?: return + val sessionId = state.currentSessionId ?: return + + // Clear buttons immediately + _uiState.update { s -> + s.copy( + messages = + s.messages.map { + if (it.id == approvalMsg.id) { + it.copy(approvalInfo = null) + } else { + it + } + }, + ) + } + + viewModelScope.launch(Dispatchers.IO) { + wsClient.send( + method = WsMethods.APPROVAL_RESPOND, + params = + mapOf( + "session_id" to sessionId, + "choice" to action, + "all" to false, + ), + onSent = { id -> trackRequest(id, WsMethods.APPROVAL_RESPOND) }, + ) + } + } + fun reconnect() { _uiState.update { it.copy( 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 c969379a..0d53482d 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 @@ -72,6 +72,9 @@ object ChatWsEventReducer { // RpcResult is handled by the ViewModel (needs pending request context) is WsEvent.RpcResult -> ReducerResult(state) + + // ApprovalRequest is handled by the ViewModel (needs active session + WS client) + is WsEvent.ApprovalRequest -> ReducerResult(state) } // ── GatewayReady ────────────────────────────────────────────────── diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt index 1202353f..2777c933 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt @@ -1042,4 +1042,124 @@ class ChatViewModelTest { // Verify upsert was called for the new message coVerify { mockDao.upsert(any()) } } + + // ── Approval flow ─────────────────────────────────────────────────── + + @Test + fun testApprovalRequest_addsSystemMessage() = + runTest { + val viewModel = ChatViewModel(app, startCleanup = false) + advanceUntilIdle() + + mockEventsFlow.emit( + WsEvent.ApprovalRequest( + command = "rm -rf /data", + description = "The agent wants to execute: rm -rf /data", + patternKeys = listOf("shell:rm"), + sessionId = null, + ), + ) + advanceUntilIdle() + + val state = viewModel.uiState.value + assertTrue(state.messages.any { it.content.contains("Approval Required") }) + val msg = state.messages.first { it.content.contains("Approval Required") } + assertNotNull(msg.approvalInfo) + assertEquals("rm -rf /data", msg.approvalInfo?.command) + } + + @Test + fun testRespondToApproval_sendsRpc() = + runTest { + val viewModel = ChatViewModel(app, startCleanup = false) + advanceUntilIdle() + + // Setup active session + var createReqId = "" + every { HermesWsClient.send(WsMethods.SESSION_CREATE, any(), any()) } answers { + createReqId = "create-req-approval-test" + val onSent = arg<((String) -> Unit)?>(2) + onSent?.invoke(createReqId) + createReqId + } + mockEventsFlow.emit(WsEvent.GatewayReady(null)) + advanceUntilIdle() + mockEventsFlow.emit(WsEvent.RpcResult(createReqId, mapOf("session_id" to "session-123"))) + advanceUntilIdle() + + // Send approval request event + mockEventsFlow.emit( + WsEvent.ApprovalRequest( + command = "rm", + description = "Dangerous command", + patternKeys = null, + sessionId = null, + ), + ) + advanceUntilIdle() + + // Call respondToApproval — should send approval.respond RPC + var approvalReqId = "" + every { HermesWsClient.send(WsMethods.APPROVAL_RESPOND, any(), any()) } answers { + approvalReqId = "approve-req-1" + val onSent = arg<((String) -> Unit)?>(2) + onSent?.invoke(approvalReqId) + approvalReqId + } + + viewModel.respondToApproval("approve") + advanceUntilIdle() + + verify { HermesWsClient.send(WsMethods.APPROVAL_RESPOND, any(), any()) } + } + + @Test + fun testRespondToApproval_clearsButtons() = + runTest { + val viewModel = ChatViewModel(app, startCleanup = false) + advanceUntilIdle() + + // Setup active session + var createReqId = "" + every { HermesWsClient.send(WsMethods.SESSION_CREATE, any(), any()) } answers { + createReqId = "create-req-clear-test" + val onSent = arg<((String) -> Unit)?>(2) + onSent?.invoke(createReqId) + createReqId + } + mockEventsFlow.emit(WsEvent.GatewayReady(null)) + advanceUntilIdle() + mockEventsFlow.emit(WsEvent.RpcResult(createReqId, mapOf("session_id" to "session-123"))) + advanceUntilIdle() + + // Send approval request + mockEventsFlow.emit( + WsEvent.ApprovalRequest( + command = "rm", + description = "Dangerous", + patternKeys = null, + sessionId = null, + ), + ) + advanceUntilIdle() + + val stateBefore = viewModel.uiState.value + val approvalMsg = stateBefore.messages.firstOrNull { it.approvalInfo != null } + assertNotNull(approvalMsg) + + // Respond + every { HermesWsClient.send(WsMethods.APPROVAL_RESPOND, any(), any()) } answers { + val onSent = arg<((String) -> Unit)?>(2) + onSent?.invoke("approve-req-clear") + "approve-req-clear" + } + viewModel.respondToApproval("approve") + advanceUntilIdle() + + // approvalInfo should be null now (buttons cleared) + val stateAfter = viewModel.uiState.value + val msgAfter = stateAfter.messages.firstOrNull { it.id == approvalMsg!!.id } + assertNotNull(msgAfter) + assertNull(msgAfter!!.approvalInfo) + } }