* 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:
1 parent
fafba3231b
commit
cac15147f4
8 files changed
+285
-3
No files matched your search
@@ -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<String>()
|
||||
WsEvent.ApprovalRequest(command, description, patternKeys, sessionId)
|
||||
}
|
||||
|
||||
else -> {
|
||||
Log.w(TAG, "Unknown event type: $eventType")
|
||||
WsEvent.Unknown(rawJson)
|
||||
|
||||
@@ -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<String>?,
|
||||
val sessionId: String?,
|
||||
) : WsEvent()
|
||||
|
||||
// ── Fallback ─────────────────────────────────────────────────────────
|
||||
|
||||
data class Unknown(
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<String>?,
|
||||
)
|
||||
|
||||
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 {
|
||||
|
||||
@@ -1119,6 +1119,7 @@ private fun ChatMessageList(
|
||||
isDarkTheme = isDark,
|
||||
searchQuery = if (isSearchActive) searchQuery else "",
|
||||
isCurrentMatch = isCurrentMatch,
|
||||
onRespondApproval = viewModel::respondToApproval,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 ──────────────────────────────────────────────────
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user