use std::collections::HashMap;
use std::sync::Arc;
use agent_client_protocol::Responder;
use agent_client_protocol::schema::v1::{
PermissionOptionId, RequestPermissionOutcome, RequestPermissionRequest,
RequestPermissionResponse, SelectedPermissionOutcome,
};
use serde::Serialize;
use tokio::sync::{Mutex, broadcast};
use uuid::Uuid;
#[derive(Clone, Debug, Serialize)]
pub struct PermissionRequestEvent {
pub id: String,
pub request: serde_json::Value,
}
struct PendingEntry {
responder: Responder<RequestPermissionResponse>,
request: serde_json::Value,
}
pub struct PermissionManager {
pending: Arc<Mutex<HashMap<String, PendingEntry>>>,
request_tx: broadcast::Sender<PermissionRequestEvent>,
resolved_tx: broadcast::Sender<String>,
}
impl PermissionManager {
pub fn new() -> Self {
let (request_tx, _) = broadcast::channel(16);
let (resolved_tx, _) = broadcast::channel(16);
Self { pending: Arc::new(Mutex::new(HashMap::new())), request_tx, resolved_tx }
}
pub fn subscribe(&self) -> broadcast::Receiver<PermissionRequestEvent> {
self.request_tx.subscribe()
}
pub fn resolved_subscribe(&self) -> broadcast::Receiver<String> {
self.resolved_tx.subscribe()
}
pub async fn pending_count(&self) -> usize {
self.pending.lock().await.len()
}
pub async fn handle_request(
&self,
request: RequestPermissionRequest,
responder: Responder<RequestPermissionResponse>,
) -> Result<(), agent_client_protocol::Error> {
let id = Uuid::new_v4().to_string();
let request = serde_json::to_value(&request).unwrap_or_default();
let event = PermissionRequestEvent { id: id.clone(), request: request.clone() };
self.pending.lock().await.insert(id, PendingEntry { responder, request });
let _ = self.request_tx.send(event);
Ok(())
}
pub async fn pending_events(&self) -> Vec<PermissionRequestEvent> {
self.pending
.lock()
.await
.iter()
.map(|(id, entry)| PermissionRequestEvent {
id: id.clone(),
request: entry.request.clone(),
})
.collect()
}
pub async fn cancel_all(&self) {
let mut map = self.pending.lock().await;
for (id, entry) in map.drain() {
let _ = entry
.responder
.respond(RequestPermissionResponse::new(RequestPermissionOutcome::Cancelled));
let _ = self.resolved_tx.send(id);
}
}
pub async fn resolve(&self, id: &str, option_id: &str) -> bool {
let mut map = self.pending.lock().await;
if let Some(entry) = map.remove(id) {
let _ = entry.responder.respond(RequestPermissionResponse::new(
RequestPermissionOutcome::Selected(SelectedPermissionOutcome::new(
PermissionOptionId::new(option_id),
)),
));
let _ = self.resolved_tx.send(id.to_string());
true
} else {
false
}
}
}
const AUTO_OPTION_KIND_PRIORITY: [&str; 4] =
["allow_always", "allow_once", "reject_once", "reject_always"];
pub const PERM_NOTICE_CONTENT_MAX_CHARS: usize = 400;
pub const MAX_PERM_NOTICE_REQUESTS: usize = 5;
fn option_label(option: &serde_json::Value) -> String {
option
.get("name")
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.or_else(|| option.get("kind").and_then(|v| v.as_str()))
.unwrap_or("?")
.to_string()
}
pub fn pick_auto_option(request: &serde_json::Value) -> Option<(String, String)> {
let options = request.get("options")?.as_array()?;
let pick = |o: &serde_json::Value| -> Option<(String, String)> {
let id = o
.get("optionId")
.or_else(|| o.get("option_id"))
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())?;
Some((id.to_string(), option_label(o)))
};
for kind in AUTO_OPTION_KIND_PRIORITY {
if let Some(picked) = options
.iter()
.find(|o| o.get("kind").and_then(|k| k.as_str()) == Some(kind))
.and_then(pick)
{
return Some(picked);
}
}
options.first().and_then(pick)
}
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct PermissionRequestSummary {
pub tool: Option<String>,
pub kind: Option<String>,
pub content: Option<String>,
pub content_omitted: usize,
pub options: Vec<String>,
}
fn truncate_chars(s: &str, max_chars: usize) -> (String, usize) {
let total = s.chars().count();
if total <= max_chars {
return (s.to_string(), 0);
}
(s.chars().take(max_chars).collect(), total - max_chars)
}
fn extract_content_preview(tool_call: &serde_json::Value) -> Option<String> {
let mut parts: Vec<String> = Vec::new();
match tool_call.get("content") {
Some(serde_json::Value::String(s)) => parts.push(s.clone()),
Some(serde_json::Value::Array(items)) => {
for item in items {
match item {
serde_json::Value::String(s) => parts.push(s.clone()),
serde_json::Value::Object(map) => {
if let Some(serde_json::Value::String(t)) = map.get("text") {
parts.push(t.clone());
} else if let Some(serde_json::Value::String(p)) = map.get("path") {
parts.push(p.clone());
} else if let Some(inner) = map.get("content")
&& let Some(serde_json::Value::String(t)) = inner.get("text")
{
parts.push(t.clone());
}
}
_ => {}
}
}
}
_ => {}
}
if parts.is_empty() {
for key in ["rawInput", "raw_input"] {
match tool_call.get(key) {
Some(serde_json::Value::String(s)) => {
parts.push(s.clone());
break;
}
Some(v @ serde_json::Value::Object(_)) => {
parts.push(serde_json::to_string(v).unwrap_or_default());
break;
}
_ => {}
}
}
}
let joined = parts.join("\n");
(!joined.trim().is_empty()).then_some(joined)
}
pub fn summarize_permission_request(request: &serde_json::Value) -> PermissionRequestSummary {
let tool_call = request.get("toolCall").or_else(|| request.get("tool_call"));
let kind = tool_call
.and_then(|tc| tc.get("kind"))
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.map(str::to_string);
let tool = tool_call
.and_then(|tc| tc.get("title"))
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.map(str::to_string)
.or_else(|| kind.clone());
let (content, content_omitted) = match tool_call.and_then(extract_content_preview) {
Some(c) => {
let (text, omitted) = truncate_chars(&c, PERM_NOTICE_CONTENT_MAX_CHARS);
(Some(text), omitted)
}
None => (None, 0),
};
let options = request
.get("options")
.and_then(|o| o.as_array())
.map(|opts| opts.iter().map(option_label).collect())
.unwrap_or_default();
PermissionRequestSummary { tool, kind, content, content_omitted, options }
}
#[cfg(test)]
mod tests {
use super::*;
fn request(options: &[(&str, &str, &str)], content: serde_json::Value) -> serde_json::Value {
serde_json::json!({
"sessionId": "s1",
"toolCall": { "toolCallId": "t1", "title": "Bash", "kind": "execute", "content": content },
"options": options
.iter()
.map(|(id, kind, name)| serde_json::json!({ "optionId": id, "kind": kind, "name": name }))
.collect::<Vec<_>>(),
})
}
const STANDARD_OPTIONS: &[(&str, &str, &str)] =
&[("o1", "allow_once", "允许一次"), ("o2", "reject_once", "拒绝")];
#[test]
fn pick_auto_option_prefers_allow_always_then_allow_once() {
let req = request(
&[("o1", "allow_once", "允许一次"), ("o2", "allow_always", "总是允许")],
serde_json::json!("rm -rf build"),
);
assert_eq!(pick_auto_option(&req), Some(("o2".to_string(), "总是允许".to_string())));
let req = request(
&[("o1", "reject_once", "拒绝"), ("o2", "allow_once", "允许一次")],
serde_json::json!("rm -rf build"),
);
assert_eq!(pick_auto_option(&req), Some(("o2".to_string(), "允许一次".to_string())));
}
#[test]
fn pick_auto_option_falls_back_to_reject_then_first() {
let req = request(
&[("o1", "reject_always", "总是拒绝"), ("o2", "reject_once", "拒绝")],
serde_json::json!("x"),
);
assert_eq!(pick_auto_option(&req), Some(("o2".to_string(), "拒绝".to_string())));
let req = request(&[("o1", "other", "自定义")], serde_json::json!("x"));
assert_eq!(pick_auto_option(&req), Some(("o1".to_string(), "自定义".to_string())));
}
#[test]
fn pick_auto_option_returns_none_on_malformed_request() {
assert_eq!(pick_auto_option(&serde_json::json!({})), None);
assert_eq!(pick_auto_option(&serde_json::json!({"options": []})), None);
let req = serde_json::json!({"options": [{"kind": "allow_once"}]});
assert_eq!(pick_auto_option(&req), None);
let req = serde_json::json!({"options": [{"option_id": "o9", "kind": "allow_once"}]});
assert_eq!(pick_auto_option(&req).map(|(id, _)| id), Some("o9".to_string()));
}
#[test]
fn summarize_extracts_tool_kind_options_and_truncates_content() {
let long = "x".repeat(PERM_NOTICE_CONTENT_MAX_CHARS + 50);
let req = request(STANDARD_OPTIONS, serde_json::json!(long));
let s = summarize_permission_request(&req);
assert_eq!(s.tool.as_deref(), Some("Bash"));
assert_eq!(s.kind.as_deref(), Some("execute"));
assert_eq!(s.options, vec!["允许一次".to_string(), "拒绝".to_string()]);
let content = s.content.expect("content present");
assert_eq!(content.chars().count(), PERM_NOTICE_CONTENT_MAX_CHARS);
assert_eq!(s.content_omitted, 50);
}
#[test]
fn summarize_handles_array_content_and_raw_input_fallback() {
let req = request(
STANDARD_OPTIONS,
serde_json::json!([{"type": "content", "content": {"type": "text", "text": "git push"}}]),
);
assert_eq!(summarize_permission_request(&req).content.as_deref(), Some("git push"));
let req = serde_json::json!({
"toolCall": {"title": "Edit", "kind": "edit", "rawInput": "old -> new"},
"options": [{"optionId": "o1", "kind": "allow_once"}],
});
let s = summarize_permission_request(&req);
assert_eq!(s.content.as_deref(), Some("old -> new"));
assert_eq!(s.options, vec!["allow_once".to_string()]);
}
#[test]
fn summarize_tolerates_empty_request() {
let s = summarize_permission_request(&serde_json::json!({}));
assert_eq!(s, PermissionRequestSummary::default());
}
}