Skip to main content

a3s_code_core/
hitl.rs

1//! Human-in-the-Loop (HITL) confirmation mechanism
2//!
3//! Provides the runtime confirmation flow for tool execution. Works with
4//! `PermissionPolicy` (permissions.rs) which decides Allow/Deny/Ask.
5//! When the permission decision is `Ask`, this module handles:
6//! - Interactive confirmation request/response flow
7//! - Timeout handling with configurable actions
8//! - YOLO mode for lane-based auto-approval (skips confirmation for entire lanes)
9
10use crate::agent::AgentEvent;
11use serde::{Deserialize, Serialize};
12use std::collections::{HashMap, HashSet};
13use std::sync::Arc;
14use std::time::{Duration, Instant};
15use tokio::sync::{broadcast, oneshot, RwLock};
16
17// Re-export SessionLane for backward compatibility (canonical home: queue.rs)
18pub use crate::queue::SessionLane;
19
20/// Action to take when confirmation times out
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
22pub enum TimeoutAction {
23    /// Reject the tool execution on timeout
24    #[default]
25    Reject,
26    /// Auto-approve the tool execution on timeout
27    AutoApprove,
28}
29
30/// Confirmation policy configuration
31///
32/// Controls the runtime behavior of HITL confirmation flow.
33/// The *decision* of whether to ask is made by `PermissionPolicy` (permissions.rs).
34/// This policy controls *how* the confirmation works: timeouts, YOLO lanes.
35#[derive(Debug, Clone, Serialize, Deserialize)]
36pub struct ConfirmationPolicy {
37    /// Whether HITL is enabled (default: false, all tools auto-approved)
38    pub enabled: bool,
39
40    /// Default timeout in milliseconds (default: 30000 = 30s)
41    pub default_timeout_ms: u64,
42
43    /// Action to take on timeout (default: Reject)
44    pub timeout_action: TimeoutAction,
45
46    /// YOLO mode: lanes that auto-approve without confirmation.
47    /// When a lane is in this set, tools in that lane skip confirmation
48    /// even if `PermissionPolicy` returns `Ask`.
49    pub yolo_lanes: HashSet<SessionLane>,
50}
51
52impl Default for ConfirmationPolicy {
53    fn default() -> Self {
54        Self {
55            enabled: false,             // HITL disabled by default
56            default_timeout_ms: 30_000, // 30 seconds
57            timeout_action: TimeoutAction::Reject,
58            yolo_lanes: HashSet::new(), // No YOLO lanes by default
59        }
60    }
61}
62
63impl ConfirmationPolicy {
64    /// Create a new policy with HITL enabled
65    pub fn enabled() -> Self {
66        Self {
67            enabled: true,
68            ..Default::default()
69        }
70    }
71
72    /// Enable YOLO mode for specific lanes
73    pub fn with_yolo_lanes(mut self, lanes: impl IntoIterator<Item = SessionLane>) -> Self {
74        self.yolo_lanes = lanes.into_iter().collect();
75        self
76    }
77
78    /// Set timeout
79    pub fn with_timeout(mut self, timeout_ms: u64, action: TimeoutAction) -> Self {
80        self.default_timeout_ms = timeout_ms;
81        self.timeout_action = action;
82        self
83    }
84
85    /// Check if a tool should skip confirmation (YOLO lane check)
86    ///
87    /// Returns true if the tool's lane is in YOLO mode, meaning it should
88    /// be auto-approved even when `PermissionPolicy` returns `Ask`.
89    pub fn is_yolo(&self, tool_name: &str) -> bool {
90        if !self.enabled {
91            return true; // HITL disabled = everything auto-approved
92        }
93        let lane = SessionLane::from_tool_name(tool_name);
94        self.yolo_lanes.contains(&lane)
95    }
96
97    /// Check if a tool requires confirmation
98    ///
99    /// This is the inverse of `is_yolo()` — returns true when HITL is enabled
100    /// and the tool's lane is NOT in YOLO mode.
101    pub fn requires_confirmation(&self, tool_name: &str) -> bool {
102        !self.is_yolo(tool_name)
103    }
104}
105
106/// Confirmation response from user
107#[derive(Debug, Clone, Serialize, Deserialize)]
108pub struct ConfirmationResponse {
109    /// Whether the tool execution was approved
110    pub approved: bool,
111    /// Optional reason for rejection
112    pub reason: Option<String>,
113}
114
115/// Trait for confirmation providers (HITL runtime behavior)
116///
117/// This trait abstracts the confirmation flow, allowing different implementations
118/// (e.g., interactive, auto-approve, test mocks) while keeping the agent logic clean.
119#[async_trait::async_trait]
120pub trait ConfirmationProvider: Send + Sync {
121    /// Check if a tool requires confirmation
122    async fn requires_confirmation(&self, tool_name: &str) -> bool;
123
124    /// Request confirmation for a tool execution
125    ///
126    /// Returns a receiver that will receive the confirmation response.
127    async fn request_confirmation(
128        &self,
129        tool_id: &str,
130        tool_name: &str,
131        args: &serde_json::Value,
132    ) -> oneshot::Receiver<ConfirmationResponse>;
133
134    /// Handle a confirmation response from the user
135    ///
136    /// Returns Ok(true) if the confirmation was found and processed,
137    /// Ok(false) if no pending confirmation was found.
138    async fn confirm(
139        &self,
140        tool_id: &str,
141        approved: bool,
142        reason: Option<String>,
143    ) -> Result<bool, String>;
144
145    /// Get the current policy
146    async fn policy(&self) -> ConfirmationPolicy;
147
148    /// Update the confirmation policy
149    async fn set_policy(&self, policy: ConfirmationPolicy);
150
151    /// Check for and handle timed out confirmations
152    async fn check_timeouts(&self) -> usize;
153
154    /// Cancel all pending confirmations
155    async fn cancel_all(&self) -> usize;
156}
157
158/// A pending confirmation request
159pub struct PendingConfirmation {
160    /// Tool call ID
161    pub tool_id: String,
162    /// Tool name
163    pub tool_name: String,
164    /// Tool arguments
165    pub args: serde_json::Value,
166    /// When the confirmation was requested
167    pub created_at: Instant,
168    /// Timeout in milliseconds
169    pub timeout_ms: u64,
170    /// Channel to send the response
171    response_tx: oneshot::Sender<ConfirmationResponse>,
172}
173
174impl PendingConfirmation {
175    /// Check if this confirmation has timed out
176    pub fn is_timed_out(&self) -> bool {
177        self.created_at.elapsed() > Duration::from_millis(self.timeout_ms)
178    }
179
180    /// Get remaining time until timeout in milliseconds
181    pub fn remaining_ms(&self) -> u64 {
182        let elapsed = self.created_at.elapsed().as_millis() as u64;
183        self.timeout_ms.saturating_sub(elapsed)
184    }
185}
186
187/// Manages confirmation requests for a session
188pub struct ConfirmationManager {
189    /// Confirmation policy
190    policy: RwLock<ConfirmationPolicy>,
191    /// Pending confirmations by tool_id
192    pending: Arc<RwLock<HashMap<String, PendingConfirmation>>>,
193    /// Event broadcaster
194    event_tx: broadcast::Sender<AgentEvent>,
195}
196
197impl ConfirmationManager {
198    /// Create a new confirmation manager
199    pub fn new(policy: ConfirmationPolicy, event_tx: broadcast::Sender<AgentEvent>) -> Self {
200        Self {
201            policy: RwLock::new(policy),
202            pending: Arc::new(RwLock::new(HashMap::new())),
203            event_tx,
204        }
205    }
206
207    /// Get the current policy
208    pub async fn policy(&self) -> ConfirmationPolicy {
209        self.policy.read().await.clone()
210    }
211
212    /// Update the confirmation policy
213    pub async fn set_policy(&self, policy: ConfirmationPolicy) {
214        *self.policy.write().await = policy;
215    }
216
217    /// Check if a tool requires confirmation
218    pub async fn requires_confirmation(&self, tool_name: &str) -> bool {
219        self.policy.read().await.requires_confirmation(tool_name)
220    }
221
222    /// Request confirmation for a tool execution
223    ///
224    /// Returns a receiver that will receive the confirmation response.
225    /// Emits a ConfirmationRequired event.
226    pub async fn request_confirmation(
227        &self,
228        tool_id: &str,
229        tool_name: &str,
230        args: &serde_json::Value,
231    ) -> oneshot::Receiver<ConfirmationResponse> {
232        let (tx, rx) = oneshot::channel();
233
234        let policy = self.policy.read().await;
235        let timeout_ms = policy.default_timeout_ms;
236        drop(policy);
237
238        let pending = PendingConfirmation {
239            tool_id: tool_id.to_string(),
240            tool_name: tool_name.to_string(),
241            args: args.clone(),
242            created_at: Instant::now(),
243            timeout_ms,
244            response_tx: tx,
245        };
246
247        // Store the pending confirmation
248        {
249            let mut pending_map = self.pending.write().await;
250            pending_map.insert(tool_id.to_string(), pending);
251        }
252
253        // Emit confirmation required event
254        let _ = self.event_tx.send(AgentEvent::ConfirmationRequired {
255            tool_id: tool_id.to_string(),
256            tool_name: tool_name.to_string(),
257            args: args.clone(),
258            timeout_ms,
259        });
260
261        rx
262    }
263
264    /// Handle a confirmation response from the user
265    ///
266    /// Returns Ok(true) if the confirmation was found and processed,
267    /// Ok(false) if no pending confirmation was found.
268    pub async fn confirm(
269        &self,
270        tool_id: &str,
271        approved: bool,
272        reason: Option<String>,
273    ) -> Result<bool, String> {
274        let pending = {
275            let mut pending_map = self.pending.write().await;
276            pending_map.remove(tool_id)
277        };
278
279        if let Some(confirmation) = pending {
280            // Emit confirmation received event
281            let _ = self.event_tx.send(AgentEvent::ConfirmationReceived {
282                tool_id: tool_id.to_string(),
283                approved,
284                reason: reason.clone(),
285            });
286
287            // Send the response
288            let response = ConfirmationResponse { approved, reason };
289            let _ = confirmation.response_tx.send(response);
290
291            Ok(true)
292        } else {
293            Ok(false)
294        }
295    }
296
297    /// Check for and handle timed out confirmations
298    ///
299    /// Returns the number of confirmations that timed out.
300    pub async fn check_timeouts(&self) -> usize {
301        let policy = self.policy.read().await;
302        let timeout_action = policy.timeout_action;
303        drop(policy);
304
305        let mut timed_out = Vec::new();
306
307        // Find timed out confirmations
308        {
309            let pending_map = self.pending.read().await;
310            for (tool_id, pending) in pending_map.iter() {
311                if pending.is_timed_out() {
312                    timed_out.push(tool_id.clone());
313                }
314            }
315        }
316
317        // Handle timed out confirmations
318        for tool_id in &timed_out {
319            let pending = {
320                let mut pending_map = self.pending.write().await;
321                pending_map.remove(tool_id)
322            };
323
324            if let Some(confirmation) = pending {
325                let (approved, action_taken) = match timeout_action {
326                    TimeoutAction::Reject => (false, "rejected"),
327                    TimeoutAction::AutoApprove => (true, "auto_approved"),
328                };
329
330                // Emit timeout event
331                let _ = self.event_tx.send(AgentEvent::ConfirmationTimeout {
332                    tool_id: tool_id.clone(),
333                    action_taken: action_taken.to_string(),
334                });
335
336                // Send the response
337                let response = ConfirmationResponse {
338                    approved,
339                    reason: Some(format!("Confirmation timed out, action: {}", action_taken)),
340                };
341                let _ = confirmation.response_tx.send(response);
342            }
343        }
344
345        timed_out.len()
346    }
347
348    /// Get the number of pending confirmations
349    pub async fn pending_count(&self) -> usize {
350        self.pending.read().await.len()
351    }
352
353    /// Get pending confirmation details (for debugging/status)
354    pub async fn pending_confirmations(&self) -> Vec<(String, String, u64)> {
355        let pending_map = self.pending.read().await;
356        pending_map
357            .values()
358            .map(|p| (p.tool_id.clone(), p.tool_name.clone(), p.remaining_ms()))
359            .collect()
360    }
361
362    /// Cancel a pending confirmation
363    pub async fn cancel(&self, tool_id: &str) -> bool {
364        let pending = {
365            let mut pending_map = self.pending.write().await;
366            pending_map.remove(tool_id)
367        };
368
369        if let Some(confirmation) = pending {
370            let response = ConfirmationResponse {
371                approved: false,
372                reason: Some("Confirmation cancelled".to_string()),
373            };
374            let _ = confirmation.response_tx.send(response);
375            true
376        } else {
377            false
378        }
379    }
380
381    /// Cancel all pending confirmations
382    pub async fn cancel_all(&self) -> usize {
383        let pending_list: Vec<_> = {
384            let mut pending_map = self.pending.write().await;
385            pending_map.drain().collect()
386        };
387
388        let count = pending_list.len();
389
390        for (_, confirmation) in pending_list {
391            let response = ConfirmationResponse {
392                approved: false,
393                reason: Some("Confirmation cancelled".to_string()),
394            };
395            let _ = confirmation.response_tx.send(response);
396        }
397
398        count
399    }
400}
401
402// Implement ConfirmationProvider trait for ConfirmationManager
403#[async_trait::async_trait]
404impl ConfirmationProvider for ConfirmationManager {
405    async fn requires_confirmation(&self, tool_name: &str) -> bool {
406        self.requires_confirmation(tool_name).await
407    }
408
409    async fn request_confirmation(
410        &self,
411        tool_id: &str,
412        tool_name: &str,
413        args: &serde_json::Value,
414    ) -> oneshot::Receiver<ConfirmationResponse> {
415        self.request_confirmation(tool_id, tool_name, args).await
416    }
417
418    async fn confirm(
419        &self,
420        tool_id: &str,
421        approved: bool,
422        reason: Option<String>,
423    ) -> Result<bool, String> {
424        self.confirm(tool_id, approved, reason).await
425    }
426
427    async fn policy(&self) -> ConfirmationPolicy {
428        self.policy().await
429    }
430
431    async fn set_policy(&self, policy: ConfirmationPolicy) {
432        self.set_policy(policy).await
433    }
434
435    async fn check_timeouts(&self) -> usize {
436        self.check_timeouts().await
437    }
438
439    async fn cancel_all(&self) -> usize {
440        self.cancel_all().await
441    }
442}
443
444#[cfg(test)]
445mod tests {
446    use super::*;
447
448    // ========================================================================
449    // SessionLane Tests
450    // ========================================================================
451
452    #[test]
453    fn test_session_lane() {
454        assert_eq!(SessionLane::from_tool_name("read"), SessionLane::Query);
455        assert_eq!(SessionLane::from_tool_name("grep"), SessionLane::Query);
456        assert_eq!(SessionLane::from_tool_name("bash"), SessionLane::Execute);
457        assert_eq!(SessionLane::from_tool_name("write"), SessionLane::Execute);
458    }
459
460    #[test]
461    fn test_session_lane_priority() {
462        assert_eq!(SessionLane::Control.priority(), 0);
463        assert_eq!(SessionLane::Query.priority(), 1);
464        assert_eq!(SessionLane::Execute.priority(), 2);
465        assert_eq!(SessionLane::Generate.priority(), 3);
466
467        // Control has highest priority (lowest number)
468        assert!(SessionLane::Control.priority() < SessionLane::Query.priority());
469        assert!(SessionLane::Query.priority() < SessionLane::Execute.priority());
470        assert!(SessionLane::Execute.priority() < SessionLane::Generate.priority());
471    }
472
473    #[test]
474    fn test_session_lane_all_query() {
475        let query_tools = ["read", "glob", "ls", "grep", "list_files", "search"];
476        for tool in query_tools {
477            assert_eq!(
478                SessionLane::from_tool_name(tool),
479                SessionLane::Query,
480                "Tool '{}' should be in Query lane",
481                tool
482            );
483        }
484    }
485
486    #[test]
487    fn test_session_lane_all_execute() {
488        let execute_tools = ["bash", "write", "edit", "delete", "move", "copy", "execute"];
489        for tool in execute_tools {
490            assert_eq!(
491                SessionLane::from_tool_name(tool),
492                SessionLane::Execute,
493                "Tool '{}' should be in Execute lane",
494                tool
495            );
496        }
497    }
498
499    // ========================================================================
500    // TimeoutAction Tests
501    // ========================================================================
502
503    // ========================================================================
504    // ConfirmationPolicy Tests
505    // ========================================================================
506
507    #[test]
508    fn test_confirmation_policy_default() {
509        let policy = ConfirmationPolicy::default();
510        assert!(!policy.enabled);
511        // HITL disabled = everything is YOLO (no confirmation needed)
512        assert!(!policy.requires_confirmation("bash"));
513        assert!(!policy.requires_confirmation("write"));
514        assert!(!policy.requires_confirmation("read"));
515    }
516
517    #[test]
518    fn test_confirmation_policy_enabled() {
519        let policy = ConfirmationPolicy::enabled();
520        assert!(policy.enabled);
521        // All tools require confirmation when enabled with no YOLO lanes
522        assert!(policy.requires_confirmation("bash"));
523        assert!(policy.requires_confirmation("write"));
524        assert!(policy.requires_confirmation("read"));
525        assert!(policy.requires_confirmation("grep"));
526    }
527
528    #[test]
529    fn test_confirmation_policy_yolo_mode() {
530        let policy = ConfirmationPolicy::enabled().with_yolo_lanes([SessionLane::Execute]);
531
532        assert!(!policy.requires_confirmation("bash")); // Execute lane in YOLO mode
533        assert!(!policy.requires_confirmation("write")); // Execute lane in YOLO mode
534        assert!(policy.requires_confirmation("read")); // Query lane NOT in YOLO
535    }
536
537    #[test]
538    fn test_confirmation_policy_yolo_multiple_lanes() {
539        let policy = ConfirmationPolicy::enabled()
540            .with_yolo_lanes([SessionLane::Query, SessionLane::Execute]);
541
542        // All tools in YOLO lanes should be auto-approved
543        assert!(!policy.requires_confirmation("bash")); // Execute
544        assert!(!policy.requires_confirmation("read")); // Query
545        assert!(!policy.requires_confirmation("grep")); // Query
546    }
547
548    #[test]
549    fn test_confirmation_policy_is_yolo() {
550        let policy = ConfirmationPolicy::enabled().with_yolo_lanes([SessionLane::Execute]);
551
552        assert!(policy.is_yolo("bash")); // Execute lane
553        assert!(policy.is_yolo("write")); // Execute lane
554        assert!(!policy.is_yolo("read")); // Query lane, not YOLO
555    }
556
557    #[test]
558    fn test_confirmation_policy_disabled_is_always_yolo() {
559        let policy = ConfirmationPolicy::default(); // disabled
560        assert!(policy.is_yolo("bash"));
561        assert!(policy.is_yolo("read"));
562        assert!(policy.is_yolo("unknown_tool"));
563    }
564
565    #[test]
566    fn test_confirmation_policy_with_timeout() {
567        let policy = ConfirmationPolicy::enabled().with_timeout(5000, TimeoutAction::AutoApprove);
568
569        assert_eq!(policy.default_timeout_ms, 5000);
570        assert_eq!(policy.timeout_action, TimeoutAction::AutoApprove);
571    }
572
573    // ========================================================================
574    // ConfirmationManager Basic Tests
575    // ========================================================================
576
577    #[tokio::test]
578    async fn test_confirmation_manager_no_hitl() {
579        let (event_tx, _) = broadcast::channel(100);
580        let manager = ConfirmationManager::new(ConfirmationPolicy::default(), event_tx);
581
582        assert!(!manager.requires_confirmation("bash").await);
583    }
584
585    #[tokio::test]
586    async fn test_confirmation_manager_with_hitl() {
587        let (event_tx, _) = broadcast::channel(100);
588        let manager = ConfirmationManager::new(ConfirmationPolicy::enabled(), event_tx);
589
590        // All tools require confirmation when HITL enabled with no YOLO lanes
591        assert!(manager.requires_confirmation("bash").await);
592        assert!(manager.requires_confirmation("read").await);
593    }
594
595    #[tokio::test]
596    async fn test_confirmation_manager_with_yolo() {
597        let (event_tx, _) = broadcast::channel(100);
598        let policy = ConfirmationPolicy::enabled().with_yolo_lanes([SessionLane::Query]);
599        let manager = ConfirmationManager::new(policy, event_tx);
600
601        assert!(manager.requires_confirmation("bash").await); // Execute lane, not YOLO
602        assert!(!manager.requires_confirmation("read").await); // Query lane, YOLO
603    }
604
605    #[tokio::test]
606    async fn test_confirmation_manager_policy_update() {
607        let (event_tx, _) = broadcast::channel(100);
608        let manager = ConfirmationManager::new(ConfirmationPolicy::default(), event_tx);
609
610        // Initially disabled
611        assert!(!manager.requires_confirmation("bash").await);
612
613        // Update policy to enabled
614        manager.set_policy(ConfirmationPolicy::enabled()).await;
615        assert!(manager.requires_confirmation("bash").await);
616
617        // Update policy with YOLO mode
618        manager
619            .set_policy(ConfirmationPolicy::enabled().with_yolo_lanes([SessionLane::Execute]))
620            .await;
621        assert!(!manager.requires_confirmation("bash").await);
622    }
623
624    // ========================================================================
625    // Confirmation Flow Tests
626    // ========================================================================
627
628    #[tokio::test]
629    async fn test_confirmation_flow_approve() {
630        let (event_tx, mut event_rx) = broadcast::channel(100);
631        let manager = ConfirmationManager::new(ConfirmationPolicy::enabled(), event_tx);
632
633        // Request confirmation
634        let rx = manager
635            .request_confirmation("tool-1", "bash", &serde_json::json!({"command": "ls"}))
636            .await;
637
638        // Check event was emitted
639        let event = event_rx.recv().await.unwrap();
640        match event {
641            AgentEvent::ConfirmationRequired {
642                tool_id,
643                tool_name,
644                timeout_ms,
645                ..
646            } => {
647                assert_eq!(tool_id, "tool-1");
648                assert_eq!(tool_name, "bash");
649                assert_eq!(timeout_ms, 30_000); // Default timeout
650            }
651            _ => panic!("Expected ConfirmationRequired event"),
652        }
653
654        // Approve the confirmation
655        let result = manager.confirm("tool-1", true, None).await;
656        assert!(result.is_ok());
657        assert!(result.unwrap());
658
659        // Check ConfirmationReceived event
660        let event = event_rx.recv().await.unwrap();
661        match event {
662            AgentEvent::ConfirmationReceived {
663                tool_id, approved, ..
664            } => {
665                assert_eq!(tool_id, "tool-1");
666                assert!(approved);
667            }
668            _ => panic!("Expected ConfirmationReceived event"),
669        }
670
671        // Check response
672        let response = rx.await.unwrap();
673        assert!(response.approved);
674        assert!(response.reason.is_none());
675    }
676
677    #[tokio::test]
678    async fn test_confirmation_flow_reject() {
679        let (event_tx, mut event_rx) = broadcast::channel(100);
680        let manager = ConfirmationManager::new(ConfirmationPolicy::enabled(), event_tx);
681
682        // Request confirmation
683        let rx = manager
684            .request_confirmation(
685                "tool-1",
686                "bash",
687                &serde_json::json!({"command": "rm -rf /"}),
688            )
689            .await;
690
691        // Skip ConfirmationRequired event
692        let _ = event_rx.recv().await.unwrap();
693
694        // Reject the confirmation with reason
695        let result = manager
696            .confirm("tool-1", false, Some("Dangerous command".to_string()))
697            .await;
698        assert!(result.is_ok());
699        assert!(result.unwrap());
700
701        // Check ConfirmationReceived event
702        let event = event_rx.recv().await.unwrap();
703        match event {
704            AgentEvent::ConfirmationReceived {
705                tool_id,
706                approved,
707                reason,
708            } => {
709                assert_eq!(tool_id, "tool-1");
710                assert!(!approved);
711                assert_eq!(reason, Some("Dangerous command".to_string()));
712            }
713            _ => panic!("Expected ConfirmationReceived event"),
714        }
715
716        // Check response
717        let response = rx.await.unwrap();
718        assert!(!response.approved);
719        assert_eq!(response.reason, Some("Dangerous command".to_string()));
720    }
721
722    #[tokio::test]
723    async fn test_confirmation_not_found() {
724        let (event_tx, _) = broadcast::channel(100);
725        let manager = ConfirmationManager::new(ConfirmationPolicy::enabled(), event_tx);
726
727        // Try to confirm non-existent confirmation
728        let result = manager.confirm("non-existent", true, None).await;
729        assert!(result.is_ok());
730        assert!(!result.unwrap()); // Returns false for not found
731    }
732
733    // ========================================================================
734    // Multiple Confirmations Tests
735    // ========================================================================
736
737    #[tokio::test]
738    async fn test_multiple_confirmations() {
739        let (event_tx, _) = broadcast::channel(100);
740        let manager = ConfirmationManager::new(ConfirmationPolicy::enabled(), event_tx);
741
742        // Request multiple confirmations
743        let rx1 = manager
744            .request_confirmation("tool-1", "bash", &serde_json::json!({"cmd": "1"}))
745            .await;
746        let rx2 = manager
747            .request_confirmation("tool-2", "write", &serde_json::json!({"cmd": "2"}))
748            .await;
749        let rx3 = manager
750            .request_confirmation("tool-3", "edit", &serde_json::json!({"cmd": "3"}))
751            .await;
752
753        // Check pending count
754        assert_eq!(manager.pending_count().await, 3);
755
756        // Approve tool-1
757        manager.confirm("tool-1", true, None).await.unwrap();
758        let response1 = rx1.await.unwrap();
759        assert!(response1.approved);
760
761        // Reject tool-2
762        manager.confirm("tool-2", false, None).await.unwrap();
763        let response2 = rx2.await.unwrap();
764        assert!(!response2.approved);
765
766        // Approve tool-3
767        manager.confirm("tool-3", true, None).await.unwrap();
768        let response3 = rx3.await.unwrap();
769        assert!(response3.approved);
770
771        // All confirmations processed
772        assert_eq!(manager.pending_count().await, 0);
773    }
774
775    #[tokio::test]
776    async fn test_pending_confirmations_info() {
777        let (event_tx, _) = broadcast::channel(100);
778        let manager = ConfirmationManager::new(ConfirmationPolicy::enabled(), event_tx);
779
780        // Request confirmations
781        let _rx1 = manager
782            .request_confirmation("tool-1", "bash", &serde_json::json!({}))
783            .await;
784        let _rx2 = manager
785            .request_confirmation("tool-2", "write", &serde_json::json!({}))
786            .await;
787
788        let pending = manager.pending_confirmations().await;
789        assert_eq!(pending.len(), 2);
790
791        // Check that both tools are in pending list
792        let tool_ids: Vec<&str> = pending.iter().map(|(id, _, _)| id.as_str()).collect();
793        assert!(tool_ids.contains(&"tool-1"));
794        assert!(tool_ids.contains(&"tool-2"));
795    }
796
797    // ========================================================================
798    // Cancel Tests
799    // ========================================================================
800
801    #[tokio::test]
802    async fn test_cancel_confirmation() {
803        let (event_tx, _) = broadcast::channel(100);
804        let manager = ConfirmationManager::new(ConfirmationPolicy::enabled(), event_tx);
805
806        // Request confirmation
807        let rx = manager
808            .request_confirmation("tool-1", "bash", &serde_json::json!({}))
809            .await;
810
811        assert_eq!(manager.pending_count().await, 1);
812
813        // Cancel confirmation
814        let cancelled = manager.cancel("tool-1").await;
815        assert!(cancelled);
816        assert_eq!(manager.pending_count().await, 0);
817
818        // Check response indicates cancellation
819        let response = rx.await.unwrap();
820        assert!(!response.approved);
821        assert_eq!(response.reason, Some("Confirmation cancelled".to_string()));
822    }
823
824    #[tokio::test]
825    async fn test_cancel_nonexistent() {
826        let (event_tx, _) = broadcast::channel(100);
827        let manager = ConfirmationManager::new(ConfirmationPolicy::enabled(), event_tx);
828
829        let cancelled = manager.cancel("non-existent").await;
830        assert!(!cancelled);
831    }
832
833    #[tokio::test]
834    async fn test_cancel_all() {
835        let (event_tx, _) = broadcast::channel(100);
836        let manager = ConfirmationManager::new(ConfirmationPolicy::enabled(), event_tx);
837
838        // Request multiple confirmations
839        let rx1 = manager
840            .request_confirmation("tool-1", "bash", &serde_json::json!({}))
841            .await;
842        let rx2 = manager
843            .request_confirmation("tool-2", "write", &serde_json::json!({}))
844            .await;
845        let rx3 = manager
846            .request_confirmation("tool-3", "edit", &serde_json::json!({}))
847            .await;
848
849        assert_eq!(manager.pending_count().await, 3);
850
851        // Cancel all
852        let cancelled_count = manager.cancel_all().await;
853        assert_eq!(cancelled_count, 3);
854        assert_eq!(manager.pending_count().await, 0);
855
856        // All responses should indicate cancellation
857        for rx in [rx1, rx2, rx3] {
858            let response = rx.await.unwrap();
859            assert!(!response.approved);
860            assert_eq!(response.reason, Some("Confirmation cancelled".to_string()));
861        }
862    }
863
864    // ========================================================================
865    // Timeout Tests
866    // ========================================================================
867
868    #[tokio::test]
869    async fn test_timeout_reject() {
870        let (event_tx, mut event_rx) = broadcast::channel(100);
871        let policy = ConfirmationPolicy {
872            enabled: true,
873            default_timeout_ms: 50, // Very short timeout for testing
874            timeout_action: TimeoutAction::Reject,
875            ..Default::default()
876        };
877        let manager = ConfirmationManager::new(policy, event_tx);
878
879        // Request confirmation
880        let rx = manager
881            .request_confirmation("tool-1", "bash", &serde_json::json!({}))
882            .await;
883
884        // Skip ConfirmationRequired event
885        let _ = event_rx.recv().await.unwrap();
886
887        // Wait for timeout
888        tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
889
890        // Check timeouts
891        let timed_out = manager.check_timeouts().await;
892        assert_eq!(timed_out, 1);
893
894        // Check timeout event
895        let event = event_rx.recv().await.unwrap();
896        match event {
897            AgentEvent::ConfirmationTimeout {
898                tool_id,
899                action_taken,
900            } => {
901                assert_eq!(tool_id, "tool-1");
902                assert_eq!(action_taken, "rejected");
903            }
904            _ => panic!("Expected ConfirmationTimeout event"),
905        }
906
907        // Check response indicates timeout rejection
908        let response = rx.await.unwrap();
909        assert!(!response.approved);
910        assert!(response.reason.as_ref().unwrap().contains("timed out"));
911    }
912
913    #[tokio::test]
914    async fn test_timeout_auto_approve() {
915        let (event_tx, mut event_rx) = broadcast::channel(100);
916        let policy = ConfirmationPolicy {
917            enabled: true,
918            default_timeout_ms: 50, // Very short timeout for testing
919            timeout_action: TimeoutAction::AutoApprove,
920            ..Default::default()
921        };
922        let manager = ConfirmationManager::new(policy, event_tx);
923
924        // Request confirmation
925        let rx = manager
926            .request_confirmation("tool-1", "bash", &serde_json::json!({}))
927            .await;
928
929        // Skip ConfirmationRequired event
930        let _ = event_rx.recv().await.unwrap();
931
932        // Wait for timeout
933        tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
934
935        // Check timeouts
936        let timed_out = manager.check_timeouts().await;
937        assert_eq!(timed_out, 1);
938
939        // Check timeout event
940        let event = event_rx.recv().await.unwrap();
941        match event {
942            AgentEvent::ConfirmationTimeout {
943                tool_id,
944                action_taken,
945            } => {
946                assert_eq!(tool_id, "tool-1");
947                assert_eq!(action_taken, "auto_approved");
948            }
949            _ => panic!("Expected ConfirmationTimeout event"),
950        }
951
952        // Check response indicates timeout auto-approval
953        let response = rx.await.unwrap();
954        assert!(response.approved);
955        assert!(response.reason.as_ref().unwrap().contains("auto_approved"));
956    }
957
958    #[tokio::test]
959    async fn test_no_timeout_when_confirmed() {
960        let (event_tx, _) = broadcast::channel(100);
961        let policy = ConfirmationPolicy {
962            enabled: true,
963            default_timeout_ms: 50,
964            timeout_action: TimeoutAction::Reject,
965            ..Default::default()
966        };
967        let manager = ConfirmationManager::new(policy, event_tx);
968
969        // Request confirmation
970        let rx = manager
971            .request_confirmation("tool-1", "bash", &serde_json::json!({}))
972            .await;
973
974        // Confirm immediately
975        manager.confirm("tool-1", true, None).await.unwrap();
976
977        // Wait past timeout
978        tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
979
980        // Check timeouts - should be 0 since already confirmed
981        let timed_out = manager.check_timeouts().await;
982        assert_eq!(timed_out, 0);
983
984        // Response should be approval (not timeout)
985        let response = rx.await.unwrap();
986        assert!(response.approved);
987        assert!(response.reason.is_none());
988    }
989}