Skip to main content

nika_engine/runtime/
hitl.rs

1//! Human-In-The-Loop (HITL) Handler for interactive workflows
2//!
3//! This module provides the `HitlHandler` trait for requesting user input
4//! during workflow execution. Implementations can integrate with different
5//! user interfaces (TUI, CLI, web, etc.).
6//!
7//! # Usage
8//!
9//! ```ignore
10//! use nika::runtime::hitl::{HitlHandler, HitlRequest, HitlResponse};
11//!
12//! // Implement for your UI
13//! struct MyHandler;
14//!
15//! #[async_trait]
16//! impl HitlHandler for MyHandler {
17//!     async fn prompt(&self, request: HitlRequest) -> Result<HitlResponse, HitlError> {
18//!         // Show prompt to user, wait for response
19//!         Ok(HitlResponse::new("user input"))
20//!     }
21//! }
22//! ```
23
24use async_trait::async_trait;
25use std::time::Duration;
26use thiserror::Error;
27
28/// Error type for HITL operations.
29#[derive(Debug, Error)]
30pub enum HitlError {
31    /// User cancelled the prompt.
32    #[error("User cancelled prompt")]
33    Cancelled,
34
35    /// Prompt timed out waiting for user input.
36    #[error("Prompt timed out after {0:?}")]
37    Timeout(Duration),
38
39    /// HITL handler is not available (headless mode).
40    #[error("HITL handler not available: {0}")]
41    NotAvailable(String),
42
43    /// Other error during prompt.
44    #[error("HITL error: {0}")]
45    Other(String),
46}
47
48/// Request for user input via HITL.
49#[derive(Debug, Clone)]
50pub struct HitlRequest {
51    /// The prompt message to display to the user.
52    pub message: String,
53    /// Default value if user provides no input.
54    pub default: Option<String>,
55    /// Optional timeout for the prompt.
56    pub timeout: Option<Duration>,
57    /// Optional list of choices for the user.
58    pub choices: Option<Vec<String>>,
59}
60
61impl HitlRequest {
62    /// Create a new HITL request with a message.
63    pub fn new(message: impl Into<String>) -> Self {
64        Self {
65            message: message.into(),
66            default: None,
67            timeout: None,
68            choices: None,
69        }
70    }
71
72    /// Set a default value.
73    pub fn with_default(mut self, default: impl Into<String>) -> Self {
74        self.default = Some(default.into());
75        self
76    }
77
78    /// Set a timeout.
79    pub fn with_timeout(mut self, timeout: Duration) -> Self {
80        self.timeout = Some(timeout);
81        self
82    }
83
84    /// Set choices for the user.
85    pub fn with_choices(mut self, choices: Vec<String>) -> Self {
86        self.choices = Some(choices);
87        self
88    }
89}
90
91/// Response from a HITL prompt.
92#[derive(Debug, Clone)]
93pub struct HitlResponse {
94    /// The user's response.
95    pub response: String,
96    /// Whether the default value was used.
97    pub default_used: bool,
98}
99
100impl HitlResponse {
101    /// Create a new response with user input.
102    pub fn new(response: impl Into<String>) -> Self {
103        Self {
104            response: response.into(),
105            default_used: false,
106        }
107    }
108
109    /// Create a response that used the default value.
110    pub fn from_default(default: impl Into<String>) -> Self {
111        Self {
112            response: default.into(),
113            default_used: true,
114        }
115    }
116}
117
118/// Trait for handling HITL (Human-In-The-Loop) prompts.
119///
120/// Implementations should handle user interaction for their specific UI.
121/// For example, a TUI implementation might show a modal dialog,
122/// while a CLI implementation might read from stdin.
123#[async_trait]
124pub trait HitlHandler: Send + Sync {
125    /// Request user input.
126    ///
127    /// The implementation should display the prompt message and wait for
128    /// user input. If a default is provided and the user provides no input,
129    /// the default should be returned.
130    ///
131    /// # Arguments
132    ///
133    /// * `request` - The HITL request containing message, default, timeout, etc.
134    ///
135    /// # Returns
136    ///
137    /// The user's response, or an error if the prompt failed.
138    async fn prompt(&self, request: HitlRequest) -> Result<HitlResponse, HitlError>;
139}
140
141/// A default handler that always uses the default value or errors.
142///
143/// Useful for testing or headless mode.
144#[derive(Debug, Default)]
145pub struct DefaultHitlHandler;
146
147#[async_trait]
148impl HitlHandler for DefaultHitlHandler {
149    async fn prompt(&self, request: HitlRequest) -> Result<HitlResponse, HitlError> {
150        match request.default {
151            Some(default) => Ok(HitlResponse::from_default(default)),
152            None => Err(HitlError::NotAvailable(
153                "No default provided and running in headless mode".to_string(),
154            )),
155        }
156    }
157}
158
159#[cfg(test)]
160mod tests {
161    use super::*;
162
163    #[tokio::test]
164    async fn test_hitl_request_builder() {
165        let request = HitlRequest::new("Enter your name")
166            .with_default("Anonymous")
167            .with_timeout(Duration::from_secs(30))
168            .with_choices(vec!["Alice".to_string(), "Bob".to_string()]);
169
170        assert_eq!(request.message, "Enter your name");
171        assert_eq!(request.default, Some("Anonymous".to_string()));
172        assert_eq!(request.timeout, Some(Duration::from_secs(30)));
173        assert_eq!(
174            request.choices,
175            Some(vec!["Alice".to_string(), "Bob".to_string()])
176        );
177    }
178
179    #[tokio::test]
180    async fn test_hitl_response_new() {
181        let response = HitlResponse::new("user input");
182        assert_eq!(response.response, "user input");
183        assert!(!response.default_used);
184    }
185
186    #[tokio::test]
187    async fn test_hitl_response_from_default() {
188        let response = HitlResponse::from_default("default value");
189        assert_eq!(response.response, "default value");
190        assert!(response.default_used);
191    }
192
193    #[tokio::test]
194    async fn test_default_handler_uses_default() {
195        let handler = DefaultHitlHandler;
196        let request = HitlRequest::new("Test prompt").with_default("default");
197
198        let response = handler.prompt(request).await.unwrap();
199        assert_eq!(response.response, "default");
200        assert!(response.default_used);
201    }
202
203    #[tokio::test]
204    async fn test_default_handler_errors_without_default() {
205        let handler = DefaultHitlHandler;
206        let request = HitlRequest::new("Test prompt");
207
208        let result = handler.prompt(request).await;
209        assert!(result.is_err());
210        assert!(matches!(result.unwrap_err(), HitlError::NotAvailable(_)));
211    }
212
213    #[tokio::test]
214    async fn test_hitl_error_display() {
215        let err = HitlError::Cancelled;
216        assert_eq!(err.to_string(), "User cancelled prompt");
217
218        let err = HitlError::Timeout(Duration::from_secs(30));
219        assert!(err.to_string().contains("30"));
220
221        let err = HitlError::NotAvailable("test".to_string());
222        assert!(err.to_string().contains("test"));
223    }
224
225    // Test that PromptTool uses HitlHandler when provided
226    #[tokio::test]
227    async fn test_custom_hitl_handler() {
228        struct CustomHandler {
229            fixed_response: String,
230        }
231
232        #[async_trait]
233        impl HitlHandler for CustomHandler {
234            async fn prompt(&self, _request: HitlRequest) -> Result<HitlResponse, HitlError> {
235                Ok(HitlResponse::new(&self.fixed_response))
236            }
237        }
238
239        let handler = CustomHandler {
240            fixed_response: "custom_response".to_string(),
241        };
242        let request = HitlRequest::new("Test");
243        let response = handler.prompt(request).await.unwrap();
244
245        assert_eq!(response.response, "custom_response");
246        assert!(!response.default_used);
247    }
248}