Skip to main content

zeph_commands/handlers/
plan.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Planning handler: `/plan`.
5
6use std::future::Future;
7use std::pin::Pin;
8
9use crate::context::CommandContext;
10use crate::{CommandError, CommandHandler, CommandOutput, SlashCategory};
11
12/// Planning handler for `/plan`.
13///
14/// Delegates to `OrchestrationAccess::handle_plan` which dispatches to `dispatch_plan_command`.
15/// The future is Send because `Agent<C>: Send` and no non-Send guards are held across
16/// await boundaries.
17pub struct PlanCommand;
18
19impl CommandHandler<CommandContext<'_>> for PlanCommand {
20    fn name(&self) -> &'static str {
21        "/plan"
22    }
23
24    fn description(&self) -> &'static str {
25        "Create or manage execution plans"
26    }
27
28    fn args_hint(&self) -> &'static str {
29        "[goal|confirm|cancel|status|list|resume|retry]"
30    }
31
32    fn category(&self) -> SlashCategory {
33        SlashCategory::Planning
34    }
35
36    fn requires_auth(&self) -> bool {
37        true
38    }
39
40    fn handle<'a>(
41        &'a self,
42        ctx: &'a mut CommandContext<'_>,
43        args: &'a str,
44    ) -> Pin<Box<dyn Future<Output = Result<CommandOutput, CommandError>> + Send + 'a>> {
45        use tracing::Instrument as _;
46        let span = tracing::info_span!("commands.plan.handle");
47        Box::pin(
48            async move {
49                // Reconstruct the full command string so the plan parser can parse it.
50                let input = if args.is_empty() {
51                    "/plan".to_owned()
52                } else {
53                    format!("/plan {args}")
54                };
55                let result = ctx.agent.handle_plan(&input).await?;
56                Ok(CommandOutput::message_or_silent(result))
57            }
58            .instrument(span),
59        )
60    }
61}
62
63#[cfg(test)]
64mod tests {
65    use super::*;
66    use crate::CommandRegistry;
67    use crate::handlers::test_helpers::{MockDebug, MockMessages, MockSession, make_ctx};
68    use crate::sink::NullSink;
69    use std::assert_matches;
70
71    #[test]
72    fn plan_name_and_description() {
73        assert_eq!(PlanCommand.name(), "/plan");
74        assert!(!PlanCommand.description().is_empty());
75    }
76
77    #[tokio::test]
78    async fn plan_no_args_returns_silent_when_agent_returns_empty() {
79        // NullAgent returns empty string from handle_plan, so result is Silent.
80        let mut sink = NullSink;
81        let mut debug = MockDebug;
82        let mut messages = MockMessages;
83        let session = MockSession;
84        let mut agent = crate::NullAgent;
85        let mut ctx = make_ctx(&mut sink, &mut debug, &mut messages, &session, &mut agent);
86        let out = PlanCommand.handle(&mut ctx, "").await.unwrap();
87        assert_matches!(out, CommandOutput::Silent);
88    }
89
90    #[tokio::test]
91    async fn plan_with_args_returns_silent_when_agent_returns_empty() {
92        let mut sink = NullSink;
93        let mut debug = MockDebug;
94        let mut messages = MockMessages;
95        let session = MockSession;
96        let mut agent = crate::NullAgent;
97        let mut ctx = make_ctx(&mut sink, &mut debug, &mut messages, &session, &mut agent);
98        let out = PlanCommand.handle(&mut ctx, "status").await.unwrap();
99        assert_matches!(out, CommandOutput::Silent);
100    }
101
102    #[tokio::test]
103    async fn plan_dispatch_allowed_when_trusted() {
104        let mut sink = NullSink;
105        let mut debug = MockDebug;
106        let mut messages = MockMessages;
107        let session = MockSession;
108        let mut agent = crate::NullAgent;
109        let mut ctx = make_ctx(&mut sink, &mut debug, &mut messages, &session, &mut agent);
110
111        let mut reg: CommandRegistry<CommandContext<'_>> = CommandRegistry::new();
112        reg.register(PlanCommand);
113
114        let result = reg.dispatch(&mut ctx, "/plan status", true).await;
115        assert!(result.unwrap().is_ok());
116    }
117
118    #[tokio::test]
119    async fn plan_dispatch_rejected_when_untrusted() {
120        let mut sink = NullSink;
121        let mut debug = MockDebug;
122        let mut messages = MockMessages;
123        let session = MockSession;
124        let mut agent = crate::NullAgent;
125        let mut ctx = make_ctx(&mut sink, &mut debug, &mut messages, &session, &mut agent);
126
127        let mut reg: CommandRegistry<CommandContext<'_>> = CommandRegistry::new();
128        reg.register(PlanCommand);
129
130        let result = reg.dispatch(&mut ctx, "/plan status", false).await;
131        let err = result.unwrap().unwrap_err();
132        assert!(err.0.contains("trusted"));
133    }
134}