feat(chat): full approval.request support (#1057)

This commit is contained in:
M57 authored and GitHub committed 2026-09-03 22:33:15 +03:00
1 parent 077ad1fa81
commit bd0b4c2d55
9 files changed
+630 -49

No files matched your search

@@ -283,7 +283,40 @@ object EventParser {
@Suppress("UNCHECKED_CAST")
val patternKeys = (payload?.get("pattern_keys") as? List<*>)?.filterIsInstance<String>()
WsEvent.ApprovalRequest(command, description, patternKeys, sessionId)
val requestId = payload?.get("request_id") as? String
@Suppress("UNCHECKED_CAST")
val rawChoices = (payload?.get("choices") as? List<*>)?.filterIsInstance<String>()
val allowPermanent = payload?.get("allow_permanent") as? Boolean
val allowSession = payload?.get("allow_session") as? Boolean
val smartDenied = payload?.get("smart_denied") as? Boolean
// Backend default (server.py `_approval_request_payload`):
// smart-denied → once/deny only; else once + session? + always? + deny.
val choices =
rawChoices ?: run {
if (smartDenied == true) {
listOf("once", "deny")
} else {
buildList {
add("once")
if (allowSession != false) {
add("session")
if (allowPermanent != false) add("always")
}
add("deny")
}
}
}
WsEvent.ApprovalRequest(
command,
description,
patternKeys,
sessionId,
requestId,
choices,
allowPermanent,
smartDenied,
)
}
"sudo.request" -> {
@@ -230,6 +230,30 @@ sealed class WsEvent {
val description: String?,
val patternKeys: List<String>?,
val sessionId: String?,
/**
* Backend `request_id` — unique across sessions. Sent back as
* `request_id` in `approval.respond` so the gateway resolves the
* EXACT pending instead of FIFO-oldest (desktop parity:
* `prompts.ts` `receiveApprovalRequest` + `approval.tsx` respond).
* Null on legacy payloads → omit, FIFO-compatible.
*/
val requestId: String? = null,
/**
* Backend-advertised choices (e.g. `["once","session","always","deny"]`,
* smart-denied → `["once","deny"]`). Null = legacy → UI falls back
* to Run/Deny.
*/
val choices: List<String>? = null,
/**
* False when the backend won't honor a permanent allow (tirith
* warning) → hide "Always allow" (desktop `allowPermanent` parity).
*/
val allowPermanent: Boolean? = null,
/**
* True when this is an owner override of a Smart DENY — backend
* restricts to once/deny only (desktop `smartDenied` parity).
*/
val smartDenied: Boolean? = null,
) : WsEvent()
// ── Sudo / secret requests ─────────────────────────────────────────
@@ -34,6 +34,12 @@ object WsMethods {
const val PROMPT_BTW = "prompt.btw"
const val CLARIFY_RESPOND = "clarify.respond"
const val APPROVAL_RESPOND = "approval.respond"
/** Ack that the client rendered a pending approval (desktop parity). */
const val APPROVAL_RECEIVED = "approval.received"
/** List unresolved approvals for a session (reconnect replay). */
const val APPROVAL_PENDING = "approval.pending"
const val SUDO_RESPOND = "sudo.respond"
const val SECRET_RESPOND = "secret.respond"
@@ -131,6 +131,14 @@ class ChatNotificationService : Service() {
showReplyNotification(body, null)
}
is WsEvent.ApprovalRequest -> {
val preview =
event.description?.takeIf { it.isNotBlank() }
?: event.command?.takeIf { it.isNotBlank() }
?: getString(R.string.notif_input_needed)
showReplyNotification(preview.take(100), null)
}
else -> {}
}
}
@@ -13,6 +13,8 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
@@ -31,6 +33,7 @@ import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.Psychology
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
@@ -41,6 +44,7 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
@@ -321,6 +325,7 @@ private fun SelfImprovementReviewCard(
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
internal fun SystemBubble(
message: ChatMessage,
@@ -332,6 +337,9 @@ internal fun SystemBubble(
return
}
val approvalInfo = message.approvalInfo
var confirmAlways by remember(message.id) { mutableStateOf(false) }
Column(
modifier =
modifier
@@ -348,52 +356,145 @@ internal fun SystemBubble(
),
)
// Approval action buttons
if (message.approvalInfo != null) {
// Approval action buttons — dynamic from backend `choices`
// (desktop `approval.tsx` parity: once/session/always/deny,
// smart-denied → once/deny only, allowPermanent=false hides Always).
if (approvalInfo != null) {
Spacer(Modifier.height(8.dp))
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
val rawChoices = approvalInfo.choices ?: listOf("once", "deny")
val visibleChoices =
rawChoices.filter { choice ->
when (choice) {
"always" -> approvalInfo.allowPermanent != false
else -> true
}
}
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
FilledTonalButton(
onClick = { onRespondApproval("approve") },
modifier =
Modifier
.height(36.dp)
.testTag("approve_button"),
colors =
ButtonDefaults.filledTonalButtonColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
),
) {
Icon(
imageVector = Icons.Filled.Check,
contentDescription = null,
modifier = Modifier.size(16.dp),
)
Spacer(Modifier.width(4.dp))
Text("Approve")
}
for (choice in visibleChoices) {
when (choice) {
"deny" -> {
FilledTonalButton(
onClick = { onRespondApproval("deny") },
modifier =
Modifier
.height(36.dp)
.testTag("deny_button"),
colors =
ButtonDefaults.filledTonalButtonColors(
containerColor = MaterialTheme.colorScheme.errorContainer,
),
) {
Icon(
imageVector = Icons.Filled.Close,
contentDescription = null,
modifier = Modifier.size(16.dp),
)
Spacer(Modifier.width(4.dp))
Text("Deny")
}
}
FilledTonalButton(
onClick = { onRespondApproval("deny") },
modifier =
Modifier
.height(36.dp)
.testTag("deny_button"),
colors =
ButtonDefaults.filledTonalButtonColors(
containerColor = MaterialTheme.colorScheme.errorContainer,
),
) {
Icon(
imageVector = Icons.Filled.Close,
contentDescription = null,
modifier = Modifier.size(16.dp),
)
Spacer(Modifier.width(4.dp))
Text("Deny")
"session" -> {
FilledTonalButton(
onClick = { onRespondApproval("session") },
modifier =
Modifier
.height(36.dp)
.testTag("session_button"),
colors =
ButtonDefaults.filledTonalButtonColors(
containerColor = MaterialTheme.colorScheme.secondaryContainer,
),
) {
Icon(
imageVector = Icons.Filled.Check,
contentDescription = null,
modifier = Modifier.size(16.dp),
)
Spacer(Modifier.width(4.dp))
Text("Allow session")
}
}
"always" -> {
FilledTonalButton(
onClick = { confirmAlways = true },
modifier =
Modifier
.height(36.dp)
.testTag("always_button"),
colors =
ButtonDefaults.filledTonalButtonColors(
containerColor = MaterialTheme.colorScheme.tertiaryContainer,
),
) {
Icon(
imageVector = Icons.Filled.Check,
contentDescription = null,
modifier = Modifier.size(16.dp),
)
Spacer(Modifier.width(4.dp))
Text("Always allow")
}
}
else -> {
// "once" (+ legacy "approve" → normalized to once in VM)
FilledTonalButton(
onClick = { onRespondApproval(choice) },
modifier =
Modifier
.height(36.dp)
.testTag("approve_button"),
colors =
ButtonDefaults.filledTonalButtonColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
),
) {
Icon(
imageVector = Icons.Filled.Check,
contentDescription = null,
modifier = Modifier.size(16.dp),
)
Spacer(Modifier.width(4.dp))
Text(if (choice == "approve") "Approve" else "Run")
}
}
}
}
}
// "Always allow" persists the pattern permanently — confirm first
// (desktop confirm-modal parity).
if (confirmAlways) {
AlertDialog(
onDismissRequest = { confirmAlways = false },
title = { Text("Always allow this command?") },
text = {
Text(
"This persists the pattern permanently so future " +
"matching commands run without asking.",
)
},
confirmButton = {
TextButton(
onClick = {
confirmAlways = false
onRespondApproval("always")
},
) {
Text("Always allow")
}
},
dismissButton = {
TextButton(onClick = { confirmAlways = false }) {
Text("Cancel")
}
},
)
}
}
}
}
@@ -5,13 +5,21 @@ import java.util.UUID
/**
* Metadata for a [ChatMessage] that represents an approval request.
* When present, the UI renders Approve/Deny buttons inline.
* When present, the UI renders approval buttons inline.
* Transient — not persisted to SQLite.
*/
data class ApprovalInfo(
val command: String?,
val description: String?,
val patternKeys: List<String>?,
/** Backend `request_id` — sent back in `approval.respond` to pin the exact pending. */
val requestId: String? = null,
/** Backend-advertised choices (`once`/`session`/`always`/`deny`). Null = legacy Run/Deny. */
val choices: List<String>? = null,
/** False → hide "Always allow" (tirith warning, desktop parity). */
val allowPermanent: Boolean? = null,
/** True → Smart DENY override, once/deny only. */
val smartDenied: Boolean? = null,
)
/**
@@ -938,6 +938,18 @@ class ChatViewModel(
// chip stays hidden until the real window lands.
viewModelScope.launch { fetchContextUsage(skipRestFallback = true) }
}
// Session.info can carry `pending_approval` (reconnect
// reconciliation) — surface it unless already on screen.
val pendingApproval = info["pending_approval"] as? Map<*, *>
if (pendingApproval != null) {
val rid = pendingApproval["request_id"] as? String
val alreadyShown =
rid != null &&
_uiState.value.messages.any { it.approvalInfo?.requestId == rid }
if (!alreadyShown) {
handleApprovalRequest(parseApprovalMap(pendingApproval, _uiState.value.currentSessionId))
}
}
}
}
@@ -1219,6 +1231,20 @@ class ChatViewModel(
val generation = request?.generation ?: sessionGeneration
resumedGeneration = generation
finishResumeWhenHydrated(generation)
// Reconnect replay: resume payload can carry `pending_approval`
// (server `_session_info_payload`); surface it, then ask for
// the full queue in case more are parked.
val pendingApproval = resultMap?.get("pending_approval") as? Map<*, *>
if (pendingApproval != null) {
val rid = pendingApproval["request_id"] as? String
val alreadyShown =
rid != null &&
_uiState.value.messages.any { it.approvalInfo?.requestId == rid }
if (!alreadyShown) {
handleApprovalRequest(parseApprovalMap(pendingApproval, sessionId))
}
}
if (sessionId != null) replayPendingApproval(sessionId)
}
WsMethods.SESSION_INTERRUPT -> {
@@ -1258,6 +1284,10 @@ class ChatViewModel(
}
}
WsMethods.APPROVAL_PENDING -> {
handleApprovalPendingResult(result)
}
WsMethods.CONFIG_SET -> {
val pending = pendingModelSwitchRequests.remove(id)
val map = result as? Map<*, *> ?: return
@@ -3603,6 +3633,10 @@ class ChatViewModel(
command = event.command,
description = event.description,
patternKeys = event.patternKeys,
requestId = event.requestId,
choices = event.choices,
allowPermanent = event.allowPermanent,
smartDenied = event.smartDenied,
),
)
_uiState.update { state ->
@@ -3611,12 +3645,106 @@ class ChatViewModel(
isAgentTyping = false,
)
}
// Desktop parity (`prompts.ts` receiveApprovalRequest): ack the render
// so the backend knows this client holds the prompt. Fire-and-forget.
val requestId = event.requestId
val sessionId = event.sessionId ?: _uiState.value.currentSessionId
if (requestId != null && sessionId != null) {
viewModelScope.launch(ioDispatcher) {
runCatching {
wsClient.send(
method = WsMethods.APPROVAL_RECEIVED,
params =
mapOf(
"session_id" to sessionId,
"request_id" to requestId,
),
)
}
}
}
}
/**
* Convert a backend approval map (snake_case, from `approval.pending` or
* `session.info` `pending_approval`) into a typed event. Mirrors
* [EventParser] defaults so replay and live paths agree.
*/
private fun parseApprovalMap(
map: Map<*, *>,
sessionId: String?,
): WsEvent.ApprovalRequest {
@Suppress("UNCHECKED_CAST")
val patternKeys = (map["pattern_keys"] as? List<*>)?.filterIsInstance<String>()
@Suppress("UNCHECKED_CAST")
val rawChoices = (map["choices"] as? List<*>)?.filterIsInstance<String>()
val allowPermanent = map["allow_permanent"] as? Boolean
val allowSession = map["allow_session"] as? Boolean
val smartDenied = map["smart_denied"] as? Boolean
val choices =
rawChoices ?: run {
if (smartDenied == true) {
listOf("once", "deny")
} else {
buildList {
add("once")
if (allowSession != false) {
add("session")
if (allowPermanent != false) add("always")
}
add("deny")
}
}
}
return WsEvent.ApprovalRequest(
command = map["command"] as? String,
description = map["description"] as? String,
patternKeys = patternKeys,
sessionId = sessionId,
requestId = map["request_id"] as? String,
choices = choices,
allowPermanent = allowPermanent,
smartDenied = smartDenied,
)
}
/**
* Reconnect replay (desktop `replayPendingApproval` parity): ask the
* gateway for unresolved approvals and surface the oldest one. Called
* after resume and after each respond (the queue can hold more).
*/
private fun replayPendingApproval(sessionId: String) {
viewModelScope.launch(ioDispatcher) {
wsClient.send(
method = WsMethods.APPROVAL_PENDING,
params = mapOf("session_id" to sessionId),
onSent = { id -> trackRequest(id, WsMethods.APPROVAL_PENDING) },
)
}
}
private fun handleApprovalPendingResult(result: Any?) {
@Suppress("UNCHECKED_CAST")
val approvals = (result as? Map<*, *>)?.get("approvals") as? List<*>
val first = approvals?.filterIsInstance<Map<*, *>>()?.firstOrNull() ?: return
val requestId = first["request_id"] as? String ?: return
val sessionId = _uiState.value.currentSessionId
// Don't duplicate a prompt already on screen.
val alreadyShown =
_uiState.value.messages.any { it.approvalInfo?.requestId == requestId }
if (alreadyShown) return
handleApprovalRequest(parseApprovalMap(first, sessionId))
}
fun respondToApproval(action: String) {
val state = _uiState.value
val approvalMsg = state.messages.lastOrNull { it.approvalInfo != null } ?: return
val sessionId = state.currentSessionId ?: return
// Desktop sends `once` for a single run; legacy mobile sent `approve`
// (any non-deny still unblocks, but stay on-spec going forward).
val choice = if (action == "approve") "once" else action
val requestId = approvalMsg.approvalInfo?.requestId
// Clear buttons immediately
_uiState.update { s ->
@@ -3633,16 +3761,20 @@ class ChatViewModel(
}
viewModelScope.launch(ioDispatcher) {
val params =
mutableMapOf<String, Any>(
"session_id" to sessionId,
"choice" to choice,
"all" to false,
)
if (requestId != null) params["request_id"] = requestId
wsClient.send(
method = WsMethods.APPROVAL_RESPOND,
params =
mapOf(
"session_id" to sessionId,
"choice" to action,
"all" to false,
),
params = params,
onSent = { id -> trackRequest(id, WsMethods.APPROVAL_RESPOND) },
)
// The queue can hold more pendings — surface the next one.
replayPendingApproval(sessionId)
}
}