* 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:
@@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user