feat(#396): native approval UI for dangerous command, memory, and skill write requests (#397)

* feat(ws): add ApprovalRequest event type and parser

* feat(model): add ApprovalInfo field to ChatMessage

* feat(viewmodel): handle approval.request events and respondToApproval

* feat(ui): render Approve/Deny buttons for approval request messages

* test: add approval request event and respond RPC tests

* fix: add ApprovalRequest branch to ChatWsEventReducer exhaustive when

---------

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
M57
2026-06-26 15:53:11 +03:00
committed by GitHub
co-authored by M57
parent fafba3231b
commit cac15147f4
8 changed files with 285 additions and 3 deletions
@@ -105,6 +105,15 @@ object EventParser {
WsEvent.SessionUpdated(payload) 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<String>()
WsEvent.ApprovalRequest(command, description, patternKeys, sessionId)
}
else -> { else -> {
Log.w(TAG, "Unknown event type: $eventType") Log.w(TAG, "Unknown event type: $eventType")
WsEvent.Unknown(rawJson) WsEvent.Unknown(rawJson)
@@ -85,6 +85,15 @@ sealed class WsEvent {
val error: JsonRpcError, val error: JsonRpcError,
) : WsEvent() ) : WsEvent()
// ── Approval request ────────────────────────────────────────────────
data class ApprovalRequest(
val command: String?,
val description: String?,
val patternKeys: List<String>?,
val sessionId: String?,
) : WsEvent()
// ── Fallback ───────────────────────────────────────────────────────── // ── Fallback ─────────────────────────────────────────────────────────
data class Unknown( data class Unknown(
@@ -26,17 +26,22 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Build 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.CheckCircle
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.ContentCopy import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.Error import androidx.compose.material.icons.filled.Error
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@@ -95,6 +100,7 @@ fun ChatBubble(
isDarkTheme: Boolean, isDarkTheme: Boolean,
searchQuery: String = "", searchQuery: String = "",
isCurrentMatch: Boolean = false, isCurrentMatch: Boolean = false,
onRespondApproval: (String) -> Unit = {},
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val screenWidth = LocalConfiguration.current.screenWidthDp.dp val screenWidth = LocalConfiguration.current.screenWidthDp.dp
@@ -125,7 +131,11 @@ fun ChatBubble(
} }
MessageRole.SYSTEM -> { MessageRole.SYSTEM -> {
SystemBubble(message, modifier) SystemBubble(
message = message,
onRespondApproval = onRespondApproval,
modifier = modifier,
)
} }
MessageRole.TOOL -> { MessageRole.TOOL -> {
@@ -415,14 +425,15 @@ private fun AssistantBubble(
@Composable @Composable
private fun SystemBubble( private fun SystemBubble(
message: ChatMessage, message: ChatMessage,
onRespondApproval: (String) -> Unit = {},
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
Box( Column(
modifier = modifier =
modifier modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 4.dp), .padding(horizontal = 16.dp, vertical = 4.dp),
contentAlignment = Alignment.Center, horizontalAlignment = Alignment.CenterHorizontally,
) { ) {
Text( Text(
text = message.content, text = message.content,
@@ -432,6 +443,54 @@ private fun SystemBubble(
color = SystemMessageColor, 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")
}
}
}
} }
} }
@@ -2,6 +2,17 @@ package com.m57.hermescontrol.ui.chat
import java.util.UUID 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<String>?,
)
data class ChatMessage( data class ChatMessage(
val id: String = UUID.randomUUID().toString(), val id: String = UUID.randomUUID().toString(),
val role: MessageRole, val role: MessageRole,
@@ -10,6 +21,7 @@ data class ChatMessage(
val isStreaming: Boolean = false, val isStreaming: Boolean = false,
val toolName: String? = null, val toolName: String? = null,
val toolStatus: ToolStatus? = null, val toolStatus: ToolStatus? = null,
val approvalInfo: ApprovalInfo? = null,
) )
enum class MessageRole { enum class MessageRole {
@@ -1119,6 +1119,7 @@ private fun ChatMessageList(
isDarkTheme = isDark, isDarkTheme = isDark,
searchQuery = if (isSearchActive) searchQuery else "", searchQuery = if (isSearchActive) searchQuery else "",
isCurrentMatch = isCurrentMatch, isCurrentMatch = isCurrentMatch,
onRespondApproval = viewModel::respondToApproval,
) )
} }
} }
@@ -269,6 +269,10 @@ class ChatViewModel(
} }
} }
is WsEvent.ApprovalRequest -> {
handleApprovalRequest(event)
}
else -> { /* reducer handles these */ } else -> { /* reducer handles these */ }
} }
} }
@@ -437,6 +441,14 @@ class ChatViewModel(
WsMethods.COMMAND_DISPATCH -> { WsMethods.COMMAND_DISPATCH -> {
handleDispatchResult(result) 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) } _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() { fun reconnect() {
_uiState.update { _uiState.update {
it.copy( it.copy(
@@ -72,6 +72,9 @@ object ChatWsEventReducer {
// 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)
// ApprovalRequest is handled by the ViewModel (needs active session + WS client)
is WsEvent.ApprovalRequest -> ReducerResult(state)
} }
// ── GatewayReady ────────────────────────────────────────────────── // ── GatewayReady ──────────────────────────────────────────────────
@@ -1042,4 +1042,124 @@ class ChatViewModelTest {
// Verify upsert was called for the new message // Verify upsert was called for the new message
coVerify { mockDao.upsert(any()) } 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)
}
} }