feat(chat): full approval.request support (#1057)
This commit is contained in:
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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user