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")
|
@Suppress("UNCHECKED_CAST")
|
||||||
val patternKeys = (payload?.get("pattern_keys") as? List<*>)?.filterIsInstance<String>()
|
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" -> {
|
"sudo.request" -> {
|
||||||
|
|||||||
@@ -230,6 +230,30 @@ sealed class WsEvent {
|
|||||||
val description: String?,
|
val description: String?,
|
||||||
val patternKeys: List<String>?,
|
val patternKeys: List<String>?,
|
||||||
val sessionId: 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()
|
) : WsEvent()
|
||||||
|
|
||||||
// ── Sudo / secret requests ─────────────────────────────────────────
|
// ── Sudo / secret requests ─────────────────────────────────────────
|
||||||
|
|||||||
@@ -34,6 +34,12 @@ object WsMethods {
|
|||||||
const val PROMPT_BTW = "prompt.btw"
|
const val PROMPT_BTW = "prompt.btw"
|
||||||
const val CLARIFY_RESPOND = "clarify.respond"
|
const val CLARIFY_RESPOND = "clarify.respond"
|
||||||
const val APPROVAL_RESPOND = "approval.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 SUDO_RESPOND = "sudo.respond"
|
||||||
const val SECRET_RESPOND = "secret.respond"
|
const val SECRET_RESPOND = "secret.respond"
|
||||||
|
|
||||||
|
|||||||
@@ -131,6 +131,14 @@ class ChatNotificationService : Service() {
|
|||||||
showReplyNotification(body, null)
|
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 -> {}
|
else -> {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import androidx.compose.foundation.clickable
|
|||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
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.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
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.ContentCopy
|
||||||
import androidx.compose.material.icons.filled.Download
|
import androidx.compose.material.icons.filled.Download
|
||||||
import androidx.compose.material.icons.filled.Psychology
|
import androidx.compose.material.icons.filled.Psychology
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.material3.ButtonDefaults
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
import androidx.compose.material3.CardDefaults
|
import androidx.compose.material3.CardDefaults
|
||||||
@@ -41,6 +44,7 @@ import androidx.compose.material3.IconButton
|
|||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.CompositionLocalProvider
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
@@ -321,6 +325,7 @@ private fun SelfImprovementReviewCard(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalLayoutApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
internal fun SystemBubble(
|
internal fun SystemBubble(
|
||||||
message: ChatMessage,
|
message: ChatMessage,
|
||||||
@@ -332,6 +337,9 @@ internal fun SystemBubble(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val approvalInfo = message.approvalInfo
|
||||||
|
var confirmAlways by remember(message.id) { mutableStateOf(false) }
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier =
|
modifier =
|
||||||
modifier
|
modifier
|
||||||
@@ -348,52 +356,145 @@ internal fun SystemBubble(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
// Approval action buttons
|
// Approval action buttons — dynamic from backend `choices`
|
||||||
if (message.approvalInfo != null) {
|
// (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))
|
Spacer(Modifier.height(8.dp))
|
||||||
Row(
|
val rawChoices = approvalInfo.choices ?: listOf("once", "deny")
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
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(
|
for (choice in visibleChoices) {
|
||||||
onClick = { onRespondApproval("approve") },
|
when (choice) {
|
||||||
modifier =
|
"deny" -> {
|
||||||
Modifier
|
FilledTonalButton(
|
||||||
.height(36.dp)
|
onClick = { onRespondApproval("deny") },
|
||||||
.testTag("approve_button"),
|
modifier =
|
||||||
colors =
|
Modifier
|
||||||
ButtonDefaults.filledTonalButtonColors(
|
.height(36.dp)
|
||||||
containerColor = MaterialTheme.colorScheme.primaryContainer,
|
.testTag("deny_button"),
|
||||||
),
|
colors =
|
||||||
) {
|
ButtonDefaults.filledTonalButtonColors(
|
||||||
Icon(
|
containerColor = MaterialTheme.colorScheme.errorContainer,
|
||||||
imageVector = Icons.Filled.Check,
|
),
|
||||||
contentDescription = null,
|
) {
|
||||||
modifier = Modifier.size(16.dp),
|
Icon(
|
||||||
)
|
imageVector = Icons.Filled.Close,
|
||||||
Spacer(Modifier.width(4.dp))
|
contentDescription = null,
|
||||||
Text("Approve")
|
modifier = Modifier.size(16.dp),
|
||||||
}
|
)
|
||||||
|
Spacer(Modifier.width(4.dp))
|
||||||
|
Text("Deny")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
FilledTonalButton(
|
"session" -> {
|
||||||
onClick = { onRespondApproval("deny") },
|
FilledTonalButton(
|
||||||
modifier =
|
onClick = { onRespondApproval("session") },
|
||||||
Modifier
|
modifier =
|
||||||
.height(36.dp)
|
Modifier
|
||||||
.testTag("deny_button"),
|
.height(36.dp)
|
||||||
colors =
|
.testTag("session_button"),
|
||||||
ButtonDefaults.filledTonalButtonColors(
|
colors =
|
||||||
containerColor = MaterialTheme.colorScheme.errorContainer,
|
ButtonDefaults.filledTonalButtonColors(
|
||||||
),
|
containerColor = MaterialTheme.colorScheme.secondaryContainer,
|
||||||
) {
|
),
|
||||||
Icon(
|
) {
|
||||||
imageVector = Icons.Filled.Close,
|
Icon(
|
||||||
contentDescription = null,
|
imageVector = Icons.Filled.Check,
|
||||||
modifier = Modifier.size(16.dp),
|
contentDescription = null,
|
||||||
)
|
modifier = Modifier.size(16.dp),
|
||||||
Spacer(Modifier.width(4.dp))
|
)
|
||||||
Text("Deny")
|
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.
|
* 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.
|
* Transient — not persisted to SQLite.
|
||||||
*/
|
*/
|
||||||
data class ApprovalInfo(
|
data class ApprovalInfo(
|
||||||
val command: String?,
|
val command: String?,
|
||||||
val description: String?,
|
val description: String?,
|
||||||
val patternKeys: List<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.
|
// chip stays hidden until the real window lands.
|
||||||
viewModelScope.launch { fetchContextUsage(skipRestFallback = true) }
|
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
|
val generation = request?.generation ?: sessionGeneration
|
||||||
resumedGeneration = generation
|
resumedGeneration = generation
|
||||||
finishResumeWhenHydrated(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 -> {
|
WsMethods.SESSION_INTERRUPT -> {
|
||||||
@@ -1258,6 +1284,10 @@ class ChatViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
WsMethods.APPROVAL_PENDING -> {
|
||||||
|
handleApprovalPendingResult(result)
|
||||||
|
}
|
||||||
|
|
||||||
WsMethods.CONFIG_SET -> {
|
WsMethods.CONFIG_SET -> {
|
||||||
val pending = pendingModelSwitchRequests.remove(id)
|
val pending = pendingModelSwitchRequests.remove(id)
|
||||||
val map = result as? Map<*, *> ?: return
|
val map = result as? Map<*, *> ?: return
|
||||||
@@ -3603,6 +3633,10 @@ class ChatViewModel(
|
|||||||
command = event.command,
|
command = event.command,
|
||||||
description = event.description,
|
description = event.description,
|
||||||
patternKeys = event.patternKeys,
|
patternKeys = event.patternKeys,
|
||||||
|
requestId = event.requestId,
|
||||||
|
choices = event.choices,
|
||||||
|
allowPermanent = event.allowPermanent,
|
||||||
|
smartDenied = event.smartDenied,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
_uiState.update { state ->
|
_uiState.update { state ->
|
||||||
@@ -3611,12 +3645,106 @@ class ChatViewModel(
|
|||||||
isAgentTyping = false,
|
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) {
|
fun respondToApproval(action: String) {
|
||||||
val state = _uiState.value
|
val state = _uiState.value
|
||||||
val approvalMsg = state.messages.lastOrNull { it.approvalInfo != null } ?: return
|
val approvalMsg = state.messages.lastOrNull { it.approvalInfo != null } ?: return
|
||||||
val sessionId = state.currentSessionId ?: 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
|
// Clear buttons immediately
|
||||||
_uiState.update { s ->
|
_uiState.update { s ->
|
||||||
@@ -3633,16 +3761,20 @@ class ChatViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
viewModelScope.launch(ioDispatcher) {
|
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(
|
wsClient.send(
|
||||||
method = WsMethods.APPROVAL_RESPOND,
|
method = WsMethods.APPROVAL_RESPOND,
|
||||||
params =
|
params = params,
|
||||||
mapOf(
|
|
||||||
"session_id" to sessionId,
|
|
||||||
"choice" to action,
|
|
||||||
"all" to false,
|
|
||||||
),
|
|
||||||
onSent = { id -> trackRequest(id, WsMethods.APPROVAL_RESPOND) },
|
onSent = { id -> trackRequest(id, WsMethods.APPROVAL_RESPOND) },
|
||||||
)
|
)
|
||||||
|
// The queue can hold more pendings — surface the next one.
|
||||||
|
replayPendingApproval(sessionId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -400,6 +400,114 @@ class EventParserTest {
|
|||||||
assertEquals("req-expire-1", expireEvent.clarifyId)
|
assertEquals("req-expire-1", expireEvent.clarifyId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Approval requests (full support) ───────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testParseApprovalRequest_withChoicesAndRequestId() {
|
||||||
|
val response =
|
||||||
|
createJsonRpcResponse(
|
||||||
|
jsonrpc = "2.0",
|
||||||
|
id = null,
|
||||||
|
result = null,
|
||||||
|
error = null,
|
||||||
|
method = "event",
|
||||||
|
params =
|
||||||
|
mapOf(
|
||||||
|
"type" to "approval.request",
|
||||||
|
"payload" to
|
||||||
|
mapOf(
|
||||||
|
"command" to "rm -rf /tmp/x",
|
||||||
|
"description" to "dangerous command",
|
||||||
|
"request_id" to "req-1",
|
||||||
|
"choices" to listOf("once", "session", "always", "deny"),
|
||||||
|
"allow_permanent" to true,
|
||||||
|
"pattern_keys" to listOf("shell:rm"),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val event = EventParser.parse(response)
|
||||||
|
assertTrue(event is WsEvent.ApprovalRequest)
|
||||||
|
val approval = event as WsEvent.ApprovalRequest
|
||||||
|
assertEquals("rm -rf /tmp/x", approval.command)
|
||||||
|
assertEquals("req-1", approval.requestId)
|
||||||
|
assertEquals(listOf("once", "session", "always", "deny"), approval.choices)
|
||||||
|
assertEquals(true, approval.allowPermanent)
|
||||||
|
assertEquals(listOf("shell:rm"), approval.patternKeys)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testParseApprovalRequest_smartDeniedDefaultsToOnceDeny() {
|
||||||
|
val response =
|
||||||
|
createJsonRpcResponse(
|
||||||
|
jsonrpc = "2.0",
|
||||||
|
id = null,
|
||||||
|
result = null,
|
||||||
|
error = null,
|
||||||
|
method = "event",
|
||||||
|
params =
|
||||||
|
mapOf(
|
||||||
|
"type" to "approval.request",
|
||||||
|
"payload" to
|
||||||
|
mapOf(
|
||||||
|
"command" to "rm -rf /",
|
||||||
|
"smart_denied" to true,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val event = EventParser.parse(response)
|
||||||
|
assertTrue(event is WsEvent.ApprovalRequest)
|
||||||
|
val approval = event as WsEvent.ApprovalRequest
|
||||||
|
assertEquals(listOf("once", "deny"), approval.choices)
|
||||||
|
assertEquals(true, approval.smartDenied)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testParseApprovalRequest_legacyDefaultsToFullChoices() {
|
||||||
|
val response =
|
||||||
|
createJsonRpcResponse(
|
||||||
|
jsonrpc = "2.0",
|
||||||
|
id = null,
|
||||||
|
result = null,
|
||||||
|
error = null,
|
||||||
|
method = "event",
|
||||||
|
params =
|
||||||
|
mapOf(
|
||||||
|
"type" to "approval.request",
|
||||||
|
"payload" to mapOf("command" to "ls"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val event = EventParser.parse(response)
|
||||||
|
assertTrue(event is WsEvent.ApprovalRequest)
|
||||||
|
val approval = event as WsEvent.ApprovalRequest
|
||||||
|
assertEquals(listOf("once", "session", "always", "deny"), approval.choices)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testParseApprovalRequest_allowPermanentFalseStillParses() {
|
||||||
|
val response =
|
||||||
|
createJsonRpcResponse(
|
||||||
|
jsonrpc = "2.0",
|
||||||
|
id = null,
|
||||||
|
result = null,
|
||||||
|
error = null,
|
||||||
|
method = "event",
|
||||||
|
params =
|
||||||
|
mapOf(
|
||||||
|
"type" to "approval.request",
|
||||||
|
"payload" to
|
||||||
|
mapOf(
|
||||||
|
"command" to "rm",
|
||||||
|
"choices" to listOf("once", "session", "always", "deny"),
|
||||||
|
"allow_permanent" to false,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val event = EventParser.parse(response)
|
||||||
|
assertTrue(event is WsEvent.ApprovalRequest)
|
||||||
|
val approval = event as WsEvent.ApprovalRequest
|
||||||
|
assertEquals(false, approval.allowPermanent)
|
||||||
|
}
|
||||||
|
|
||||||
// ── TEST-07: Untested subtypes ─────────────────────────────────────
|
// ── TEST-07: Untested subtypes ─────────────────────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -3035,6 +3035,167 @@ class ChatViewModelTest {
|
|||||||
assertNull(msgAfter!!.approvalInfo)
|
assertNull(msgAfter!!.approvalInfo)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testApprovalRequest_storesRequestIdAndChoices() =
|
||||||
|
runTest {
|
||||||
|
val viewModel = createViewModel()
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
mockEventsFlow.emit(
|
||||||
|
WsEvent.ApprovalRequest(
|
||||||
|
command = "rm -rf /tmp/x",
|
||||||
|
description = "dangerous command",
|
||||||
|
patternKeys = listOf("shell:rm"),
|
||||||
|
sessionId = null,
|
||||||
|
requestId = "req-1",
|
||||||
|
choices = listOf("once", "session", "always", "deny"),
|
||||||
|
allowPermanent = true,
|
||||||
|
smartDenied = false,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
val msg =
|
||||||
|
viewModel.uiState.value.messages
|
||||||
|
.first { it.content.contains("Approval Required") }
|
||||||
|
assertEquals("req-1", msg.approvalInfo?.requestId)
|
||||||
|
assertEquals(
|
||||||
|
listOf("once", "session", "always", "deny"),
|
||||||
|
msg.approvalInfo?.choices,
|
||||||
|
)
|
||||||
|
assertEquals(true, msg.approvalInfo?.allowPermanent)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testRespondToApproval_mapsApproveToOnceAndSendsRequestId() =
|
||||||
|
runTest {
|
||||||
|
val (viewModel, sessionId) = createViewModelWithSession()
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
mockEventsFlow.emit(
|
||||||
|
WsEvent.ApprovalRequest(
|
||||||
|
command = "rm",
|
||||||
|
description = "Dangerous",
|
||||||
|
patternKeys = null,
|
||||||
|
sessionId = null,
|
||||||
|
requestId = "req-42",
|
||||||
|
choices = listOf("once", "deny"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
viewModel.respondToApproval("approve")
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
verify {
|
||||||
|
HermesWsClient.send(
|
||||||
|
WsMethods.APPROVAL_RESPOND,
|
||||||
|
withArg { params ->
|
||||||
|
assertEquals(sessionId, params["session_id"])
|
||||||
|
assertEquals("once", params["choice"])
|
||||||
|
assertEquals("req-42", params["request_id"])
|
||||||
|
},
|
||||||
|
any(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testRespondToApproval_sessionChoice() =
|
||||||
|
runTest {
|
||||||
|
val (viewModel, sessionId) = createViewModelWithSession()
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
mockEventsFlow.emit(
|
||||||
|
WsEvent.ApprovalRequest(
|
||||||
|
command = "rm",
|
||||||
|
description = "Dangerous",
|
||||||
|
patternKeys = null,
|
||||||
|
sessionId = null,
|
||||||
|
requestId = "req-7",
|
||||||
|
choices = listOf("once", "session", "always", "deny"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
viewModel.respondToApproval("session")
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
verify {
|
||||||
|
HermesWsClient.send(
|
||||||
|
WsMethods.APPROVAL_RESPOND,
|
||||||
|
withArg { params ->
|
||||||
|
assertEquals("session", params["choice"])
|
||||||
|
assertEquals("req-7", params["request_id"])
|
||||||
|
},
|
||||||
|
any(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testRespondToApproval_omitsRequestIdWhenAbsent() =
|
||||||
|
runTest {
|
||||||
|
val (viewModel, sessionId) = createViewModelWithSession()
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
mockEventsFlow.emit(
|
||||||
|
WsEvent.ApprovalRequest(
|
||||||
|
command = "ls",
|
||||||
|
description = "Safe",
|
||||||
|
patternKeys = null,
|
||||||
|
sessionId = null,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
viewModel.respondToApproval("deny")
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
verify {
|
||||||
|
HermesWsClient.send(
|
||||||
|
WsMethods.APPROVAL_RESPOND,
|
||||||
|
withArg { params ->
|
||||||
|
assertEquals("deny", params["choice"])
|
||||||
|
assertFalse(params.containsKey("request_id"))
|
||||||
|
},
|
||||||
|
any(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testRespondToApproval_triggersPendingReplay() =
|
||||||
|
runTest {
|
||||||
|
val (viewModel, sessionId) = createViewModelWithSession()
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
mockEventsFlow.emit(
|
||||||
|
WsEvent.ApprovalRequest(
|
||||||
|
command = "rm",
|
||||||
|
description = "Dangerous",
|
||||||
|
patternKeys = null,
|
||||||
|
sessionId = null,
|
||||||
|
requestId = "req-9",
|
||||||
|
choices = listOf("once", "deny"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
viewModel.respondToApproval("once")
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
verify {
|
||||||
|
HermesWsClient.send(
|
||||||
|
WsMethods.APPROVAL_PENDING,
|
||||||
|
withArg { params ->
|
||||||
|
assertEquals(sessionId, params["session_id"])
|
||||||
|
},
|
||||||
|
any(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Sudo / secret prompt flow (issue #524) ───────────────────────────
|
// ── Sudo / secret prompt flow (issue #524) ───────────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
Reference in new issue
Block a user