diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatCredentialPromptsDelegate.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatCredentialPromptsDelegate.kt new file mode 100644 index 00000000..8b47c45d --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatCredentialPromptsDelegate.kt @@ -0,0 +1,180 @@ +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 sudo and secret prompt state, timeouts, dismissal, and response RPCs. + * + * Extracted behavior-preservingly from [ChatViewModel] (issue #524). + */ +class ChatCredentialPromptsDelegate( + private val scope: CoroutineScope, + private val ioDispatcher: CoroutineDispatcher, + private val uiState: MutableStateFlow, + private val wsSend: (method: String, params: Map, onSent: ((String) -> Unit)?) -> Unit, + private val trackRequest: (id: String, method: String) -> Unit, +) { + /** + * The agent needs the user's sudo password. Surface a secure dialog and + * reply via sudo.respond. + */ + fun handleSudoRequest(event: WsEvent.SudoRequest) { + uiState.update { + it.copy( + sudoPrompt = SudoPromptUi(event.requestId, event.sessionId), + isAgentTyping = false, + ) + } + } + + /** + * Backend sudo timeout (120s) — clear only the matching dialog so a late + * expire for an old prompt never kills the current one. + */ + fun handleSudoExpire(event: WsEvent.SudoExpire) { + uiState.update { state -> + val current = state.sudoPrompt ?: return@update state + if (event.requestId != null && current.requestId != null && + event.requestId != current.requestId + ) { + return@update state + } + state.copy(sudoPrompt = null) + } + } + + /** + * The agent needs a secret value (token/password). Surface a secure dialog + * and reply via secret.respond. + */ + fun handleSecretRequest(event: WsEvent.SecretRequest) { + uiState.update { + it.copy( + secretPrompt = + SecretPromptUi( + event.requestId, + event.sessionId, + event.envVar, + event.prompt, + ), + isAgentTyping = false, + ) + } + } + + /** + * Backend secret timeout — match-only clear like [handleSudoExpire]. + */ + fun handleSecretExpire(event: WsEvent.SecretExpire) { + uiState.update { state -> + val current = state.secretPrompt ?: return@update state + if (event.requestId != null && current.requestId != null && + event.requestId != current.requestId + ) { + return@update state + } + state.copy(secretPrompt = null) + } + } + + /** + * Cancel → send empty password (desktop parity). Backend treats empty sudo as + * failed sudo (no command runs), unblocking the turn instantly. + */ + fun dismissSudo() { + val prompt = uiState.value.sudoPrompt ?: return + val sessionId = prompt.sessionId ?: uiState.value.currentSessionId + uiState.update { it.copy(sudoPrompt = null) } + if (sessionId == null) return + scope.launch(ioDispatcher) { + val params = + mutableMapOf( + "session_id" to sessionId, + "password" to "", + ) + prompt.requestId?.let { id -> params["request_id"] = id } + wsSend( + WsMethods.SUDO_RESPOND, + params, + ) { id -> trackRequest(id, WsMethods.SUDO_RESPOND) } + } + } + + /** + * Cancel → send empty value (desktop parity). Backend secret_cb returns + * skipped=True on empty, unblocking the turn instantly. + */ + fun dismissSecret() { + val prompt = uiState.value.secretPrompt ?: return + val sessionId = prompt.sessionId ?: uiState.value.currentSessionId + uiState.update { it.copy(secretPrompt = null) } + if (sessionId == null) return + scope.launch(ioDispatcher) { + val params = + mutableMapOf( + "session_id" to sessionId, + "value" to "", + ) + prompt.requestId?.let { id -> params["request_id"] = id } + wsSend( + WsMethods.SECRET_RESPOND, + params, + ) { id -> trackRequest(id, WsMethods.SECRET_RESPOND) } + } + } + + /** + * Send the user's sudo password back to the gateway. Clear prompt immediately, + * then fire the RPC. + */ + fun respondToSudo(password: String) { + val prompt = uiState.value.sudoPrompt ?: return + val sessionId = prompt.sessionId ?: uiState.value.currentSessionId ?: return + if (password.isBlank()) return + + uiState.update { it.copy(sudoPrompt = null) } + + scope.launch(ioDispatcher) { + val params = + mutableMapOf( + "session_id" to sessionId, + "password" to password, + ) + prompt.requestId?.let { id -> params["request_id"] = id } + wsSend( + WsMethods.SUDO_RESPOND, + params, + ) { id -> trackRequest(id, WsMethods.SUDO_RESPOND) } + } + } + + /** + * Send the user's secret value back to the gateway. + */ + fun respondToSecret(value: String) { + val prompt = uiState.value.secretPrompt ?: return + val sessionId = prompt.sessionId ?: uiState.value.currentSessionId ?: return + if (value.isBlank()) return + + uiState.update { it.copy(secretPrompt = null) } + + scope.launch(ioDispatcher) { + val params = + mutableMapOf( + "session_id" to sessionId, + "value" to value, + ) + prompt.requestId?.let { id -> params["request_id"] = id } + wsSend( + WsMethods.SECRET_RESPOND, + params, + ) { id -> trackRequest(id, WsMethods.SECRET_RESPOND) } + } + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatCredentialPromptsDelegateTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatCredentialPromptsDelegateTest.kt new file mode 100644 index 00000000..c888cdd1 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatCredentialPromptsDelegateTest.kt @@ -0,0 +1,87 @@ +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.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class ChatCredentialPromptsDelegateTest { + private val testDispatcher = StandardTestDispatcher() + private val testScope = TestScope(testDispatcher) + private val uiState = MutableStateFlow(ChatUiState(currentSessionId = "sess-1")) + private val sentMethods = mutableListOf() + private val sentParams = mutableListOf>() + private val trackedRequests = mutableListOf>() + + private val delegate = + ChatCredentialPromptsDelegate( + scope = testScope, + ioDispatcher = testDispatcher, + uiState = uiState, + wsSend = { method, params, onSent -> + sentMethods.add(method) + sentParams.add(params) + onSent?.invoke("req-${sentMethods.size}") + }, + trackRequest = { id, method -> trackedRequests.add(id to method) }, + ) + + @Test + fun handleSudoRequest_andRespond_clearsPromptAndSendsRpc() = + testScope.runTest { + delegate.handleSudoRequest(WsEvent.SudoRequest(requestId = "r-sudo", sessionId = "s-1")) + assertEquals("r-sudo", uiState.value.sudoPrompt?.requestId) + assertFalse(uiState.value.isAgentTyping) + + delegate.respondToSudo("secret-pass") + assertNull(uiState.value.sudoPrompt) + + advanceUntilIdle() + assertEquals(listOf(WsMethods.SUDO_RESPOND), sentMethods) + assertEquals("secret-pass", sentParams.first()["password"]) + assertEquals("r-sudo", sentParams.first()["request_id"]) + assertEquals("s-1", sentParams.first()["session_id"]) + } + + @Test + fun handleSecretRequest_andDismiss_sendsEmptyValue() = + testScope.runTest { + delegate.handleSecretRequest( + WsEvent.SecretRequest( + requestId = "r-sec", + sessionId = "s-1", + envVar = "OPENAI_API_KEY", + prompt = "Enter key", + ), + ) + assertEquals("OPENAI_API_KEY", uiState.value.secretPrompt?.envVar) + + delegate.dismissSecret() + assertNull(uiState.value.secretPrompt) + + advanceUntilIdle() + assertEquals(listOf(WsMethods.SECRET_RESPOND), sentMethods) + assertEquals("", sentParams.first()["value"]) + assertEquals("r-sec", sentParams.first()["request_id"]) + } + + @Test + fun expireHandlers_ignoreMismatchedRequestId() { + delegate.handleSudoRequest(WsEvent.SudoRequest(requestId = "keep-me", sessionId = "s-1")) + delegate.handleSudoExpire(WsEvent.SudoExpire(requestId = "other-req", sessionId = "s-1")) + assertNotNull(uiState.value.sudoPrompt) + + delegate.handleSudoExpire(WsEvent.SudoExpire(requestId = "keep-me", sessionId = "s-1")) + assertNull(uiState.value.sudoPrompt) + } +}