diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatApprovalsDelegate.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatApprovalsDelegate.kt new file mode 100644 index 00000000..99c3094c --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatApprovalsDelegate.kt @@ -0,0 +1,192 @@ +package com.m57.hermescontrol.ui.chat + +import com.m57.hermescontrol.data.ws.WsEvent +import com.m57.hermescontrol.data.ws.WsMethods +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +/** + * Owns approval rendering, deduplication, user response submission, and + * reconnect/resume pending approval replay. + * + * Extracted behavior-preservingly from [ChatViewModel]. + */ +class ChatApprovalsDelegate( + private val scope: CoroutineScope, + private val ioDispatcher: CoroutineDispatcher, + private val uiState: MutableStateFlow, + private val runtimeSessionId: () -> String?, + private val wsSend: (method: String, params: Map, onSent: ((String) -> Unit)?) -> Unit, + private val trackRequest: (id: String, method: String) -> Unit, + private val addSystemMessage: (text: String) -> Unit, +) { + 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, + requestId = event.requestId, + choices = event.choices, + allowPermanent = event.allowPermanent, + smartDenied = event.smartDenied, + ), + ) + uiState.update { state -> + state.copy( + messages = state.messages + msg, + 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 = runtimeSessionId() ?: event.sessionId ?: uiState.value.currentSessionId + if (requestId != null && sessionId != null) { + scope.launch(ioDispatcher) { + runCatching { + wsSend( + WsMethods.APPROVAL_RECEIVED, + mapOf( + "session_id" to sessionId, + "request_id" to requestId, + ), + null, + ) + } + } + } + } + + fun replayPendingApproval(sessionId: String) { + val targetSessionId = runtimeSessionId() ?: sessionId + scope.launch(ioDispatcher) { + wsSend( + WsMethods.APPROVAL_PENDING, + mapOf("session_id" to targetSessionId), + ) { id -> trackRequest(id, WsMethods.APPROVAL_PENDING) } + } + } + + fun handleApprovalPendingResult(result: Any?) { + @Suppress("UNCHECKED_CAST") + val approvals = (result as? Map<*, *>)?.get("approvals") as? List<*> + val first = approvals?.filterIsInstance>()?.firstOrNull() ?: return + val sessionId = runtimeSessionId() ?: uiState.value.currentSessionId + maybeSurfacePendingApproval(first, sessionId) + } + + fun handleApprovalRespondResult(result: Any?) { + val map = result as? Map<*, *> + val resolved = (map?.get("resolved") as? Number)?.toInt() ?: 0 + if (resolved > 0) { + addSystemMessage("Approval submitted") + } + } + + fun maybeSurfacePendingApproval( + map: Map<*, *>, + sessionId: String?, + ) { + val requestId = map["request_id"] as? String + val alreadyShown = + requestId != null && + uiState.value.messages.any { it.approvalInfo?.requestId == requestId } + if (!alreadyShown) { + handleApprovalRequest(parseApprovalMap(map, sessionId)) + } + } + + fun respondToApproval(action: String) { + val state = uiState.value + val approvalMsg = state.messages.lastOrNull { it.approvalInfo != null } ?: return + val sessionId = runtimeSessionId() ?: 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 -> + s.copy( + messages = + s.messages.map { + if (it.id == approvalMsg.id) { + it.copy(approvalInfo = null) + } else { + it + } + }, + ) + } + + scope.launch(ioDispatcher) { + val params = + mutableMapOf( + "session_id" to sessionId, + "choice" to choice, + "all" to false, + ) + if (requestId != null) params["request_id"] = requestId + wsSend( + WsMethods.APPROVAL_RESPOND, + params, + ) { id -> trackRequest(id, WsMethods.APPROVAL_RESPOND) } + // The queue can hold more pendings — surface the next one. + replayPendingApproval(sessionId) + } + } +} + +/** + * 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. + */ +internal 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, + ) +} diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatApprovalsDelegateTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatApprovalsDelegateTest.kt new file mode 100644 index 00000000..4f3d298d --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatApprovalsDelegateTest.kt @@ -0,0 +1,169 @@ +package com.m57.hermescontrol.ui.chat + +import com.m57.hermescontrol.data.ws.WsEvent +import com.m57.hermescontrol.data.ws.WsMethods +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class ChatApprovalsDelegateTest { + private val testDispatcher = StandardTestDispatcher() + private val testScope = TestScope(testDispatcher) + private val uiState = MutableStateFlow(ChatUiState(currentSessionId = "storage-session-1")) + private var runtimeId: String? = null + private val sentMethods = mutableListOf() + private val sentParams = mutableListOf>() + private val trackedRequests = mutableListOf>() + private val systemMessages = mutableListOf() + + private val delegate = + ChatApprovalsDelegate( + scope = testScope, + ioDispatcher = testDispatcher, + uiState = uiState, + runtimeSessionId = { runtimeId }, + wsSend = { method, params, onSent -> + sentMethods.add(method) + sentParams.add(params) + onSent?.invoke("req-id-${sentMethods.size}") + }, + trackRequest = { id, method -> trackedRequests.add(id to method) }, + addSystemMessage = { text -> systemMessages.add(text) }, + ) + + @Test + fun parseApprovalMap_honorsSmartDeniedAndChoicePermutations() { + val denied = + parseApprovalMap( + mapOf("smart_denied" to true), + sessionId = "s1", + ) + assertEquals(listOf("once", "deny"), denied.choices) + + val full = + parseApprovalMap( + mapOf("allow_session" to true, "allow_permanent" to true), + sessionId = "s1", + ) + assertEquals(listOf("once", "session", "always", "deny"), full.choices) + + val noAlways = + parseApprovalMap( + mapOf("allow_session" to true, "allow_permanent" to false), + sessionId = "s1", + ) + assertEquals(listOf("once", "session", "deny"), noAlways.choices) + + val explicit = + parseApprovalMap( + mapOf("choices" to listOf("custom", "deny")), + sessionId = "s1", + ) + assertEquals(listOf("custom", "deny"), explicit.choices) + } + + @Test + fun handleApprovalRequest_appendsSystemMessageAndSendsAckWithPreferredRuntimeId() = + testScope.runTest { + runtimeId = "runtime-session-xyz" + val event = + WsEvent.ApprovalRequest( + command = "git status", + description = "inspect working tree", + patternKeys = null, + sessionId = "event-session", + requestId = "req-123", + ) + + delegate.handleApprovalRequest(event) + advanceUntilIdle() + + val lastMsg = uiState.value.messages.last() + assertEquals(MessageRole.SYSTEM, lastMsg.role) + assertEquals("**Approval Required**\ninspect working tree", lastMsg.content) + assertEquals("req-123", lastMsg.approvalInfo?.requestId) + assertFalse(uiState.value.isAgentTyping) + + assertEquals(listOf(WsMethods.APPROVAL_RECEIVED), sentMethods) + assertEquals("runtime-session-xyz", sentParams.first()["session_id"]) + assertEquals("req-123", sentParams.first()["request_id"]) + } + + @Test + fun respondToApproval_clearsButtonsImmediatelyAndSendsChoiceOnce() = + testScope.runTest { + runtimeId = "runtime-session-xyz" + delegate.handleApprovalRequest( + WsEvent.ApprovalRequest( + command = "ls", + description = null, + patternKeys = null, + sessionId = null, + requestId = "req-999", + ), + ) + advanceUntilIdle() + sentMethods.clear() + sentParams.clear() + + delegate.respondToApproval("approve") + val messageBeforeIdle = uiState.value.messages.last() + assertNull(messageBeforeIdle.approvalInfo) + + advanceUntilIdle() + assertEquals(listOf(WsMethods.APPROVAL_RESPOND, WsMethods.APPROVAL_PENDING), sentMethods) + assertEquals("once", sentParams[0]["choice"]) + assertEquals("req-999", sentParams[0]["request_id"]) + assertEquals(false, sentParams[0]["all"]) + assertEquals("runtime-session-xyz", sentParams[0]["session_id"]) + assertEquals("runtime-session-xyz", sentParams[1]["session_id"]) + } + + @Test + fun maybeSurfacePendingApproval_dedupesExistingOnScreenRequestId() { + delegate.handleApprovalRequest( + WsEvent.ApprovalRequest( + command = "ls", + description = null, + patternKeys = null, + sessionId = null, + requestId = "dup-1", + ), + ) + val initialCount = uiState.value.messages.size + + delegate.maybeSurfacePendingApproval( + mapOf("command" to "ls", "request_id" to "dup-1"), + sessionId = "s1", + ) + assertEquals(initialCount, uiState.value.messages.size) + + delegate.maybeSurfacePendingApproval( + mapOf("command" to "pwd", "request_id" to "new-2"), + sessionId = "s1", + ) + assertEquals(initialCount + 1, uiState.value.messages.size) + assertEquals( + "new-2", + uiState.value.messages + .last() + .approvalInfo + ?.requestId, + ) + } + + @Test + fun handleApprovalRespondResult_appendsDesktopParitySystemMessage() { + delegate.handleApprovalRespondResult(mapOf("resolved" to 1)) + assertEquals(listOf("Approval submitted"), systemMessages) + } +}