From 077ad1fa81d68f9bd7e3499b99c4635774ccaa80 Mon Sep 17 00:00:00 2001 From: M57 <41645758+Hy4ri@users.noreply.github.com> Date: Thu, 3 Sep 2026 22:08:07 +0300 Subject: [PATCH] feat(chat): full sudo.request support with expire, cancel-unblock, secret fields (#1056) --- .../m57/hermescontrol/data/ws/EventParser.kt | 14 +- .../com/m57/hermescontrol/data/ws/WsEvent.kt | 23 ++++ .../notification/ChatNotificationService.kt | 12 ++ .../hermescontrol/ui/chat/ChatViewModel.kt | 97 +++++++++++++- .../ui/chat/ChatWsEventReducer.kt | 4 + .../ui/chat/components/ChatDialogs.kt | 11 +- .../chat/components/ChatLifecycleEffects.kt | 2 + app/src/main/res/values/strings.xml | 1 + .../hermescontrol/data/ws/EventParserTest.kt | 67 ++++++++++ .../ui/chat/ChatViewModelTest.kt | 124 ++++++++++++++++++ 10 files changed, 348 insertions(+), 7 deletions(-) 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 3ae39754..518d81a1 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 @@ -291,9 +291,21 @@ object EventParser { WsEvent.SudoRequest(requestId, sessionId) } + "sudo.expire" -> { + val requestId = payload?.get("request_id") as? String + WsEvent.SudoExpire(requestId, sessionId) + } + "secret.request" -> { val requestId = payload?.get("request_id") as? String - WsEvent.SecretRequest(requestId, sessionId) + val envVar = payload?.get("env_var") as? String + val prompt = payload?.get("prompt") as? String + WsEvent.SecretRequest(requestId, sessionId, envVar, prompt) + } + + "secret.expire" -> { + val requestId = payload?.get("request_id") as? String + WsEvent.SecretExpire(requestId, sessionId) } else -> { 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 5db47c98..3a63dabf 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 @@ -244,14 +244,37 @@ sealed class WsEvent { val sessionId: String?, ) : WsEvent() + /** + * Backend sudo timeout (120s `_block`) — clears the matching dialog. + * Payload: `{ request_id }`. Only clears when ids match so a late + * expire for an old prompt never kills the current one (desktop parity). + */ + data class SudoExpire( + val requestId: String?, + val sessionId: String?, + ) : WsEvent() + /** * Backend needs a secret value (password / token) to continue a turn * (desktop: `secret.request` → `secret.respond {request_id, value}`). * Mobile previously dropped this and the agent hung forever. + * `envVar`/`prompt` mirror desktop: title = envVar ?: secretTitle, + * body = prompt ?: secretDesc. */ data class SecretRequest( val requestId: String?, val sessionId: String?, + val envVar: String? = null, + val prompt: String? = null, + ) : WsEvent() + + /** + * Backend secret timeout — clears the matching dialog. + * Payload: `{ request_id }`. Match-only clear like [SudoExpire]. + */ + data class SecretExpire( + val requestId: String?, + val sessionId: String?, ) : WsEvent() // ── Gateway-level errors ─────────────────────────────────────────── 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 4e171590..7834ee69 100644 --- a/app/src/main/java/com/m57/hermescontrol/notification/ChatNotificationService.kt +++ b/app/src/main/java/com/m57/hermescontrol/notification/ChatNotificationService.kt @@ -119,6 +119,18 @@ class ChatNotificationService : Service() { showReplyNotification(getString(R.string.notif_clarification_needed), null) } + is WsEvent.SudoRequest -> { + showReplyNotification(getString(R.string.notif_input_needed), null) + } + + is WsEvent.SecretRequest -> { + val body = + event.prompt?.takeIf { it.isNotBlank() } + ?: event.envVar?.takeIf { it.isNotBlank() } + ?: getString(R.string.notif_input_needed) + showReplyNotification(body, null) + } + else -> {} } } 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 a78a2825..a4775776 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 @@ -444,6 +444,8 @@ data class SudoPromptUi( data class SecretPromptUi( val requestId: String?, val sessionId: String?, + val envVar: String? = null, + val prompt: String? = null, ) /** @@ -1007,10 +1009,18 @@ class ChatViewModel( handleSudoRequest(event) } + is WsEvent.SudoExpire -> { + handleSudoExpire(event) + } + is WsEvent.SecretRequest -> { handleSecretRequest(event) } + is WsEvent.SecretExpire -> { + handleSecretExpire(event) + } + is WsEvent.GatewayError -> { // Reducer already set errorMessage; no extra VM work needed. } @@ -3651,6 +3661,23 @@ class ChatViewModel( } } + /** + * Backend sudo timeout (120s) — clear only the matching dialog so a + * late expire for an old prompt never kills the current one. + * Desktop parity: `patchOverlayState(prev => prev.sudo?.requestId === id ? null : prev)`. + */ + private 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). Previously dropped → * agent hung forever. Now we surface a secure dialog and reply via @@ -3659,18 +3686,82 @@ class ChatViewModel( private fun handleSecretRequest(event: WsEvent.SecretRequest) { _uiState.update { it.copy( - secretPrompt = SecretPromptUi(event.requestId, event.sessionId), + secretPrompt = + SecretPromptUi( + event.requestId, + event.sessionId, + event.envVar, + event.prompt, + ), isAgentTyping = false, ) } } - fun dismissSudo() { - _uiState.update { it.copy(sudoPrompt = null) } + /** + * Backend secret timeout — match-only clear like [handleSudoExpire]. + */ + private 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: + * `prompt-overlays.tsx` `send('')`). Backend treats empty sudo as + * failed sudo (no command runs), so closing the dialog is a safe + * refusal that unblocks the turn instantly instead of hanging 120s + * until `sudo.expire`. + */ + 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 + viewModelScope.launch(ioDispatcher) { + val params = + mutableMapOf( + "session_id" to sessionId, + "password" to "", + ) + prompt.requestId?.let { id -> params["request_id"] = id } + wsClient.send( + method = WsMethods.SUDO_RESPOND, + params = params, + onSent = { 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 + viewModelScope.launch(ioDispatcher) { + val params = + mutableMapOf( + "session_id" to sessionId, + "value" to "", + ) + prompt.requestId?.let { id -> params["request_id"] = id } + wsClient.send( + method = WsMethods.SECRET_RESPOND, + params = params, + onSent = { id -> trackRequest(id, WsMethods.SECRET_RESPOND) }, + ) + } } /** diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducer.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducer.kt index 50562f1e..d4f84abe 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducer.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducer.kt @@ -163,8 +163,12 @@ object ChatWsEventReducer { // SudoRequest / SecretRequest are handled by the ViewModel (issue #524) is WsEvent.SudoRequest -> ReducerResult(state = state, streamingState = streamingState) + is WsEvent.SudoExpire -> ReducerResult(state = state, streamingState = streamingState) + is WsEvent.SecretRequest -> ReducerResult(state = state, streamingState = streamingState) + is WsEvent.SecretExpire -> ReducerResult(state = state, streamingState = streamingState) + // ReactionEvent is handled by the ViewModel — purely cosmetic animation is WsEvent.ReactionEvent -> ReducerResult(state = state, streamingState = streamingState) diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatDialogs.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatDialogs.kt index 0d3dad90..e7fdd9fb 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatDialogs.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatDialogs.kt @@ -87,20 +87,25 @@ fun SudoPromptDialog( fun SecretPromptDialog( onConfirm: (String) -> Unit, onDismiss: () -> Unit, + envVar: String? = null, + prompt: String? = null, ) { var secret by remember { mutableStateOf("") } + val titleText = envVar?.takeIf { it.isNotBlank() } ?: stringResource(R.string.chat_secret_title) + val bodyText = prompt?.takeIf { it.isNotBlank() } ?: stringResource(R.string.chat_secret_body) + val labelText = envVar?.takeIf { it.isNotBlank() } ?: stringResource(R.string.chat_secret_value) AlertDialog( onDismissRequest = onDismiss, - title = { Text(stringResource(R.string.chat_secret_title)) }, + title = { Text(titleText) }, text = { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - Text(text = stringResource(R.string.chat_secret_body)) + Text(text = bodyText) Spacer(modifier = Modifier.height(4.dp)) OutlinedTextField( value = secret, onValueChange = { secret = it }, - label = { Text(stringResource(R.string.chat_secret_value)) }, + label = { Text(labelText) }, modifier = Modifier.fillMaxWidth().testTag("secret_value_input"), singleLine = true, visualTransformation = PasswordVisualTransformation(), diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatLifecycleEffects.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatLifecycleEffects.kt index c869afb4..7af3e313 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatLifecycleEffects.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatLifecycleEffects.kt @@ -133,6 +133,8 @@ fun ChatLifecycleEffects( SecretPromptDialog( onConfirm = viewModel::respondToSecret, onDismiss = viewModel::dismissSecret, + envVar = prompt.envVar, + prompt = prompt.prompt, ) } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 6930444a..108c8063 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -145,6 +145,7 @@ Waiting for Hermes replies New message Hermes needs a clarification + Hermes needs your input Type your reply\u2026 Hermes Background Service Keeps connection alive in the background 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 98d595e3..db180e13 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 @@ -855,4 +855,71 @@ class EventParserTest { assertEquals("1", todoUpdated.todos[1].parent) assertTrue(todoUpdated.todos[1].isSubtask) } + + @Test + fun testParseSudoRequest_returnsSudoRequest() { + val params = + mapOf( + "type" to "sudo.request", + "session_id" to "sess-sudo-1", + "payload" to mapOf("request_id" to "sudo-1"), + ) + val event = EventParser.parseParams(params) + assertTrue(event is WsEvent.SudoRequest) + val sudo = event as WsEvent.SudoRequest + assertEquals("sudo-1", sudo.requestId) + assertEquals("sess-sudo-1", sudo.sessionId) + } + + @Test + fun testParseSudoExpire_returnsSudoExpire() { + val params = + mapOf( + "type" to "sudo.expire", + "session_id" to "sess-sudo-1", + "payload" to mapOf("request_id" to "sudo-1"), + ) + val event = EventParser.parseParams(params) + assertTrue(event is WsEvent.SudoExpire) + val expire = event as WsEvent.SudoExpire + assertEquals("sudo-1", expire.requestId) + assertEquals("sess-sudo-1", expire.sessionId) + } + + @Test + fun testParseSecretRequest_withEnvVarAndPrompt() { + val params = + mapOf( + "type" to "secret.request", + "session_id" to "sess-secret-1", + "payload" to + mapOf( + "request_id" to "secret-1", + "env_var" to "GITHUB_TOKEN", + "prompt" to "Enter your GitHub token to continue", + ), + ) + val event = EventParser.parseParams(params) + assertTrue(event is WsEvent.SecretRequest) + val secret = event as WsEvent.SecretRequest + assertEquals("secret-1", secret.requestId) + assertEquals("sess-secret-1", secret.sessionId) + assertEquals("GITHUB_TOKEN", secret.envVar) + assertEquals("Enter your GitHub token to continue", secret.prompt) + } + + @Test + fun testParseSecretExpire_returnsSecretExpire() { + val params = + mapOf( + "type" to "secret.expire", + "session_id" to "sess-secret-1", + "payload" to mapOf("request_id" to "secret-1"), + ) + val event = EventParser.parseParams(params) + assertTrue(event is WsEvent.SecretExpire) + val expire = event as WsEvent.SecretExpire + assertEquals("secret-1", expire.requestId) + assertEquals("sess-secret-1", expire.sessionId) + } } 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 8c453580..39030a6a 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 @@ -3159,6 +3159,130 @@ class ChatViewModelTest { assertNull(viewModel.uiState.value.secretPrompt) } + @Test + fun testSudoExpire_clearsMatchingPrompt() = + runTest { + val (viewModel, _) = createViewModelWithSession() + advanceUntilIdle() + + mockEventsFlow.emit(WsEvent.SudoRequest(requestId = "sudo-1", sessionId = null)) + advanceUntilIdle() + assertNotNull(viewModel.uiState.value.sudoPrompt) + + mockEventsFlow.emit(WsEvent.SudoExpire(requestId = "sudo-1", sessionId = null)) + advanceUntilIdle() + + assertNull(viewModel.uiState.value.sudoPrompt) + } + + @Test + fun testSudoExpire_ignoresNonMatchingRequestId() = + runTest { + val (viewModel, _) = createViewModelWithSession() + advanceUntilIdle() + + mockEventsFlow.emit(WsEvent.SudoRequest(requestId = "sudo-2", sessionId = null)) + advanceUntilIdle() + assertNotNull(viewModel.uiState.value.sudoPrompt) + + mockEventsFlow.emit(WsEvent.SudoExpire(requestId = "sudo-stale", sessionId = null)) + advanceUntilIdle() + + assertNotNull(viewModel.uiState.value.sudoPrompt) + } + + @Test + fun testSecretExpire_clearsMatchingPrompt() = + runTest { + val (viewModel, _) = createViewModelWithSession() + advanceUntilIdle() + + mockEventsFlow.emit(WsEvent.SecretRequest(requestId = "secret-1", sessionId = null)) + advanceUntilIdle() + assertNotNull(viewModel.uiState.value.secretPrompt) + + mockEventsFlow.emit(WsEvent.SecretExpire(requestId = "secret-1", sessionId = null)) + advanceUntilIdle() + + assertNull(viewModel.uiState.value.secretPrompt) + } + + @Test + fun testSecretRequest_setsEnvVarAndPrompt() = + runTest { + val (viewModel, _) = createViewModelWithSession() + advanceUntilIdle() + + mockEventsFlow.emit( + WsEvent.SecretRequest( + requestId = "secret-1", + sessionId = null, + envVar = "GITHUB_TOKEN", + prompt = "Enter your GitHub token", + ), + ) + advanceUntilIdle() + + val prompt = viewModel.uiState.value.secretPrompt + assertNotNull(prompt) + assertEquals("GITHUB_TOKEN", prompt?.envVar) + assertEquals("Enter your GitHub token", prompt?.prompt) + } + + @Test + fun testDismissSudo_sendsEmptyPassword() = + runTest { + val (viewModel, sessionId) = createViewModelWithSession() + advanceUntilIdle() + + mockEventsFlow.emit(WsEvent.SudoRequest(requestId = "sudo-1", sessionId = null)) + advanceUntilIdle() + assertNotNull(viewModel.uiState.value.sudoPrompt) + + viewModel.dismissSudo() + advanceUntilIdle() + + assertNull(viewModel.uiState.value.sudoPrompt) + verify { + HermesWsClient.send( + WsMethods.SUDO_RESPOND, + withArg { params -> + assertEquals(sessionId, params["session_id"]) + assertEquals("", params["password"]) + assertEquals("sudo-1", params["request_id"]) + }, + any(), + ) + } + } + + @Test + fun testDismissSecret_sendsEmptyValue() = + runTest { + val (viewModel, sessionId) = createViewModelWithSession() + advanceUntilIdle() + + mockEventsFlow.emit(WsEvent.SecretRequest(requestId = "secret-1", sessionId = null)) + advanceUntilIdle() + assertNotNull(viewModel.uiState.value.secretPrompt) + + viewModel.dismissSecret() + advanceUntilIdle() + + assertNull(viewModel.uiState.value.secretPrompt) + verify { + HermesWsClient.send( + WsMethods.SECRET_RESPOND, + withArg { params -> + assertEquals(sessionId, params["session_id"]) + assertEquals("", params["value"]) + assertEquals("secret-1", params["request_id"]) + }, + any(), + ) + } + } + // ── Settings ───────────────────────────────────────────────────────────── @Test