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,
)
}
}