use bamboo_agent_core::Session;
use chrono::Utc;
use serde::{Deserialize, Serialize};
pub const APPROVAL_DELEGATION_METADATA_KEY: &str = "approval.delegation.state";
const MAX_PENDING: usize = 64;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PendingChildApproval {
pub child_session_id: String,
pub child_tool_call_id: String,
pub tool_name: String,
pub permission_type: String,
pub resource: String,
pub parent_pending_question_id: String,
pub registered_at: String,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ApprovalDelegationState {
#[serde(default)]
pub pending: Vec<PendingChildApproval>,
}
impl ApprovalDelegationState {
pub fn upsert(&mut self, request: PendingChildApproval) {
self.pending
.retain(|p| p.parent_pending_question_id != request.parent_pending_question_id);
self.pending.push(request);
if self.pending.len() > MAX_PENDING {
let overflow = self.pending.len() - MAX_PENDING;
self.pending.drain(0..overflow);
}
}
pub fn take_by_question(&mut self, question_id: &str) -> Option<PendingChildApproval> {
let idx = self
.pending
.iter()
.position(|p| p.parent_pending_question_id == question_id)?;
Some(self.pending.remove(idx))
}
pub fn take_by_child(&mut self, child_session_id: &str) -> Option<PendingChildApproval> {
let idx = self
.pending
.iter()
.position(|p| p.child_session_id == child_session_id)?;
Some(self.pending.remove(idx))
}
pub fn is_empty(&self) -> bool {
self.pending.is_empty()
}
}
pub fn read_approval_delegation_state(session: &Session) -> Option<ApprovalDelegationState> {
let raw = session.metadata.get(APPROVAL_DELEGATION_METADATA_KEY)?;
serde_json::from_str::<ApprovalDelegationState>(raw).ok()
}
pub fn ensure_approval_delegation_state(session: &Session) -> ApprovalDelegationState {
read_approval_delegation_state(session).unwrap_or_default()
}
pub fn write_approval_delegation_state(session: &mut Session, state: ApprovalDelegationState) {
if state.is_empty() {
session.metadata.remove(APPROVAL_DELEGATION_METADATA_KEY);
return;
}
match serde_json::to_string(&state) {
Ok(json) => {
session
.metadata
.insert(APPROVAL_DELEGATION_METADATA_KEY.to_string(), json);
}
Err(error) => {
tracing::warn!(
"failed to serialize approval delegation state for session {}: {error}",
session.id
);
}
}
}
pub fn new_pending_child_approval(
child_session_id: impl Into<String>,
child_tool_call_id: impl Into<String>,
tool_name: impl Into<String>,
permission_type: impl Into<String>,
resource: impl Into<String>,
parent_pending_question_id: impl Into<String>,
) -> PendingChildApproval {
PendingChildApproval {
child_session_id: child_session_id.into(),
child_tool_call_id: child_tool_call_id.into(),
tool_name: tool_name.into(),
permission_type: permission_type.into(),
resource: resource.into(),
parent_pending_question_id: parent_pending_question_id.into(),
registered_at: Utc::now().to_rfc3339(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use bamboo_agent_core::Session;
fn req(child: &str, question: &str) -> PendingChildApproval {
new_pending_child_approval(child, "tc-1", "Write", "WriteFile", "/tmp/x", question)
}
#[test]
fn round_trips_through_parent_metadata() {
let mut parent = Session::new("parent", "model");
let mut state = ApprovalDelegationState::default();
state.upsert(req("child-1", "q-1"));
state.upsert(req("child-2", "q-2"));
write_approval_delegation_state(&mut parent, state);
let loaded = read_approval_delegation_state(&parent).expect("persists");
assert_eq!(loaded.pending.len(), 2);
assert_eq!(loaded.pending[0].child_session_id, "child-1");
assert_eq!(loaded.pending[1].parent_pending_question_id, "q-2");
}
#[test]
fn upsert_dedupes_on_question_id() {
let mut state = ApprovalDelegationState::default();
state.upsert(req("child-1", "q-1"));
state.upsert(req("child-1b", "q-1")); assert_eq!(state.pending.len(), 1);
assert_eq!(state.pending[0].child_session_id, "child-1b");
}
#[test]
fn take_by_question_and_child() {
let mut state = ApprovalDelegationState::default();
state.upsert(req("child-1", "q-1"));
state.upsert(req("child-2", "q-2"));
let taken = state.take_by_question("q-1").expect("found by question");
assert_eq!(taken.child_session_id, "child-1");
assert_eq!(state.pending.len(), 1);
let taken2 = state.take_by_child("child-2").expect("found by child");
assert_eq!(taken2.parent_pending_question_id, "q-2");
assert!(state.pending.is_empty());
}
#[test]
fn empty_state_is_removed_from_metadata() {
let mut parent = Session::new("parent", "model");
let mut state = ApprovalDelegationState::default();
state.upsert(req("child-1", "q-1"));
write_approval_delegation_state(&mut parent, state);
assert!(parent
.metadata
.contains_key(APPROVAL_DELEGATION_METADATA_KEY));
let mut state = ensure_approval_delegation_state(&parent);
state.take_by_question("q-1");
write_approval_delegation_state(&mut parent, state);
assert!(!parent
.metadata
.contains_key(APPROVAL_DELEGATION_METADATA_KEY));
}
}