use crate::bridge::HostBridge;
use crate::llm::acp_permission::{
parse_response, request_params, WireOutcome, METHOD_REQUEST_PERMISSION,
};
use super::{ApprovalBatch, PreparedSessionApprovalDecision};
pub async fn request_session_approval(
bridge: &HostBridge,
session_id: &str,
batch: &ApprovalBatch,
) -> Result<PreparedSessionApprovalDecision, String> {
if session_id.is_empty() || bridge.get_session_id() != session_id {
return Err("prepared approval bridge does not match the attached session".to_string());
}
let raw_input = serde_json::to_value(batch).map_err(|error| error.to_string())?;
let summary = batch
.groups
.iter()
.flat_map(|group| group.summaries.iter())
.cloned()
.collect::<Vec<_>>()
.join("\n");
let params = request_params(
Some(session_id),
&batch.batch_fingerprint,
"prepared_session.start",
&raw_input,
serde_json::json!({"summary": summary, "risk": "prepared_run", "approval_batch": batch}),
&serde_json::json!({"action": "ask", "reason": "prepared session requires grouped approval"}),
None,
None,
);
let response = bridge
.call(METHOD_REQUEST_PERMISSION, params)
.await
.map_err(|error| error.to_string())?;
let (approved, resolution) = match parse_response(&response) {
WireOutcome::Allowed { resolution } => (true, resolution),
WireOutcome::Rejected { resolution, .. } => (false, resolution),
};
Ok(PreparedSessionApprovalDecision {
request_id: batch.request_id,
batch_fingerprint: batch.batch_fingerprint.clone(),
approved,
decider: resolution.decider,
})
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::Arc;
fn responding_bridge(
response: serde_json::Value,
calls: Arc<AtomicUsize>,
expected_batch: ApprovalBatch,
) -> HostBridge {
let pending = Arc::new(tokio::sync::Mutex::new(HashMap::<
u64,
tokio::sync::oneshot::Sender<serde_json::Value>,
>::new()));
let responses = pending.clone();
let writer = Arc::new(move |line: &str| {
let request: serde_json::Value = serde_json::from_str(line).unwrap();
assert_eq!(request["method"], METHOD_REQUEST_PERMISSION);
assert_eq!(request["params"]["sessionId"], "session-1");
assert_eq!(request["params"]["toolCall"]["kind"], "other");
assert_eq!(
request["params"]["toolCall"]["rawInput"],
serde_json::to_value(&expected_batch).unwrap()
);
assert!(request["params"]["toolCall"]["_meta"]["harn"]
.get("toolAnnotations")
.is_none());
assert_eq!(request["params"]["options"][0]["optionId"], "allow");
calls.fetch_add(1, Ordering::SeqCst);
let id = request["id"].as_u64().unwrap();
responses
.try_lock()
.unwrap()
.remove(&id)
.unwrap()
.send(serde_json::json!({"id": id, "result": response.clone()}))
.map_err(|_| "approval response receiver closed".to_string())
});
let bridge = HostBridge::from_parts_with_writer(
pending,
Arc::new(AtomicBool::new(false)),
writer,
1,
);
bridge.set_session_id("session-1");
bridge
}
fn batch() -> ApprovalBatch {
ApprovalBatch {
request_id: uuid::Uuid::new_v4(),
batch_fingerprint: "batch-1".to_string(),
plan_fingerprint: "plan-1".to_string(),
groups: vec![super::super::ApprovalGroup {
semantic_group: "workspace".to_string(),
requirement_fingerprints: vec!["requirement-1".to_string()],
summaries: vec!["Write the declared workspace".to_string()],
risk_labels: vec!["workspace".to_string()],
}],
}
}
#[tokio::test]
async fn grouped_approval_uses_canonical_allowed_denied_and_malformed_answers() {
for (response, approved) in [
(
serde_json::json!({"outcome":{"outcome":"selected","optionId":"allow"}}),
true,
),
(
serde_json::json!({"outcome":{"outcome":"selected","optionId":"reject"}}),
false,
),
(
serde_json::json!({"outcome":{"outcome":"cancelled"}}),
false,
),
(
serde_json::json!({
"outcome":{"outcome":"selected","optionId":"allow"},
"_meta":{"harn":{"permissionDecision":{}}}
}),
false,
),
(serde_json::json!({}), false),
(serde_json::json!({"outcome":"approved"}), false),
(
serde_json::json!({"outcome":{"outcome":"selected","optionId":"unknown"}}),
false,
),
] {
let calls = Arc::new(AtomicUsize::new(0));
let request = batch();
let bridge = responding_bridge(response, calls.clone(), request.clone());
let decision = request_session_approval(&bridge, "session-1", &request)
.await
.unwrap();
assert_eq!(decision.approved, approved);
assert_eq!(decision.request_id, request.request_id);
assert_eq!(decision.batch_fingerprint, "batch-1");
assert_eq!(calls.load(Ordering::SeqCst), 1);
}
}
#[tokio::test]
async fn another_session_cannot_use_the_approval_bridge() {
let calls = Arc::new(AtomicUsize::new(0));
let request = batch();
let bridge = responding_bridge(serde_json::json!({}), calls.clone(), request.clone());
for session_id in ["other-session", ""] {
assert!(request_session_approval(&bridge, session_id, &request)
.await
.is_err());
}
assert_eq!(calls.load(Ordering::SeqCst), 0);
}
}