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 518d81a1..4a19bd98 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 @@ -283,7 +283,40 @@ object EventParser { @Suppress("UNCHECKED_CAST") val patternKeys = (payload?.get("pattern_keys") as? List<*>)?.filterIsInstance() - WsEvent.ApprovalRequest(command, description, patternKeys, sessionId) + val requestId = payload?.get("request_id") as? String + + @Suppress("UNCHECKED_CAST") + val rawChoices = (payload?.get("choices") as? List<*>)?.filterIsInstance() + val allowPermanent = payload?.get("allow_permanent") as? Boolean + val allowSession = payload?.get("allow_session") as? Boolean + val smartDenied = payload?.get("smart_denied") as? Boolean + // Backend default (server.py `_approval_request_payload`): + // smart-denied → once/deny only; else once + session? + always? + deny. + val choices = + rawChoices ?: run { + if (smartDenied == true) { + listOf("once", "deny") + } else { + buildList { + add("once") + if (allowSession != false) { + add("session") + if (allowPermanent != false) add("always") + } + add("deny") + } + } + } + WsEvent.ApprovalRequest( + command, + description, + patternKeys, + sessionId, + requestId, + choices, + allowPermanent, + smartDenied, + ) } "sudo.request" -> { 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 3a63dabf..7f1cff0f 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 @@ -230,6 +230,30 @@ sealed class WsEvent { val description: String?, val patternKeys: List?, val sessionId: String?, + /** + * Backend `request_id` — unique across sessions. Sent back as + * `request_id` in `approval.respond` so the gateway resolves the + * EXACT pending instead of FIFO-oldest (desktop parity: + * `prompts.ts` `receiveApprovalRequest` + `approval.tsx` respond). + * Null on legacy payloads → omit, FIFO-compatible. + */ + val requestId: String? = null, + /** + * Backend-advertised choices (e.g. `["once","session","always","deny"]`, + * smart-denied → `["once","deny"]`). Null = legacy → UI falls back + * to Run/Deny. + */ + val choices: List? = null, + /** + * False when the backend won't honor a permanent allow (tirith + * warning) → hide "Always allow" (desktop `allowPermanent` parity). + */ + val allowPermanent: Boolean? = null, + /** + * True when this is an owner override of a Smart DENY — backend + * restricts to once/deny only (desktop `smartDenied` parity). + */ + val smartDenied: Boolean? = null, ) : WsEvent() // ── Sudo / secret requests ───────────────────────────────────────── diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/WsMethods.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/WsMethods.kt index dd00f6b5..c8e00fb4 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/WsMethods.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/WsMethods.kt @@ -34,6 +34,12 @@ object WsMethods { const val PROMPT_BTW = "prompt.btw" const val CLARIFY_RESPOND = "clarify.respond" const val APPROVAL_RESPOND = "approval.respond" + + /** Ack that the client rendered a pending approval (desktop parity). */ + const val APPROVAL_RECEIVED = "approval.received" + + /** List unresolved approvals for a session (reconnect replay). */ + const val APPROVAL_PENDING = "approval.pending" const val SUDO_RESPOND = "sudo.respond" const val SECRET_RESPOND = "secret.respond" diff --git a/app/src/main/java/com/m57/hermescontrol/notification/ChatNotificationService.kt b/app/src/main/java/com/m57/hermescontrol/notification/ChatNotificationService.kt index 7834ee69..0d3d1503 100644 --- a/app/src/main/java/com/m57/hermescontrol/notification/ChatNotificationService.kt +++ b/app/src/main/java/com/m57/hermescontrol/notification/ChatNotificationService.kt @@ -131,6 +131,14 @@ class ChatNotificationService : Service() { showReplyNotification(body, null) } + is WsEvent.ApprovalRequest -> { + val preview = + event.description?.takeIf { it.isNotBlank() } + ?: event.command?.takeIf { it.isNotBlank() } + ?: getString(R.string.notif_input_needed) + showReplyNotification(preview.take(100), null) + } + else -> {} } } 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 fe671c64..67c2552e 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 @@ -13,6 +13,8 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth @@ -31,6 +33,7 @@ import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.ContentCopy import androidx.compose.material.icons.filled.Download import androidx.compose.material.icons.filled.Psychology +import androidx.compose.material3.AlertDialog import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults @@ -41,6 +44,7 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect @@ -321,6 +325,7 @@ private fun SelfImprovementReviewCard( } } +@OptIn(ExperimentalLayoutApi::class) @Composable internal fun SystemBubble( message: ChatMessage, @@ -332,6 +337,9 @@ internal fun SystemBubble( return } + val approvalInfo = message.approvalInfo + var confirmAlways by remember(message.id) { mutableStateOf(false) } + Column( modifier = modifier @@ -348,52 +356,145 @@ internal fun SystemBubble( ), ) - // Approval action buttons - if (message.approvalInfo != null) { + // Approval action buttons — dynamic from backend `choices` + // (desktop `approval.tsx` parity: once/session/always/deny, + // smart-denied → once/deny only, allowPermanent=false hides Always). + if (approvalInfo != null) { Spacer(Modifier.height(8.dp)) - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), + val rawChoices = approvalInfo.choices ?: listOf("once", "deny") + val visibleChoices = + rawChoices.filter { choice -> + when (choice) { + "always" -> approvalInfo.allowPermanent != false + else -> true + } + } + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally), + verticalArrangement = 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") - } + for (choice in visibleChoices) { + when (choice) { + "deny" -> { + 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") + } + } - 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") + "session" -> { + FilledTonalButton( + onClick = { onRespondApproval("session") }, + modifier = + Modifier + .height(36.dp) + .testTag("session_button"), + colors = + ButtonDefaults.filledTonalButtonColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer, + ), + ) { + Icon( + imageVector = Icons.Filled.Check, + contentDescription = null, + modifier = Modifier.size(16.dp), + ) + Spacer(Modifier.width(4.dp)) + Text("Allow session") + } + } + + "always" -> { + FilledTonalButton( + onClick = { confirmAlways = true }, + modifier = + Modifier + .height(36.dp) + .testTag("always_button"), + colors = + ButtonDefaults.filledTonalButtonColors( + containerColor = MaterialTheme.colorScheme.tertiaryContainer, + ), + ) { + Icon( + imageVector = Icons.Filled.Check, + contentDescription = null, + modifier = Modifier.size(16.dp), + ) + Spacer(Modifier.width(4.dp)) + Text("Always allow") + } + } + + else -> { + // "once" (+ legacy "approve" → normalized to once in VM) + FilledTonalButton( + onClick = { onRespondApproval(choice) }, + 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(if (choice == "approve") "Approve" else "Run") + } + } + } } } + // "Always allow" persists the pattern permanently — confirm first + // (desktop confirm-modal parity). + if (confirmAlways) { + AlertDialog( + onDismissRequest = { confirmAlways = false }, + title = { Text("Always allow this command?") }, + text = { + Text( + "This persists the pattern permanently so future " + + "matching commands run without asking.", + ) + }, + confirmButton = { + TextButton( + onClick = { + confirmAlways = false + onRespondApproval("always") + }, + ) { + Text("Always allow") + } + }, + dismissButton = { + TextButton(onClick = { confirmAlways = false }) { + Text("Cancel") + } + }, + ) + } } } } 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 474a8340..55b49a1f 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 @@ -5,13 +5,21 @@ import java.util.UUID /** * Metadata for a [ChatMessage] that represents an approval request. - * When present, the UI renders Approve/Deny buttons inline. + * When present, the UI renders approval buttons inline. * Transient — not persisted to SQLite. */ data class ApprovalInfo( val command: String?, val description: String?, val patternKeys: List?, + /** Backend `request_id` — sent back in `approval.respond` to pin the exact pending. */ + val requestId: String? = null, + /** Backend-advertised choices (`once`/`session`/`always`/`deny`). Null = legacy Run/Deny. */ + val choices: List? = null, + /** False → hide "Always allow" (tirith warning, desktop parity). */ + val allowPermanent: Boolean? = null, + /** True → Smart DENY override, once/deny only. */ + val smartDenied: Boolean? = null, ) /** 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 a4775776..405d54f3 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 @@ -938,6 +938,18 @@ class ChatViewModel( // chip stays hidden until the real window lands. viewModelScope.launch { fetchContextUsage(skipRestFallback = true) } } + // Session.info can carry `pending_approval` (reconnect + // reconciliation) — surface it unless already on screen. + val pendingApproval = info["pending_approval"] as? Map<*, *> + if (pendingApproval != null) { + val rid = pendingApproval["request_id"] as? String + val alreadyShown = + rid != null && + _uiState.value.messages.any { it.approvalInfo?.requestId == rid } + if (!alreadyShown) { + handleApprovalRequest(parseApprovalMap(pendingApproval, _uiState.value.currentSessionId)) + } + } } } @@ -1219,6 +1231,20 @@ class ChatViewModel( val generation = request?.generation ?: sessionGeneration resumedGeneration = generation finishResumeWhenHydrated(generation) + // Reconnect replay: resume payload can carry `pending_approval` + // (server `_session_info_payload`); surface it, then ask for + // the full queue in case more are parked. + val pendingApproval = resultMap?.get("pending_approval") as? Map<*, *> + if (pendingApproval != null) { + val rid = pendingApproval["request_id"] as? String + val alreadyShown = + rid != null && + _uiState.value.messages.any { it.approvalInfo?.requestId == rid } + if (!alreadyShown) { + handleApprovalRequest(parseApprovalMap(pendingApproval, sessionId)) + } + } + if (sessionId != null) replayPendingApproval(sessionId) } WsMethods.SESSION_INTERRUPT -> { @@ -1258,6 +1284,10 @@ class ChatViewModel( } } + WsMethods.APPROVAL_PENDING -> { + handleApprovalPendingResult(result) + } + WsMethods.CONFIG_SET -> { val pending = pendingModelSwitchRequests.remove(id) val map = result as? Map<*, *> ?: return @@ -3603,6 +3633,10 @@ class ChatViewModel( command = event.command, description = event.description, patternKeys = event.patternKeys, + requestId = event.requestId, + choices = event.choices, + allowPermanent = event.allowPermanent, + smartDenied = event.smartDenied, ), ) _uiState.update { state -> @@ -3611,12 +3645,106 @@ class ChatViewModel( isAgentTyping = false, ) } + // Desktop parity (`prompts.ts` receiveApprovalRequest): ack the render + // so the backend knows this client holds the prompt. Fire-and-forget. + val requestId = event.requestId + val sessionId = event.sessionId ?: _uiState.value.currentSessionId + if (requestId != null && sessionId != null) { + viewModelScope.launch(ioDispatcher) { + runCatching { + wsClient.send( + method = WsMethods.APPROVAL_RECEIVED, + params = + mapOf( + "session_id" to sessionId, + "request_id" to requestId, + ), + ) + } + } + } + } + + /** + * Convert a backend approval map (snake_case, from `approval.pending` or + * `session.info` `pending_approval`) into a typed event. Mirrors + * [EventParser] defaults so replay and live paths agree. + */ + private fun parseApprovalMap( + map: Map<*, *>, + sessionId: String?, + ): WsEvent.ApprovalRequest { + @Suppress("UNCHECKED_CAST") + val patternKeys = (map["pattern_keys"] as? List<*>)?.filterIsInstance() + + @Suppress("UNCHECKED_CAST") + val rawChoices = (map["choices"] as? List<*>)?.filterIsInstance() + val allowPermanent = map["allow_permanent"] as? Boolean + val allowSession = map["allow_session"] as? Boolean + val smartDenied = map["smart_denied"] as? Boolean + val choices = + rawChoices ?: run { + if (smartDenied == true) { + listOf("once", "deny") + } else { + buildList { + add("once") + if (allowSession != false) { + add("session") + if (allowPermanent != false) add("always") + } + add("deny") + } + } + } + return WsEvent.ApprovalRequest( + command = map["command"] as? String, + description = map["description"] as? String, + patternKeys = patternKeys, + sessionId = sessionId, + requestId = map["request_id"] as? String, + choices = choices, + allowPermanent = allowPermanent, + smartDenied = smartDenied, + ) + } + + /** + * Reconnect replay (desktop `replayPendingApproval` parity): ask the + * gateway for unresolved approvals and surface the oldest one. Called + * after resume and after each respond (the queue can hold more). + */ + private fun replayPendingApproval(sessionId: String) { + viewModelScope.launch(ioDispatcher) { + wsClient.send( + method = WsMethods.APPROVAL_PENDING, + params = mapOf("session_id" to sessionId), + onSent = { id -> trackRequest(id, WsMethods.APPROVAL_PENDING) }, + ) + } + } + + private fun handleApprovalPendingResult(result: Any?) { + @Suppress("UNCHECKED_CAST") + val approvals = (result as? Map<*, *>)?.get("approvals") as? List<*> + val first = approvals?.filterIsInstance>()?.firstOrNull() ?: return + val requestId = first["request_id"] as? String ?: return + val sessionId = _uiState.value.currentSessionId + // Don't duplicate a prompt already on screen. + val alreadyShown = + _uiState.value.messages.any { it.approvalInfo?.requestId == requestId } + if (alreadyShown) return + handleApprovalRequest(parseApprovalMap(first, sessionId)) } fun respondToApproval(action: String) { val state = _uiState.value val approvalMsg = state.messages.lastOrNull { it.approvalInfo != null } ?: return val sessionId = state.currentSessionId ?: return + // Desktop sends `once` for a single run; legacy mobile sent `approve` + // (any non-deny still unblocks, but stay on-spec going forward). + val choice = if (action == "approve") "once" else action + val requestId = approvalMsg.approvalInfo?.requestId // Clear buttons immediately _uiState.update { s -> @@ -3633,16 +3761,20 @@ class ChatViewModel( } viewModelScope.launch(ioDispatcher) { + val params = + mutableMapOf( + "session_id" to sessionId, + "choice" to choice, + "all" to false, + ) + if (requestId != null) params["request_id"] = requestId wsClient.send( method = WsMethods.APPROVAL_RESPOND, - params = - mapOf( - "session_id" to sessionId, - "choice" to action, - "all" to false, - ), + params = params, onSent = { id -> trackRequest(id, WsMethods.APPROVAL_RESPOND) }, ) + // The queue can hold more pendings — surface the next one. + replayPendingApproval(sessionId) } } diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/EventParserTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/EventParserTest.kt index db180e13..ebf22b3a 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/ws/EventParserTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/EventParserTest.kt @@ -400,6 +400,114 @@ class EventParserTest { assertEquals("req-expire-1", expireEvent.clarifyId) } + // ── Approval requests (full support) ─────────────────────────────── + + @Test + fun testParseApprovalRequest_withChoicesAndRequestId() { + val response = + createJsonRpcResponse( + jsonrpc = "2.0", + id = null, + result = null, + error = null, + method = "event", + params = + mapOf( + "type" to "approval.request", + "payload" to + mapOf( + "command" to "rm -rf /tmp/x", + "description" to "dangerous command", + "request_id" to "req-1", + "choices" to listOf("once", "session", "always", "deny"), + "allow_permanent" to true, + "pattern_keys" to listOf("shell:rm"), + ), + ), + ) + val event = EventParser.parse(response) + assertTrue(event is WsEvent.ApprovalRequest) + val approval = event as WsEvent.ApprovalRequest + assertEquals("rm -rf /tmp/x", approval.command) + assertEquals("req-1", approval.requestId) + assertEquals(listOf("once", "session", "always", "deny"), approval.choices) + assertEquals(true, approval.allowPermanent) + assertEquals(listOf("shell:rm"), approval.patternKeys) + } + + @Test + fun testParseApprovalRequest_smartDeniedDefaultsToOnceDeny() { + val response = + createJsonRpcResponse( + jsonrpc = "2.0", + id = null, + result = null, + error = null, + method = "event", + params = + mapOf( + "type" to "approval.request", + "payload" to + mapOf( + "command" to "rm -rf /", + "smart_denied" to true, + ), + ), + ) + val event = EventParser.parse(response) + assertTrue(event is WsEvent.ApprovalRequest) + val approval = event as WsEvent.ApprovalRequest + assertEquals(listOf("once", "deny"), approval.choices) + assertEquals(true, approval.smartDenied) + } + + @Test + fun testParseApprovalRequest_legacyDefaultsToFullChoices() { + val response = + createJsonRpcResponse( + jsonrpc = "2.0", + id = null, + result = null, + error = null, + method = "event", + params = + mapOf( + "type" to "approval.request", + "payload" to mapOf("command" to "ls"), + ), + ) + val event = EventParser.parse(response) + assertTrue(event is WsEvent.ApprovalRequest) + val approval = event as WsEvent.ApprovalRequest + assertEquals(listOf("once", "session", "always", "deny"), approval.choices) + } + + @Test + fun testParseApprovalRequest_allowPermanentFalseStillParses() { + val response = + createJsonRpcResponse( + jsonrpc = "2.0", + id = null, + result = null, + error = null, + method = "event", + params = + mapOf( + "type" to "approval.request", + "payload" to + mapOf( + "command" to "rm", + "choices" to listOf("once", "session", "always", "deny"), + "allow_permanent" to false, + ), + ), + ) + val event = EventParser.parse(response) + assertTrue(event is WsEvent.ApprovalRequest) + val approval = event as WsEvent.ApprovalRequest + assertEquals(false, approval.allowPermanent) + } + // ── TEST-07: Untested subtypes ───────────────────────────────────── @Test 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 39030a6a..cf10e240 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 @@ -3035,6 +3035,167 @@ class ChatViewModelTest { assertNull(msgAfter!!.approvalInfo) } + @Test + fun testApprovalRequest_storesRequestIdAndChoices() = + runTest { + val viewModel = createViewModel() + advanceUntilIdle() + + mockEventsFlow.emit( + WsEvent.ApprovalRequest( + command = "rm -rf /tmp/x", + description = "dangerous command", + patternKeys = listOf("shell:rm"), + sessionId = null, + requestId = "req-1", + choices = listOf("once", "session", "always", "deny"), + allowPermanent = true, + smartDenied = false, + ), + ) + advanceUntilIdle() + + val msg = + viewModel.uiState.value.messages + .first { it.content.contains("Approval Required") } + assertEquals("req-1", msg.approvalInfo?.requestId) + assertEquals( + listOf("once", "session", "always", "deny"), + msg.approvalInfo?.choices, + ) + assertEquals(true, msg.approvalInfo?.allowPermanent) + } + + @Test + fun testRespondToApproval_mapsApproveToOnceAndSendsRequestId() = + runTest { + val (viewModel, sessionId) = createViewModelWithSession() + advanceUntilIdle() + + mockEventsFlow.emit( + WsEvent.ApprovalRequest( + command = "rm", + description = "Dangerous", + patternKeys = null, + sessionId = null, + requestId = "req-42", + choices = listOf("once", "deny"), + ), + ) + advanceUntilIdle() + + viewModel.respondToApproval("approve") + advanceUntilIdle() + + verify { + HermesWsClient.send( + WsMethods.APPROVAL_RESPOND, + withArg { params -> + assertEquals(sessionId, params["session_id"]) + assertEquals("once", params["choice"]) + assertEquals("req-42", params["request_id"]) + }, + any(), + ) + } + } + + @Test + fun testRespondToApproval_sessionChoice() = + runTest { + val (viewModel, sessionId) = createViewModelWithSession() + advanceUntilIdle() + + mockEventsFlow.emit( + WsEvent.ApprovalRequest( + command = "rm", + description = "Dangerous", + patternKeys = null, + sessionId = null, + requestId = "req-7", + choices = listOf("once", "session", "always", "deny"), + ), + ) + advanceUntilIdle() + + viewModel.respondToApproval("session") + advanceUntilIdle() + + verify { + HermesWsClient.send( + WsMethods.APPROVAL_RESPOND, + withArg { params -> + assertEquals("session", params["choice"]) + assertEquals("req-7", params["request_id"]) + }, + any(), + ) + } + } + + @Test + fun testRespondToApproval_omitsRequestIdWhenAbsent() = + runTest { + val (viewModel, sessionId) = createViewModelWithSession() + advanceUntilIdle() + + mockEventsFlow.emit( + WsEvent.ApprovalRequest( + command = "ls", + description = "Safe", + patternKeys = null, + sessionId = null, + ), + ) + advanceUntilIdle() + + viewModel.respondToApproval("deny") + advanceUntilIdle() + + verify { + HermesWsClient.send( + WsMethods.APPROVAL_RESPOND, + withArg { params -> + assertEquals("deny", params["choice"]) + assertFalse(params.containsKey("request_id")) + }, + any(), + ) + } + } + + @Test + fun testRespondToApproval_triggersPendingReplay() = + runTest { + val (viewModel, sessionId) = createViewModelWithSession() + advanceUntilIdle() + + mockEventsFlow.emit( + WsEvent.ApprovalRequest( + command = "rm", + description = "Dangerous", + patternKeys = null, + sessionId = null, + requestId = "req-9", + choices = listOf("once", "deny"), + ), + ) + advanceUntilIdle() + + viewModel.respondToApproval("once") + advanceUntilIdle() + + verify { + HermesWsClient.send( + WsMethods.APPROVAL_PENDING, + withArg { params -> + assertEquals(sessionId, params["session_id"]) + }, + any(), + ) + } + } + // ── Sudo / secret prompt flow (issue #524) ─────────────────────────── @Test