feat(chat): full sudo.request support with expire, cancel-unblock, secret fields (#1056)

This commit is contained in:
M57 authored and GitHub committed 2026-09-03 22:08:07 +03:00
1 parent 6d2362ba8a
commit 077ad1fa81
10 files changed
+348 -7

No files matched your search

@@ -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 -> {
@@ -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 ───────────────────────────────────────────
@@ -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 -> {}
}
}
@@ -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<String, Any>(
"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<String, Any>(
"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) },
)
}
}
/**
@@ -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)
@@ -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(),
@@ -133,6 +133,8 @@ fun ChatLifecycleEffects(
SecretPromptDialog(
onConfirm = viewModel::respondToSecret,
onDismiss = viewModel::dismissSecret,
envVar = prompt.envVar,
prompt = prompt.prompt,
)
}
}
+1
View File
@@ -145,6 +145,7 @@
<string name="notif_waiting_replies">Waiting for Hermes replies</string>
<string name="notif_new_message">New message</string>
<string name="notif_clarification_needed">Hermes needs a clarification</string>
<string name="notif_input_needed">Hermes needs your input</string>
<string name="notif_reply_placeholder">Type your reply\u2026</string>
<string name="notif_channel_service_name">Hermes Background Service</string>
<string name="notif_channel_service_desc">Keeps connection alive in the background</string>
@@ -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)
}
}
@@ -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