feat(chat): full sudo.request support with expire, cancel-unblock, secret fields (#1056)
This commit is contained in:
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user