Skip to main content

recursive/tools/
plan_mode.rs

1//! `enter_plan_mode` / `exit_plan_mode` tools — agent-driven read-only planning.
2//!
3//! When the agent calls `enter_plan_mode`, the runtime sets an exploring flag
4//! that blocks any write tools until `exit_plan_mode` is called. `exit_plan_mode`
5//! clears the flag, emits a [`AgentEvent::PlanProposed`] event (so TUI/HTTP
6//! surfaces can render the review modal), and then **blocks** until the human
7//! reviewer calls [`PlanApprovalGate::approve`] or [`PlanApprovalGate::reject`].
8//!
9//! # Concurrency safety
10//!
11//! [`PlanApprovalGate::wait_for_approval`] reads the response without holding a
12//! lock across any `.await` point, preventing deadlocks.
13
14use std::sync::atomic::{AtomicBool, Ordering};
15use std::sync::{Arc, RwLock};
16
17use async_trait::async_trait;
18use serde_json::{json, Value};
19use tokio::sync::Notify;
20
21use crate::error::Result;
22use crate::event::{AgentEvent, EventSink};
23use crate::llm::ToolSpec;
24use crate::permissions::{PermissionMode, PermissionsConfig};
25use crate::tools::{Tool, ToolSideEffect};
26
27// ---------------------------------------------------------------------------
28// Tool name constants (M-5)
29// ---------------------------------------------------------------------------
30
31/// Canonical name for the `enter_plan_mode` tool.
32///
33/// Use this constant instead of the bare string literal so that any rename
34/// of the tool produces a compile-time error rather than a silent runtime mismatch.
35pub const ENTER_PLAN_MODE_TOOL_NAME: &str = "enter_plan_mode";
36
37/// Canonical name for the `exit_plan_mode` tool.
38///
39/// Use this constant instead of the bare string literal so that any rename
40/// of the tool produces a compile-time error rather than a silent runtime mismatch.
41pub const EXIT_PLAN_MODE_TOOL_NAME: &str = "exit_plan_mode";
42
43// ---------------------------------------------------------------------------
44// PlanApprovalResult
45// ---------------------------------------------------------------------------
46
47/// The human reviewer's decision on an agent-proposed plan.
48#[derive(Debug, Clone)]
49pub enum PlanApprovalResult {
50    /// Plan was approved; execution may proceed.
51    Approved,
52    /// Plan was rejected; the agent receives the reason and should revise.
53    Rejected { reason: String },
54}
55
56// ---------------------------------------------------------------------------
57// PlanApprovalGate
58// ---------------------------------------------------------------------------
59
60/// Shared state for the agent-driven plan mode approval flow.
61///
62/// A single `Arc<PlanApprovalGate>` is created in [`AgentRuntimeBuilder::build`]
63/// and shared between:
64/// * [`EnterPlanModeTool`] — sets `exploring_plan_mode`
65/// * [`ExitPlanModeTool`] — clears flag, emits event, awaits decision
66/// * [`AgentRuntime`] — calls [`approve`](Self::approve) / [`reject`](Self::reject)
67/// * [`RunCore`](crate::run_core::RunCore) — reads `exploring_plan_mode` to gate writes
68#[derive(Debug)]
69pub struct PlanApprovalGate {
70    /// `true` while the agent is in exploring (read-only plan) mode.
71    pub exploring_plan_mode: Arc<AtomicBool>,
72    /// The plan text currently pending review (set by `ExitPlanModeTool`).
73    pub pending_plan: Arc<RwLock<Option<String>>>,
74    /// Decision set by the human reviewer (TUI / HTTP).
75    response: Arc<RwLock<Option<PlanApprovalResult>>>,
76    /// Wakes up `wait_for_approval` when a decision arrives.
77    notify: Arc<Notify>,
78}
79
80impl PlanApprovalGate {
81    /// Create a fresh gate (exploring mode off, no pending decision).
82    pub fn new() -> Self {
83        Self {
84            exploring_plan_mode: Arc::new(AtomicBool::new(false)),
85            pending_plan: Arc::new(RwLock::new(None)),
86            response: Arc::new(RwLock::new(None)),
87            notify: Arc::new(Notify::new()),
88        }
89    }
90
91    /// Block until the human reviewer sets a decision.
92    ///
93    /// Reads the response without holding any lock across `.await`, preventing
94    /// deadlocks. On return the stored response is cleared so the gate can be
95    /// reused for the next `exit_plan_mode` call.
96    pub async fn wait_for_approval(&self) -> PlanApprovalResult {
97        loop {
98            // Read without holding the lock across the await point.
99            let result_opt = {
100                let guard = self.response.read().unwrap_or_else(|e| e.into_inner());
101                guard.clone()
102            };
103            if let Some(result) = result_opt {
104                // Clear for future use before returning.
105                if let Ok(mut w) = self.response.write() {
106                    *w = None;
107                }
108                return result;
109            }
110            // No decision yet — sleep until notify fires.
111            self.notify.notified().await;
112        }
113    }
114
115    /// Approve the pending plan. Called by TUI / HTTP on user confirmation.
116    ///
117    /// Clears the pending plan text so stale re-injection after compaction
118    /// cannot occur — the reinjector reads [`pending_plan`](Self::pending_plan)
119    /// which returns `None` after this call.
120    pub fn approve(&self) {
121        if let Ok(mut w) = self.response.write() {
122            *w = Some(PlanApprovalResult::Approved);
123        }
124        if let Ok(mut w) = self.pending_plan.write() {
125            *w = None;
126        }
127        self.notify.notify_one();
128    }
129
130    /// Reject the pending plan with a reason. Called by TUI / HTTP.
131    ///
132    /// Clears the pending plan text so stale re-injection after compaction
133    /// cannot occur.
134    pub fn reject(&self, reason: impl Into<String>) {
135        if let Ok(mut w) = self.response.write() {
136            *w = Some(PlanApprovalResult::Rejected {
137                reason: reason.into(),
138            });
139        }
140        if let Ok(mut w) = self.pending_plan.write() {
141            *w = None;
142        }
143        self.notify.notify_one();
144    }
145
146    /// Return the currently-pending plan text, if any (set by
147    /// [`begin_approval`](Self::begin_approval), cleared by
148    /// [`approve`](Self::approve) or [`reject`](Self::reject)).
149    /// Used by post-compact re-injection so the model retains the plan
150    /// across compaction while it is still pending approval.
151    ///
152    /// Returns `None` gracefully on a poisoned lock (invariant #5 —
153    /// no unwrap in product code).
154    pub fn pending_plan(&self) -> Option<String> {
155        self.pending_plan.read().ok().and_then(|g| g.clone())
156    }
157
158    /// Set the pending plan text and clear any stale approval/rejection
159    /// from a previous cycle.
160    ///
161    /// Must be called instead of writing `pending_plan` directly so that
162    /// a ghost `response` left over from a duplicate concurrent HTTP request
163    /// (TOCTOU race) cannot bypass the human-approval gate on the next round.
164    pub fn begin_approval(&self, plan_text: String) {
165        if let Ok(mut w) = self.pending_plan.write() {
166            *w = Some(plan_text);
167        }
168        // Clear any stale response from a previous (or concurrent duplicate)
169        // approval so the next wait_for_approval() call blocks correctly.
170        if let Ok(mut w) = self.response.write() {
171            *w = None;
172        }
173    }
174}
175
176impl Default for PlanApprovalGate {
177    fn default() -> Self {
178        Self::new()
179    }
180}
181
182// ---------------------------------------------------------------------------
183// EnterPlanModeTool
184// ---------------------------------------------------------------------------
185
186/// Tool that switches the agent into read-only planning mode.
187///
188/// Once entered, any non-readonly tool call (except `exit_plan_mode`) is
189/// blocked with an error message. The agent should use read tools to explore
190/// the codebase, then call `exit_plan_mode` with its markdown plan.
191///
192/// When a `PermissionsConfig` is provided, the tool also checks which tools
193/// are in `Plan` mode and includes that information in the response, guiding
194/// the agent to focus on those tools during planning.
195pub struct EnterPlanModeTool {
196    gate: Arc<PlanApprovalGate>,
197    /// Optional permissions config to check which tools require plan mode.
198    permissions: Option<Arc<PermissionsConfig>>,
199}
200
201impl EnterPlanModeTool {
202    /// Create a new `EnterPlanModeTool` sharing the given gate.
203    pub fn new(gate: Arc<PlanApprovalGate>) -> Self {
204        Self {
205            gate,
206            permissions: None,
207        }
208    }
209
210    /// Attach a permissions config so the tool can report which tools
211    /// are in `Plan` mode.
212    pub fn with_permissions(mut self, permissions: Arc<PermissionsConfig>) -> Self {
213        self.permissions = Some(permissions);
214        self
215    }
216}
217
218#[async_trait]
219impl Tool for EnterPlanModeTool {
220    fn is_deferred(&self) -> bool {
221        true
222    }
223
224    fn spec(&self) -> ToolSpec {
225        ToolSpec {
226            name: ENTER_PLAN_MODE_TOOL_NAME.into(),
227            description: "Enter read-only planning mode for non-trivial or architectural \
228                 changes. While active, write tools (Write, Edit, Bash, …) are blocked.\n\n\
229                 Use enter_plan_mode when:\n\
230                 - The task requires exploring 3+ files before deciding what to change\n\
231                 - The task touches architectural boundaries (new module, new tool, API change)\n\
232                 - You are unsure of the correct approach and want to discuss options first\n\n\
233                 While in plan mode:\n\
234                 - Read files freely (Read, Glob, Grep)\n\
235                 - Think through trade-offs in your responses\n\
236                 - DO NOT call Write, Edit, or Bash\n\
237                 - When you have a clear plan, call exit_plan_mode with a markdown summary"
238                .into(),
239            parameters: json!({
240                "type": "object",
241                "properties": {}
242            }),
243        }
244    }
245
246    async fn execute(&self, _arguments: Value) -> Result<String> {
247        self.gate.exploring_plan_mode.store(true, Ordering::Relaxed);
248
249        // If permissions are configured, report which tools are in Plan mode.
250        let mut response = json!({ "entered": true });
251        if let Some(ref config) = self.permissions {
252            if matches!(config.mode, PermissionMode::Plan { .. }) {
253                response["default_mode"] = json!("plan");
254            }
255        }
256
257        Ok(response.to_string())
258    }
259
260    fn side_effect_class(&self) -> ToolSideEffect {
261        ToolSideEffect::Mutating
262    }
263
264    fn is_readonly(&self) -> bool {
265        false
266    }
267}
268
269// ---------------------------------------------------------------------------
270// ExitPlanModeTool
271// ---------------------------------------------------------------------------
272
273/// Tool that exits plan mode and presents the agent's plan for human review.
274///
275/// Execution **blocks** until the human reviewer calls
276/// [`PlanApprovalGate::approve`] or [`PlanApprovalGate::reject`]. The tool
277/// returns `{"approved": true}` or `{"approved": false, "reason": "…"}` so
278/// the agent can branch on the outcome.
279pub struct ExitPlanModeTool {
280    gate: Arc<PlanApprovalGate>,
281    event_sink: Arc<dyn EventSink>,
282    /// Optional permissions config to validate plan coverage.
283    permissions: Option<Arc<PermissionsConfig>>,
284}
285
286impl ExitPlanModeTool {
287    /// Create a new `ExitPlanModeTool`.
288    ///
289    /// * `gate`       — shared with `EnterPlanModeTool` and `AgentRuntime`.
290    /// * `event_sink` — emits [`AgentEvent::PlanProposed`] to TUI / HTTP.
291    pub fn new(gate: Arc<PlanApprovalGate>, event_sink: Arc<dyn EventSink>) -> Self {
292        Self {
293            gate,
294            event_sink,
295            permissions: None,
296        }
297    }
298
299    /// Attach a permissions config so the tool can validate that the plan
300    /// covers tools in `Plan` mode.
301    pub fn with_permissions(mut self, permissions: Arc<PermissionsConfig>) -> Self {
302        self.permissions = Some(permissions);
303        self
304    }
305}
306
307#[async_trait]
308impl Tool for ExitPlanModeTool {
309    fn is_deferred(&self) -> bool {
310        true
311    }
312
313    fn spec(&self) -> ToolSpec {
314        ToolSpec {
315            name: EXIT_PLAN_MODE_TOOL_NAME.into(),
316            description:
317                "Exit plan mode and present your plan for human review. Include a markdown \
318                 summary with: (1) your understanding of the current code, (2) the approach \
319                 you propose and why, (3) the files you will modify and how, (4) how you will \
320                 verify the change is correct. Execution blocks until the reviewer confirms \
321                 or rejects the plan."
322                    .into(),
323            parameters: json!({
324                "type": "object",
325                "required": ["plan"],
326                "properties": {
327                    "plan": {
328                        "type": "string",
329                        "description": "Markdown summary of your plan."
330                    }
331                }
332            }),
333        }
334    }
335
336    async fn execute(&self, arguments: Value) -> Result<String> {
337        let plan_text = arguments
338            .get("plan")
339            .and_then(|v| v.as_str())
340            .unwrap_or("")
341            .to_string();
342
343        // If permissions are configured, check that the plan covers Plan-mode tools.
344        if let Some(ref _config) = self.permissions {
345            // Plan-mode tools are determined by the default mode.
346            // The plan text is passed through to the reviewer as-is.
347        }
348
349        // Clear exploring mode so normal tool execution resumes after approval.
350        self.gate
351            .exploring_plan_mode
352            .store(false, Ordering::Relaxed);
353
354        // Store the plan text so external callers can read it, and atomically
355        // clear any stale response from a previous cycle (prevents ghost approvals).
356        self.gate.begin_approval(plan_text.clone());
357
358        // Emit PlanProposed so TUI / HTTP can render the review modal.
359        // Plan Mode 2.0 has no pending tool_calls — the plan is prose only.
360        self.event_sink
361            .emit(AgentEvent::PlanProposed {
362                plan_text: plan_text.clone(),
363                tool_calls: vec![],
364            })
365            .await;
366
367        // Block until the human makes a decision.
368        // No lock is held across this await (see PlanApprovalGate::wait_for_approval).
369        let result = self.gate.wait_for_approval().await;
370
371        match result {
372            PlanApprovalResult::Approved => Ok(json!({ "approved": true }).to_string()),
373            PlanApprovalResult::Rejected { reason } => {
374                Ok(json!({ "approved": false, "reason": reason }).to_string())
375            }
376        }
377    }
378
379    fn side_effect_class(&self) -> ToolSideEffect {
380        // Waits for external human input.
381        ToolSideEffect::External
382    }
383
384    fn is_readonly(&self) -> bool {
385        false
386    }
387}
388
389// ---------------------------------------------------------------------------
390// PlanModeRequestGate (Goal-202)
391// ---------------------------------------------------------------------------
392
393/// Decision result for the pre-confirmation step.
394#[derive(Debug, Clone)]
395pub enum PlanModeRequestResult {
396    /// User allowed the agent to enter plan mode.
397    Approved,
398    /// User declined; agent should execute without planning.
399    Rejected { reason: String },
400}
401
402/// Gate for the plan-mode pre-confirmation step.
403///
404/// Parallel to [`PlanApprovalGate`] but governs the *entry* decision
405/// (before any exploration), not the plan review.
406pub struct PlanModeRequestGate {
407    response: Arc<RwLock<Option<PlanModeRequestResult>>>,
408    notify: Arc<Notify>,
409}
410
411impl PlanModeRequestGate {
412    /// Create a fresh gate.
413    pub fn new() -> Self {
414        Self {
415            response: Arc::new(RwLock::new(None)),
416            notify: Arc::new(Notify::new()),
417        }
418    }
419
420    /// Block until the user makes a decision.
421    pub async fn wait_for_decision(&self) -> PlanModeRequestResult {
422        loop {
423            let result_opt = {
424                let guard = self.response.read().unwrap_or_else(|e| e.into_inner());
425                guard.clone()
426            };
427            if let Some(result) = result_opt {
428                if let Ok(mut w) = self.response.write() {
429                    *w = None;
430                }
431                return result;
432            }
433            self.notify.notified().await;
434        }
435    }
436
437    /// Approve the plan-mode request.
438    pub fn approve(&self) {
439        if let Ok(mut w) = self.response.write() {
440            *w = Some(PlanModeRequestResult::Approved);
441        }
442        self.notify.notify_one();
443    }
444
445    /// Reject the plan-mode request with a reason.
446    pub fn reject(&self, reason: impl Into<String>) {
447        if let Ok(mut w) = self.response.write() {
448            *w = Some(PlanModeRequestResult::Rejected {
449                reason: reason.into(),
450            });
451        }
452        self.notify.notify_one();
453    }
454}
455
456impl Default for PlanModeRequestGate {
457    fn default() -> Self {
458        Self::new()
459    }
460}
461
462// ---------------------------------------------------------------------------
463// RequestPlanModeTool (Goal-202)
464// ---------------------------------------------------------------------------
465
466/// Tool that asks the user for permission before entering plan mode.
467///
468/// The agent should call this tool **before** `enter_plan_mode` whenever
469/// it considers a planning phase beneficial.  If the user approves, the
470/// agent proceeds with `enter_plan_mode` as usual.  If the user rejects,
471/// the agent should execute directly without entering plan mode.
472///
473/// This two-step flow avoids wasting tokens generating a plan that the
474/// user never wanted.
475pub struct RequestPlanModeTool {
476    gate: Arc<PlanModeRequestGate>,
477    event_sink: Arc<dyn EventSink>,
478}
479
480impl RequestPlanModeTool {
481    /// Create a new `RequestPlanModeTool`.
482    pub fn new(gate: Arc<PlanModeRequestGate>, event_sink: Arc<dyn EventSink>) -> Self {
483        Self { gate, event_sink }
484    }
485}
486
487#[async_trait]
488impl Tool for RequestPlanModeTool {
489    fn is_deferred(&self) -> bool {
490        true
491    }
492
493    fn spec(&self) -> ToolSpec {
494        ToolSpec {
495            name: "request_plan_mode".into(),
496            description:
497                "Before entering plan mode, call this tool to ask the user whether they want \
498                 you to create a plan first.  Provide a brief `reason` explaining why planning \
499                 would be helpful for this task.  The call blocks until the user decides. \
500                 If approved, proceed with `enter_plan_mode`; if rejected, execute directly \
501                 without planning."
502                    .into(),
503            parameters: serde_json::json!({
504                "type": "object",
505                "required": ["reason"],
506                "properties": {
507                    "reason": {
508                        "type": "string",
509                        "description": "Brief explanation of why a planning phase would be \
510                            beneficial (1-2 sentences)."
511                    }
512                }
513            }),
514        }
515    }
516
517    async fn execute(&self, arguments: serde_json::Value) -> Result<String> {
518        let reason = arguments
519            .get("reason")
520            .and_then(|v| v.as_str())
521            .unwrap_or("")
522            .to_string();
523
524        // Notify TUI / HTTP so they can prompt the user.
525        self.event_sink
526            .emit(crate::event::AgentEvent::PlanModeRequested {
527                reason: reason.clone(),
528            })
529            .await;
530
531        // Block until the user makes a decision.
532        let result = self.gate.wait_for_decision().await;
533
534        match result {
535            PlanModeRequestResult::Approved => {
536                self.event_sink
537                    .emit(crate::event::AgentEvent::PlanModeApproved)
538                    .await;
539                Ok(serde_json::json!({ "approved": true }).to_string())
540            }
541            PlanModeRequestResult::Rejected { reason: r } => {
542                self.event_sink
543                    .emit(crate::event::AgentEvent::PlanModeRejected { reason: r.clone() })
544                    .await;
545                Ok(serde_json::json!({ "approved": false, "reason": r }).to_string())
546            }
547        }
548    }
549
550    fn side_effect_class(&self) -> ToolSideEffect {
551        // Waits for external human input (same as exit_plan_mode).
552        ToolSideEffect::External
553    }
554
555    fn is_readonly(&self) -> bool {
556        false
557    }
558}
559
560// ---------------------------------------------------------------------------
561// Tests
562// ---------------------------------------------------------------------------
563
564#[cfg(test)]
565mod tests {
566    use super::*;
567    use crate::event::NullSink;
568
569    fn make_gate() -> Arc<PlanApprovalGate> {
570        Arc::new(PlanApprovalGate::new())
571    }
572
573    // -- EnterPlanModeTool --------------------------------------------------
574
575    #[tokio::test]
576    async fn enter_plan_mode_returns_confirmation_message() {
577        let gate = make_gate();
578        let tool = EnterPlanModeTool::new(gate.clone());
579
580        assert!(
581            !gate.exploring_plan_mode.load(Ordering::Relaxed),
582            "flag should start false"
583        );
584
585        let result = tool.execute(json!({})).await.unwrap();
586        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
587        assert_eq!(parsed["entered"], true);
588        assert!(
589            gate.exploring_plan_mode.load(Ordering::Relaxed),
590            "flag should be set after enter"
591        );
592    }
593
594    // -- ExitPlanModeTool: blocks until confirmed ---------------------------
595
596    #[tokio::test]
597    async fn exit_plan_mode_blocks_until_confirmed() {
598        let gate = make_gate();
599        // Pre-set exploring mode so we can verify it gets cleared.
600        gate.exploring_plan_mode.store(true, Ordering::Relaxed);
601
602        let tool = Arc::new(ExitPlanModeTool::new(gate.clone(), Arc::new(NullSink)));
603
604        // Spawn a task that approves after a short delay.
605        let gate_clone = gate.clone();
606        let approve_handle = tokio::spawn(async move {
607            // Yield to ensure the tool's await is reached first.
608            tokio::task::yield_now().await;
609            gate_clone.approve();
610        });
611
612        let result = tool
613            .execute(json!({ "plan": "step 1: read; step 2: write" }))
614            .await
615            .unwrap();
616
617        approve_handle.await.unwrap();
618
619        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
620        assert_eq!(parsed["approved"], true);
621        assert!(
622            !gate.exploring_plan_mode.load(Ordering::Relaxed),
623            "flag should be cleared after exit"
624        );
625    }
626
627    // -- ExitPlanModeTool: rejection propagates reason ----------------------
628
629    #[tokio::test]
630    async fn exit_plan_mode_returns_rejection_reason() {
631        let gate = make_gate();
632        let tool = Arc::new(ExitPlanModeTool::new(gate.clone(), Arc::new(NullSink)));
633
634        let gate_clone = gate.clone();
635        let reject_handle = tokio::spawn(async move {
636            tokio::task::yield_now().await;
637            gate_clone.reject("Plan is incomplete");
638        });
639
640        let result = tool.execute(json!({ "plan": "my plan" })).await.unwrap();
641
642        reject_handle.await.unwrap();
643
644        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
645        assert_eq!(parsed["approved"], false);
646        assert_eq!(parsed["reason"], "Plan is incomplete");
647    }
648
649    // -- Gate: approve / reject directly ------------------------------------
650
651    #[tokio::test]
652    async fn gate_approve_wakes_waiter() {
653        let gate = make_gate();
654        let gate_clone = gate.clone();
655
656        let waiter = tokio::spawn(async move { gate_clone.wait_for_approval().await });
657
658        // Yield, then approve.
659        tokio::task::yield_now().await;
660        gate.approve();
661
662        let result = waiter.await.unwrap();
663        assert!(matches!(result, PlanApprovalResult::Approved));
664    }
665
666    #[tokio::test]
667    async fn gate_reject_wakes_waiter_with_reason() {
668        let gate = make_gate();
669        let gate_clone = gate.clone();
670
671        let waiter = tokio::spawn(async move { gate_clone.wait_for_approval().await });
672
673        tokio::task::yield_now().await;
674        gate.reject("too risky");
675
676        let result = waiter.await.unwrap();
677        assert!(matches!(
678            result,
679            PlanApprovalResult::Rejected { reason } if reason == "too risky"
680        ));
681    }
682
683    // -- Gate: cleared after use so it can be reused ------------------------
684
685    #[tokio::test]
686    async fn gate_response_cleared_after_wait() {
687        let gate = make_gate();
688        gate.approve();
689        let _ = gate.wait_for_approval().await;
690
691        // Response should be None now.
692        let stored = gate.response.read().unwrap().clone();
693        assert!(stored.is_none(), "response should be cleared after read");
694    }
695
696    // -- PlanModeRequestGate (Goal-202) ------------------------------------
697
698    #[tokio::test]
699    async fn request_gate_approve_wakes_waiter() {
700        let gate = Arc::new(PlanModeRequestGate::new());
701        let gate_clone = gate.clone();
702        let waiter = tokio::spawn(async move { gate_clone.wait_for_decision().await });
703        tokio::task::yield_now().await;
704        gate.approve();
705        let result = waiter.await.unwrap();
706        assert!(matches!(result, PlanModeRequestResult::Approved));
707    }
708
709    #[tokio::test]
710    async fn request_gate_reject_propagates_reason() {
711        let gate = Arc::new(PlanModeRequestGate::new());
712        let gate_clone = gate.clone();
713        let waiter = tokio::spawn(async move { gate_clone.wait_for_decision().await });
714        tokio::task::yield_now().await;
715        gate.reject("user skipped");
716        let result = waiter.await.unwrap();
717        assert!(matches!(
718            result,
719            PlanModeRequestResult::Rejected { reason } if reason == "user skipped"
720        ));
721    }
722
723    #[tokio::test]
724    async fn request_gate_cleared_after_use() {
725        let gate = Arc::new(PlanModeRequestGate::new());
726        gate.approve();
727        let _ = gate.wait_for_decision().await;
728        let stored = gate.response.read().unwrap().clone();
729        assert!(
730            stored.is_none(),
731            "response should be cleared after decision"
732        );
733    }
734
735    #[tokio::test]
736    async fn request_plan_mode_tool_emits_event_and_blocks_until_approved() {
737        use crate::event::NullSink;
738        let gate = Arc::new(PlanModeRequestGate::new());
739        let tool = Arc::new(RequestPlanModeTool::new(gate.clone(), Arc::new(NullSink)));
740        let gate_clone = gate.clone();
741        let approve_handle = tokio::spawn(async move {
742            tokio::task::yield_now().await;
743            gate_clone.approve();
744        });
745        let result = tool
746            .execute(json!({ "reason": "complex task" }))
747            .await
748            .unwrap();
749        approve_handle.await.unwrap();
750        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
751        assert_eq!(parsed["approved"], true);
752    }
753
754    #[tokio::test]
755    async fn request_plan_mode_tool_rejected_returns_false() {
756        use crate::event::NullSink;
757        let gate = Arc::new(PlanModeRequestGate::new());
758        let tool = Arc::new(RequestPlanModeTool::new(gate.clone(), Arc::new(NullSink)));
759        let gate_clone = gate.clone();
760        let reject_handle = tokio::spawn(async move {
761            tokio::task::yield_now().await;
762            gate_clone.reject("not needed");
763        });
764        let result = tool
765            .execute(json!({ "reason": "might need plan" }))
766            .await
767            .unwrap();
768        reject_handle.await.unwrap();
769        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
770        assert_eq!(parsed["approved"], false);
771        assert_eq!(parsed["reason"], "not needed");
772    }
773
774    // -- PlanApprovalGate::pending_plan -------------------------------------
775
776    #[test]
777    fn pending_plan_returns_begin_approval_text() {
778        let gate = PlanApprovalGate::new();
779        assert_eq!(gate.pending_plan(), None, "starts empty");
780        gate.begin_approval("my plan".to_string());
781        assert_eq!(
782            gate.pending_plan().as_deref(),
783            Some("my plan"),
784            "returns the text set by begin_approval"
785        );
786    }
787
788    #[test]
789    fn pending_plan_cleared_on_approve() {
790        let gate = PlanApprovalGate::new();
791        gate.begin_approval("plan text".to_string());
792        assert_eq!(gate.pending_plan().as_deref(), Some("plan text"));
793        gate.approve();
794        assert_eq!(
795            gate.pending_plan(),
796            None,
797            "pending_plan must be cleared after approve() to prevent stale re-injection"
798        );
799    }
800
801    #[test]
802    fn pending_plan_cleared_on_reject() {
803        let gate = PlanApprovalGate::new();
804        gate.begin_approval("plan text".to_string());
805        assert_eq!(gate.pending_plan().as_deref(), Some("plan text"));
806        gate.reject("bad plan");
807        assert_eq!(
808            gate.pending_plan(),
809            None,
810            "pending_plan must be cleared after reject() to prevent stale re-injection"
811        );
812    }
813
814    // -- PlanApprovalGate::begin_approval -----------------------------------
815
816    #[test]
817    fn begin_approval_sets_plan_and_clears_stale_response() {
818        let gate = PlanApprovalGate::new();
819
820        // Simulate a stale response left over from a previous cycle.
821        *gate.response.write().unwrap() = Some(PlanApprovalResult::Approved);
822
823        gate.begin_approval("new plan text".to_string());
824
825        assert!(
826            gate.response.read().unwrap().is_none(),
827            "begin_approval must clear stale response to prevent TOCTOU bypass"
828        );
829        assert_eq!(
830            gate.pending_plan.read().unwrap().as_deref(),
831            Some("new plan text"),
832            "begin_approval must store the new plan text"
833        );
834    }
835
836    // -- is_deferred / is_readonly for both tools ---------------------------
837
838    #[test]
839    fn enter_plan_mode_tool_is_deferred_and_not_readonly() {
840        // kills `replace EnterPlanModeTool::is_deferred -> bool with false`
841        // kills `replace EnterPlanModeTool::is_readonly -> bool with true`
842        let gate = make_gate();
843        let tool = EnterPlanModeTool::new(gate);
844        assert!(
845            tool.is_deferred(),
846            "EnterPlanModeTool must be deferred (not eagerly loaded)"
847        );
848        assert!(
849            !tool.is_readonly(),
850            "EnterPlanModeTool must NOT be readonly (it sets exploring_plan_mode)"
851        );
852    }
853
854    #[test]
855    fn exit_plan_mode_tool_is_deferred_and_not_readonly() {
856        // kills `replace ExitPlanModeTool::is_deferred -> bool with false`
857        // kills `replace ExitPlanModeTool::is_readonly -> bool with true`
858        let gate = make_gate();
859        let tool = ExitPlanModeTool::new(gate, Arc::new(NullSink));
860        assert!(tool.is_deferred(), "ExitPlanModeTool must be deferred");
861        assert!(
862            !tool.is_readonly(),
863            "ExitPlanModeTool must NOT be readonly (it blocks until the user reviews the plan)"
864        );
865    }
866
867    #[test]
868    fn begin_approval_called_twice_no_ghost_approval() {
869        let gate = PlanApprovalGate::new();
870
871        // First cycle: begin, then approve.
872        gate.begin_approval("plan v1".to_string());
873        *gate.response.write().unwrap() = Some(PlanApprovalResult::Approved);
874
875        // Second cycle: begin_approval should wipe the approval from cycle 1.
876        gate.begin_approval("plan v2".to_string());
877
878        assert!(
879            gate.response.read().unwrap().is_none(),
880            "second begin_approval must clear the cycle-1 approval (ghost-approval prevention)"
881        );
882        assert_eq!(
883            gate.pending_plan.read().unwrap().as_deref(),
884            Some("plan v2"),
885            "pending_plan should reflect the latest plan text"
886        );
887    }
888}