Skip to main content

nika_engine/ast/
action.rs

1//! Task Action Types - the 5 action verbs
2//!
3//! Defines the task action variants:
4//! - `InferParams`: One-shot LLM call
5//! - `ExecParams`: Shell command execution
6//! - `FetchParams`: HTTP request
7//! - `InvokeParams`: MCP tool call / resource read
8//! - `AgentParams`: Agentic execution with tool calling
9//!
10//! ## Shorthand Syntax
11//!
12//! `infer:` and `exec:` support shorthand string syntax:
13//! ```yaml
14//! # Shorthand
15//! infer: "Generate a headline"
16//! exec: "echo hello"
17//!
18//! # Full form (equivalent)
19//! infer:
20//!   prompt: "Generate a headline"
21//! exec:
22//!   command: "echo hello"
23//! ```
24
25use rustc_hash::FxHashMap;
26use serde::{Deserialize, Deserializer, Serialize};
27
28use crate::ast::{AgentParams, InvokeParams};
29use crate::error::NikaError;
30
31/// Expected response format for infer tasks
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize)]
33#[serde(rename_all = "lowercase")]
34pub enum ResponseFormat {
35    /// Plain text response (default)
36    #[default]
37    Text,
38    /// JSON response
39    Json,
40    /// Markdown response
41    Markdown,
42}
43
44/// Infer action - one-shot LLM call
45///
46/// Supports shorthand: `infer: "prompt"` or full form `infer: { prompt: "..." }`
47///
48/// ## LLM Control Options
49/// - `temperature`: Control randomness (0.0-2.0, default varies by model)
50/// - `max_tokens`: Limit output length
51/// - `system`: System prompt to prepend
52///
53/// ## Extended Thinking
54/// - `extended_thinking`: Enable Claude's extended thinking for deeper reasoning
55/// - `thinking_budget`: Token budget for extended thinking (1024-65536, default 4096)
56#[derive(Debug, Clone, Default)]
57pub struct InferParams {
58    pub prompt: String,
59    /// Override provider for this task
60    pub provider: Option<String>,
61    /// Override model for this task
62    pub model: Option<String>,
63    /// Temperature for sampling (0.0 = deterministic, 2.0 = maximum randomness)
64    pub temperature: Option<f64>,
65    /// Maximum tokens to generate
66    pub max_tokens: Option<u32>,
67    /// System prompt to set context for the LLM
68    pub system: Option<String>,
69    /// Expected response format: json, text, markdown
70    pub response_format: Option<ResponseFormat>,
71    /// Enable extended thinking for deeper reasoning (Claude only)
72    pub extended_thinking: Option<bool>,
73    /// Token budget for extended thinking (1024-65536, default 4096, Claude only)
74    pub thinking_budget: Option<u64>,
75    /// Multimodal content parts for vision models (text + images)
76    pub content: Option<Vec<crate::ast::content::ContentPart>>,
77    /// Guardrails for validating infer output
78    pub guardrails: Vec<crate::ast::guardrails::GuardrailConfig>,
79}
80
81impl<'de> Deserialize<'de> for InferParams {
82    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
83    where
84        D: Deserializer<'de>,
85    {
86        #[derive(Deserialize)]
87        #[serde(untagged)]
88        enum InferParamsHelper {
89            Short(String),
90            Full {
91                #[serde(default)]
92                prompt: String,
93                #[serde(default)]
94                provider: Option<String>,
95                #[serde(default)]
96                model: Option<String>,
97                #[serde(default)]
98                temperature: Option<f64>,
99                #[serde(default)]
100                max_tokens: Option<u32>,
101                #[serde(default)]
102                system: Option<String>,
103                #[serde(default)]
104                response_format: Option<ResponseFormat>,
105                #[serde(default)]
106                extended_thinking: Option<bool>,
107                #[serde(default)]
108                thinking_budget: Option<u64>,
109                #[serde(default)]
110                content: Option<Vec<crate::ast::content::ContentPart>>,
111                #[serde(default)]
112                guardrails: Vec<crate::ast::guardrails::GuardrailConfig>,
113            },
114        }
115
116        match InferParamsHelper::deserialize(deserializer)? {
117            InferParamsHelper::Short(prompt) => Ok(InferParams {
118                prompt,
119                provider: None,
120                model: None,
121                temperature: None,
122                max_tokens: None,
123                system: None,
124                response_format: None,
125                extended_thinking: None,
126                thinking_budget: None,
127                content: None,
128                guardrails: Vec::new(),
129            }),
130            InferParamsHelper::Full {
131                prompt,
132                provider,
133                model,
134                temperature,
135                max_tokens,
136                system,
137                response_format,
138                extended_thinking,
139                thinking_budget,
140                content,
141                guardrails,
142            } => Ok(InferParams {
143                prompt,
144                provider,
145                model,
146                temperature,
147                max_tokens,
148                system,
149                response_format,
150                extended_thinking,
151                thinking_budget,
152                content,
153                guardrails,
154            }),
155        }
156    }
157}
158
159impl InferParams {
160    /// Validate infer parameters.
161    ///
162    /// # Errors
163    ///
164    /// Returns an error string if:
165    /// - `prompt` is empty or whitespace-only
166    /// - `temperature` is outside valid range (0.0..=2.0)
167    /// - `extended_thinking` is true with non-Claude provider
168    /// - `thinking_budget` is outside valid range (1024..=65536)
169    ///
170    pub fn validate(&self) -> Result<(), NikaError> {
171        // Prompt can be empty when content is present (vision mode)
172        let has_content = self.content.as_ref().is_some_and(|c| !c.is_empty());
173        if self.prompt.trim().is_empty() && !has_content {
174            return Err(NikaError::ValidationError {
175                reason: "Infer requires 'prompt' or 'content' (neither provided)".into(),
176            });
177        }
178
179        if let Some(temp) = self.temperature {
180            if !(0.0..=2.0).contains(&temp) {
181                return Err(NikaError::ValidationError {
182                    reason: format!("temperature must be between 0.0 and 2.0, got {}", temp),
183                });
184            }
185        }
186
187        // Validate extended_thinking requires Claude provider
188        if self.extended_thinking == Some(true) {
189            if let Some(ref provider) = self.provider {
190                if provider != "claude" {
191                    return Err(NikaError::ValidationError {
192                        reason: format!(
193                            "extended_thinking only supported for claude provider, got '{}'",
194                            provider
195                        ),
196                    });
197                }
198            }
199            // If provider is None, will inherit workflow default (validation deferred to runtime)
200        }
201
202        // Validate thinking_budget range
203        if let Some(budget) = self.thinking_budget {
204            if !(1024..=65536).contains(&budget) {
205                return Err(NikaError::ValidationError {
206                    reason: format!(
207                        "thinking_budget must be between 1024 and 65536, got {}",
208                        budget
209                    ),
210                });
211            }
212        }
213
214        Ok(())
215    }
216
217    /// Get effective thinking budget.
218    ///
219    /// Returns the configured `thinking_budget` if set, otherwise returns
220    /// the default value (4096).
221    pub fn effective_thinking_budget(&self) -> u64 {
222        self.thinking_budget.unwrap_or(4096)
223    }
224}
225
226/// Exec action - shell command
227///
228/// Supports shorthand: `exec: "command"` or full form `exec: { command: "...", shell: true }`
229///
230/// ## Shell Field
231/// - `shell: None` (default) → Shell-free execution via shlex parsing
232/// - `shell: Some(true)` → Execute via shell (opt-in for pipes, env vars)
233/// - `shell: Some(false)` → Explicitly shell-free
234#[derive(Debug, Clone)]
235pub struct ExecParams {
236    pub command: String,
237    /// Whether to execute via shell. None means shell-free (default, secure).
238    pub shell: Option<bool>,
239    /// Timeout in seconds for command execution
240    pub timeout: Option<u64>,
241    /// Working directory for command execution
242    pub cwd: Option<String>,
243    /// Environment variables to pass to the command
244    pub env: Option<FxHashMap<String, String>>,
245}
246
247impl<'de> Deserialize<'de> for ExecParams {
248    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
249    where
250        D: Deserializer<'de>,
251    {
252        #[derive(Deserialize)]
253        #[serde(untagged)]
254        enum ExecParamsHelper {
255            Short(String),
256            Full {
257                command: String,
258                #[serde(default)]
259                shell: Option<bool>,
260                #[serde(default)]
261                timeout: Option<u64>,
262                #[serde(default)]
263                cwd: Option<String>,
264                #[serde(default)]
265                env: Option<FxHashMap<String, String>>,
266            },
267        }
268
269        match ExecParamsHelper::deserialize(deserializer)? {
270            ExecParamsHelper::Short(command) => Ok(ExecParams {
271                command,
272                shell: None,
273                timeout: None,
274                cwd: None,
275                env: None,
276            }),
277            ExecParamsHelper::Full {
278                command,
279                shell,
280                timeout,
281                cwd,
282                env,
283            } => Ok(ExecParams {
284                command,
285                shell,
286                timeout,
287                cwd,
288                env,
289            }),
290        }
291    }
292}
293
294impl ExecParams {
295    /// Validate exec parameters
296    ///
297    /// # Errors
298    ///
299    /// Returns an error string if:
300    /// - `command` is empty or whitespace-only
301    /// - `timeout` is zero
302    pub fn validate(&self) -> Result<(), NikaError> {
303        if self.command.trim().is_empty() {
304            return Err(NikaError::ValidationError {
305                reason: "Exec command cannot be empty".into(),
306            });
307        }
308        if self.timeout == Some(0) {
309            return Err(NikaError::ValidationError {
310                reason: "Exec timeout must be greater than 0".into(),
311            });
312        }
313        Ok(())
314    }
315}
316
317/// Configuration for retry behavior with exponential backoff
318#[derive(Debug, Clone, Deserialize, Default, PartialEq)]
319pub struct RetryConfig {
320    /// Maximum number of retry attempts (default: 3)
321    #[serde(default = "default_max_attempts")]
322    pub max_attempts: u32,
323
324    /// Initial backoff in milliseconds (default: 1000)
325    #[serde(default = "default_backoff_ms")]
326    pub backoff_ms: u64,
327
328    /// Backoff multiplier for exponential backoff (default: 2.0)
329    #[serde(default = "default_multiplier")]
330    pub multiplier: f64,
331}
332
333fn default_max_attempts() -> u32 {
334    3
335}
336
337fn default_backoff_ms() -> u64 {
338    1000
339}
340
341fn default_multiplier() -> f64 {
342    2.0
343}
344
345/// Fetch action - HTTP request
346///
347/// ## JSON Body
348/// - `json: { ... }` — Auto-serializes to JSON and sets Content-Type: application/json
349/// - Mutually exclusive with `body` (json takes precedence)
350///
351/// ## Redirect Control
352/// - `follow_redirects: true` (default) — Follows HTTP redirects up to 5 hops
353/// - `follow_redirects: false` — Disables redirect following, returns 3xx response
354#[derive(Debug, Clone, Deserialize)]
355pub struct FetchParams {
356    pub url: String,
357    #[serde(default = "default_method")]
358    pub method: String,
359    #[serde(default)]
360    pub headers: FxHashMap<String, String>,
361    pub body: Option<String>,
362    /// JSON body to auto-serialize
363    /// If provided, serializes to string and sets Content-Type: application/json
364    /// Takes precedence over `body` if both are specified
365    #[serde(default)]
366    pub json: Option<serde_json::Value>,
367    /// Request timeout in seconds (matches JSON schema)
368    pub timeout: Option<u64>,
369    /// Optional retry configuration for failed requests
370    #[serde(default)]
371    pub retry: Option<RetryConfig>,
372    /// Whether to follow HTTP redirects
373    /// Defaults to true if not specified
374    #[serde(default)]
375    pub follow_redirects: Option<bool>,
376    /// Response mode: "full" (status + headers + body JSON) or "binary" (CAS store)
377    #[serde(default)]
378    pub response: Option<String>,
379    /// Extraction mode: markdown, text, selector, metadata, links, jsonpath
380    #[serde(default)]
381    pub extract: Option<String>,
382    /// CSS selector or JSONPath expression (used with extract: selector, text, jsonpath)
383    #[serde(default)]
384    pub selector: Option<String>,
385}
386
387impl FetchParams {
388    /// Validate fetch parameters
389    ///
390    /// # Errors
391    ///
392    /// Returns an error string if:
393    /// - `url` is empty or whitespace-only
394    /// - `method` is not a valid HTTP method
395    /// - `timeout` is zero
396    pub fn validate(&self) -> Result<(), NikaError> {
397        if self.url.trim().is_empty() {
398            return Err(NikaError::ValidationError {
399                reason: "Fetch URL cannot be empty".into(),
400            });
401        }
402        let valid_methods = ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"];
403        let method_upper = self.method.to_uppercase();
404        if !valid_methods.contains(&method_upper.as_str()) {
405            return Err(NikaError::ValidationError {
406                reason: format!(
407                    "Invalid HTTP method '{}', expected one of: {}",
408                    self.method,
409                    valid_methods.join(", ")
410                ),
411            });
412        }
413        if self.timeout == Some(0) {
414            return Err(NikaError::ValidationError {
415                reason: "Fetch timeout must be greater than 0".into(),
416            });
417        }
418        if let Some(ref r) = self.response {
419            if r != "full" && r != "binary" {
420                return Err(NikaError::ValidationError {
421                    reason: format!("Invalid response mode '{}', expected 'full' or 'binary'", r),
422                });
423            }
424        }
425        if let Some(ref extract) = self.extract {
426            let valid = [
427                "markdown", "article", "text", "selector", "metadata", "links", "jsonpath", "feed",
428                "llm_txt",
429            ];
430            if !valid.contains(&extract.as_str()) {
431                return Err(NikaError::ValidationError {
432                    reason: format!(
433                        "fetch extract must be one of: {}, got '{}'",
434                        valid.join(", "),
435                        extract
436                    ),
437                });
438            }
439        }
440        if self.selector.is_some() && self.extract.is_none() {
441            return Err(NikaError::ValidationError {
442                reason: "fetch 'selector' requires 'extract' to be set".to_string(),
443            });
444        }
445        if self.response.is_some() && self.extract.is_some() {
446            return Err(NikaError::ValidationError {
447                reason: format!(
448                    "fetch cannot combine 'response: {}' with 'extract: {}' — response modes bypass extraction",
449                    self.response.as_deref().unwrap_or(""),
450                    self.extract.as_deref().unwrap_or("")
451                ),
452            });
453        }
454        Ok(())
455    }
456}
457
458fn default_method() -> String {
459    "GET".to_string()
460}
461
462/// The 5 task action types
463///
464/// Each variant corresponds to a YAML verb:
465/// - `infer:` - LLM inference (one-shot)
466/// - `exec:` - Shell command execution
467/// - `fetch:` - HTTP request
468/// - `invoke:` - MCP tool call or resource read
469/// - `agent:` - Agentic execution with tool calling loop
470#[derive(Debug, Clone, Deserialize)]
471#[serde(untagged)]
472#[allow(clippy::large_enum_variant)] // AgentParams is large due to CompletionConfig; boxing would be a breaking change
473pub enum TaskAction {
474    Infer { infer: InferParams },
475    Exec { exec: ExecParams },
476    Fetch { fetch: FetchParams },
477    Invoke { invoke: InvokeParams },
478    Agent { agent: AgentParams },
479}
480
481impl TaskAction {
482    /// Get the verb name for this action (infer, exec, fetch, invoke, agent)
483    pub fn verb_name(&self) -> &'static str {
484        match self {
485            TaskAction::Infer { .. } => "infer",
486            TaskAction::Exec { .. } => "exec",
487            TaskAction::Fetch { .. } => "fetch",
488            TaskAction::Invoke { .. } => "invoke",
489            TaskAction::Agent { .. } => "agent",
490        }
491    }
492}
493
494#[cfg(test)]
495mod tests {
496    use super::*;
497    use crate::serde_yaml;
498    use serde_json::json;
499
500    // =========================================================================
501    // InferParams Tests
502    // =========================================================================
503
504    #[test]
505    fn test_infer_params_shorthand_deserialize() {
506        let yaml = r#"
507infer: "Generate a headline for QR Code AI"
508"#;
509        let action: TaskAction = serde_yaml::from_str(yaml).unwrap();
510        match action {
511            TaskAction::Infer { infer } => {
512                assert_eq!(infer.prompt, "Generate a headline for QR Code AI");
513                assert!(infer.provider.is_none());
514                assert!(infer.model.is_none());
515            }
516            _ => panic!("Expected TaskAction::Infer"),
517        }
518    }
519
520    #[test]
521    fn test_infer_params_full_form_deserialize() {
522        let yaml = r#"
523infer:
524  prompt: "Generate a headline"
525  provider: claude
526  model: claude-sonnet-4-6
527"#;
528        let action: TaskAction = serde_yaml::from_str(yaml).unwrap();
529        match action {
530            TaskAction::Infer { infer } => {
531                assert_eq!(infer.prompt, "Generate a headline");
532                assert_eq!(infer.provider, Some("claude".to_string()));
533                assert_eq!(infer.model, Some("claude-sonnet-4-6".to_string()));
534            }
535            _ => panic!("Expected TaskAction::Infer"),
536        }
537    }
538
539    #[test]
540    fn test_infer_params_full_form_only_prompt() {
541        let yaml = r#"
542infer:
543  prompt: "Generate a headline"
544"#;
545        let action: TaskAction = serde_yaml::from_str(yaml).unwrap();
546        match action {
547            TaskAction::Infer { infer } => {
548                assert_eq!(infer.prompt, "Generate a headline");
549                assert!(infer.provider.is_none());
550                assert!(infer.model.is_none());
551            }
552            _ => panic!("Expected TaskAction::Infer"),
553        }
554    }
555
556    #[test]
557    fn test_infer_params_multiline_prompt_shorthand() {
558        let yaml = r#"
559infer: |
560  Generate a comprehensive headline for QR Code AI.
561  Include value proposition and key benefit.
562  Keep under 100 characters.
563"#;
564        let action: TaskAction = serde_yaml::from_str(yaml).unwrap();
565        match action {
566            TaskAction::Infer { infer } => {
567                assert!(infer.prompt.contains("Generate a comprehensive headline"));
568                assert!(infer.prompt.contains("value proposition"));
569            }
570            _ => panic!("Expected TaskAction::Infer"),
571        }
572    }
573
574    #[test]
575    fn test_infer_params_with_provider_only() {
576        let yaml = r#"
577infer:
578  prompt: "Test"
579  provider: openai
580"#;
581        let action: TaskAction = serde_yaml::from_str(yaml).unwrap();
582        match action {
583            TaskAction::Infer { infer } => {
584                assert_eq!(infer.provider, Some("openai".to_string()));
585                assert!(infer.model.is_none());
586            }
587            _ => panic!("Expected TaskAction::Infer"),
588        }
589    }
590
591    #[test]
592    fn test_infer_params_with_model_only() {
593        let yaml = r#"
594infer:
595  prompt: "Test"
596  model: gpt-4
597"#;
598        let action: TaskAction = serde_yaml::from_str(yaml).unwrap();
599        match action {
600            TaskAction::Infer { infer } => {
601                assert!(infer.provider.is_none());
602                assert_eq!(infer.model, Some("gpt-4".to_string()));
603            }
604            _ => panic!("Expected TaskAction::Infer"),
605        }
606    }
607
608    // =========================================================================
609    // InferParams Validation Tests
610    // =========================================================================
611
612    #[test]
613    fn test_infer_params_validate_ok() {
614        let params = InferParams {
615            prompt: "Generate something".to_string(),
616            temperature: Some(0.7),
617            ..Default::default()
618        };
619        assert!(params.validate().is_ok());
620    }
621
622    #[test]
623    fn test_infer_params_validate_empty_prompt() {
624        let params = InferParams {
625            prompt: "".to_string(),
626            ..Default::default()
627        };
628        let result = params.validate();
629        assert!(result.is_err());
630        assert!(result.unwrap_err().to_string().contains("neither provided"));
631    }
632
633    #[test]
634    fn test_infer_params_validate_whitespace_only_prompt() {
635        let params = InferParams {
636            prompt: "   \n\t  ".to_string(),
637            ..Default::default()
638        };
639        let result = params.validate();
640        assert!(result.is_err());
641        assert!(result.unwrap_err().to_string().contains("neither provided"));
642    }
643
644    #[test]
645    fn test_infer_params_validate_content_without_prompt_ok() {
646        use crate::ast::content::{ContentPart, ImageDetail};
647        let params = InferParams {
648            prompt: "".to_string(),
649            content: Some(vec![ContentPart::Image {
650                source: "blake3:abc".to_string(),
651                detail: ImageDetail::Auto,
652            }]),
653            ..Default::default()
654        };
655        assert!(params.validate().is_ok());
656    }
657
658    #[test]
659    fn test_infer_params_validate_content_and_prompt_ok() {
660        use crate::ast::content::ContentPart;
661        let params = InferParams {
662            prompt: "Describe this image".to_string(),
663            content: Some(vec![ContentPart::Text {
664                text: "hello".to_string(),
665            }]),
666            ..Default::default()
667        };
668        assert!(params.validate().is_ok());
669    }
670
671    #[test]
672    fn test_infer_params_validate_empty_content_vec_rejected() {
673        let params = InferParams {
674            prompt: "".to_string(),
675            content: Some(vec![]),
676            ..Default::default()
677        };
678        let result = params.validate();
679        assert!(result.is_err());
680        assert!(result.unwrap_err().to_string().contains("neither provided"));
681    }
682
683    #[test]
684    fn test_infer_params_validate_temperature_too_low() {
685        let params = InferParams {
686            prompt: "Test".to_string(),
687            temperature: Some(-0.1),
688            ..Default::default()
689        };
690        let result = params.validate();
691        assert!(result.is_err());
692        assert!(result.unwrap_err().to_string().contains("temperature"));
693    }
694
695    #[test]
696    fn test_infer_params_validate_temperature_too_high() {
697        let params = InferParams {
698            prompt: "Test".to_string(),
699            temperature: Some(2.5),
700            ..Default::default()
701        };
702        let result = params.validate();
703        assert!(result.is_err());
704        assert!(result.unwrap_err().to_string().contains("temperature"));
705    }
706
707    #[test]
708    fn test_infer_params_validate_temperature_boundary_valid() {
709        // 0.0 and 2.0 are valid boundary values
710        let params_min = InferParams {
711            prompt: "Test".to_string(),
712            temperature: Some(0.0),
713            ..Default::default()
714        };
715        assert!(params_min.validate().is_ok());
716
717        let params_max = InferParams {
718            prompt: "Test".to_string(),
719            temperature: Some(2.0),
720            ..Default::default()
721        };
722        assert!(params_max.validate().is_ok());
723    }
724
725    // =========================================================================
726    // InferParams LLM Control Options Tests
727    // =========================================================================
728
729    #[test]
730    fn test_infer_params_with_temperature() {
731        let yaml = r#"
732infer:
733  prompt: "Be creative"
734  temperature: 0.9
735"#;
736        let action: TaskAction = serde_yaml::from_str(yaml).unwrap();
737        match action {
738            TaskAction::Infer { infer } => {
739                assert_eq!(infer.prompt, "Be creative");
740                assert_eq!(infer.temperature, Some(0.9));
741                assert!(infer.max_tokens.is_none());
742                assert!(infer.system.is_none());
743            }
744            _ => panic!("Expected TaskAction::Infer"),
745        }
746    }
747
748    #[test]
749    fn test_infer_params_with_max_tokens() {
750        let yaml = r#"
751infer:
752  prompt: "Short answer"
753  max_tokens: 100
754"#;
755        let action: TaskAction = serde_yaml::from_str(yaml).unwrap();
756        match action {
757            TaskAction::Infer { infer } => {
758                assert_eq!(infer.prompt, "Short answer");
759                assert_eq!(infer.max_tokens, Some(100));
760                assert!(infer.temperature.is_none());
761            }
762            _ => panic!("Expected TaskAction::Infer"),
763        }
764    }
765
766    #[test]
767    fn test_infer_params_with_system_prompt() {
768        let yaml = r#"
769infer:
770  prompt: "Explain quantum computing"
771  system: "You are a physics professor explaining to undergraduates."
772"#;
773        let action: TaskAction = serde_yaml::from_str(yaml).unwrap();
774        match action {
775            TaskAction::Infer { infer } => {
776                assert_eq!(infer.prompt, "Explain quantum computing");
777                assert_eq!(
778                    infer.system,
779                    Some("You are a physics professor explaining to undergraduates.".to_string())
780                );
781            }
782            _ => panic!("Expected TaskAction::Infer"),
783        }
784    }
785
786    #[test]
787    fn test_infer_params_full_llm_control() {
788        let yaml = r#"
789infer:
790  prompt: "Write a haiku"
791  provider: openai
792  model: gpt-4o
793  temperature: 0.7
794  max_tokens: 50
795  system: "You are a Japanese poetry master."
796"#;
797        let action: TaskAction = serde_yaml::from_str(yaml).unwrap();
798        match action {
799            TaskAction::Infer { infer } => {
800                assert_eq!(infer.prompt, "Write a haiku");
801                assert_eq!(infer.provider, Some("openai".to_string()));
802                assert_eq!(infer.model, Some("gpt-4o".to_string()));
803                assert_eq!(infer.temperature, Some(0.7));
804                assert_eq!(infer.max_tokens, Some(50));
805                assert_eq!(
806                    infer.system,
807                    Some("You are a Japanese poetry master.".to_string())
808                );
809            }
810            _ => panic!("Expected TaskAction::Infer"),
811        }
812    }
813
814    #[test]
815    fn test_infer_params_shorthand_defaults_llm_options() {
816        let yaml = r#"
817infer: "Simple prompt"
818"#;
819        let action: TaskAction = serde_yaml::from_str(yaml).unwrap();
820        match action {
821            TaskAction::Infer { infer } => {
822                assert_eq!(infer.prompt, "Simple prompt");
823                assert!(infer.temperature.is_none());
824                assert!(infer.max_tokens.is_none());
825                assert!(infer.system.is_none());
826            }
827            _ => panic!("Expected TaskAction::Infer"),
828        }
829    }
830
831    #[test]
832    fn test_infer_params_temperature_zero() {
833        let yaml = r#"
834infer:
835  prompt: "Deterministic output"
836  temperature: 0.0
837"#;
838        let action: TaskAction = serde_yaml::from_str(yaml).unwrap();
839        match action {
840            TaskAction::Infer { infer } => {
841                assert_eq!(infer.temperature, Some(0.0));
842            }
843            _ => panic!("Expected TaskAction::Infer"),
844        }
845    }
846
847    // =========================================================================
848    // ExecParams Tests
849    // =========================================================================
850
851    #[test]
852    fn test_exec_params_shorthand_deserialize() {
853        let yaml = r#"
854exec: "npm run build"
855"#;
856        let action: TaskAction = serde_yaml::from_str(yaml).unwrap();
857        match action {
858            TaskAction::Exec { exec } => {
859                assert_eq!(exec.command, "npm run build");
860            }
861            _ => panic!("Expected TaskAction::Exec"),
862        }
863    }
864
865    #[test]
866    fn test_exec_params_full_form_deserialize() {
867        let yaml = r#"
868exec:
869  command: "npm run build"
870"#;
871        let action: TaskAction = serde_yaml::from_str(yaml).unwrap();
872        match action {
873            TaskAction::Exec { exec } => {
874                assert_eq!(exec.command, "npm run build");
875            }
876            _ => panic!("Expected TaskAction::Exec"),
877        }
878    }
879
880    #[test]
881    fn test_exec_params_complex_command() {
882        let yaml = r#"
883exec: "cargo test --lib -- --test-threads=1 --nocapture"
884"#;
885        let action: TaskAction = serde_yaml::from_str(yaml).unwrap();
886        match action {
887            TaskAction::Exec { exec } => {
888                assert!(exec.command.contains("cargo test"));
889                assert!(exec.command.contains("--test-threads=1"));
890            }
891            _ => panic!("Expected TaskAction::Exec"),
892        }
893    }
894
895    #[test]
896    fn test_exec_params_with_pipes_and_redirects() {
897        let yaml = r#"
898exec: "cat file.txt | grep pattern > output.txt"
899"#;
900        let action: TaskAction = serde_yaml::from_str(yaml).unwrap();
901        match action {
902            TaskAction::Exec { exec } => {
903                assert!(exec.command.contains("grep pattern"));
904            }
905            _ => panic!("Expected TaskAction::Exec"),
906        }
907    }
908
909    // =========================================================================
910    // ExecParams Shell Field Tests
911    // =========================================================================
912
913    #[test]
914    fn test_exec_params_shell_field_default_none() {
915        let yaml = r#"
916exec:
917  command: "echo hello"
918"#;
919        let action: TaskAction = serde_yaml::from_str(yaml).unwrap();
920        match action {
921            TaskAction::Exec { exec } => {
922                assert_eq!(exec.command, "echo hello");
923                assert_eq!(exec.shell, None); // None means shell-free (default)
924            }
925            _ => panic!("Expected TaskAction::Exec"),
926        }
927    }
928
929    #[test]
930    fn test_exec_params_shell_true_explicit() {
931        let yaml = r#"
932exec:
933  command: "echo $HOME && ls | grep foo"
934  shell: true
935"#;
936        let action: TaskAction = serde_yaml::from_str(yaml).unwrap();
937        match action {
938            TaskAction::Exec { exec } => {
939                assert!(exec.command.contains("$HOME"));
940                assert_eq!(exec.shell, Some(true));
941            }
942            _ => panic!("Expected TaskAction::Exec"),
943        }
944    }
945
946    #[test]
947    fn test_exec_params_shell_false_explicit() {
948        let yaml = r#"
949exec:
950  command: "echo hello"
951  shell: false
952"#;
953        let action: TaskAction = serde_yaml::from_str(yaml).unwrap();
954        match action {
955            TaskAction::Exec { exec } => {
956                assert_eq!(exec.shell, Some(false));
957            }
958            _ => panic!("Expected TaskAction::Exec"),
959        }
960    }
961
962    // =========================================================================
963    // FetchParams Tests
964    // =========================================================================
965
966    #[test]
967    fn test_fetch_params_minimal() {
968        let yaml = r#"
969fetch:
970  url: "https://api.example.com/data"
971"#;
972        let action: TaskAction = serde_yaml::from_str(yaml).unwrap();
973        match action {
974            TaskAction::Fetch { fetch } => {
975                assert_eq!(fetch.url, "https://api.example.com/data");
976                assert_eq!(fetch.method, "GET");
977                assert!(fetch.headers.is_empty());
978                assert!(fetch.body.is_none());
979            }
980            _ => panic!("Expected TaskAction::Fetch"),
981        }
982    }
983
984    #[test]
985    fn test_fetch_params_with_method() {
986        let yaml = r#"
987fetch:
988  url: "https://api.example.com/data"
989  method: "POST"
990"#;
991        let action: TaskAction = serde_yaml::from_str(yaml).unwrap();
992        match action {
993            TaskAction::Fetch { fetch } => {
994                assert_eq!(fetch.method, "POST");
995            }
996            _ => panic!("Expected TaskAction::Fetch"),
997        }
998    }
999
1000    #[test]
1001    fn test_fetch_params_default_method_get() {
1002        let yaml = r#"
1003fetch:
1004  url: "https://api.example.com/data"
1005"#;
1006        let action: TaskAction = serde_yaml::from_str(yaml).unwrap();
1007        match action {
1008            TaskAction::Fetch { fetch } => {
1009                assert_eq!(fetch.method, "GET");
1010            }
1011            _ => panic!("Expected TaskAction::Fetch"),
1012        }
1013    }
1014
1015    #[test]
1016    fn test_fetch_params_with_headers() {
1017        let yaml = r#"
1018fetch:
1019  url: "https://api.example.com/data"
1020  method: "GET"
1021  headers:
1022    Authorization: "Bearer token123"
1023    Content-Type: "application/json"
1024"#;
1025        let action: TaskAction = serde_yaml::from_str(yaml).unwrap();
1026        match action {
1027            TaskAction::Fetch { fetch } => {
1028                assert_eq!(fetch.headers.len(), 2);
1029                assert_eq!(
1030                    fetch.headers.get("Authorization"),
1031                    Some(&"Bearer token123".to_string())
1032                );
1033                assert_eq!(
1034                    fetch.headers.get("Content-Type"),
1035                    Some(&"application/json".to_string())
1036                );
1037            }
1038            _ => panic!("Expected TaskAction::Fetch"),
1039        }
1040    }
1041
1042    #[test]
1043    fn test_fetch_params_with_body() {
1044        let yaml = r#"
1045fetch:
1046  url: "https://api.example.com/data"
1047  method: "POST"
1048  body: '{"key": "value"}'
1049"#;
1050        let action: TaskAction = serde_yaml::from_str(yaml).unwrap();
1051        match action {
1052            TaskAction::Fetch { fetch } => {
1053                assert_eq!(fetch.body, Some(r#"{"key": "value"}"#.to_string()));
1054            }
1055            _ => panic!("Expected TaskAction::Fetch"),
1056        }
1057    }
1058
1059    #[test]
1060    fn test_fetch_params_complete() {
1061        let yaml = r#"
1062fetch:
1063  url: "https://api.example.com/users"
1064  method: "POST"
1065  headers:
1066    Authorization: "Bearer token"
1067    Content-Type: "application/json"
1068  body: '{"name": "Alice"}'
1069"#;
1070        let action: TaskAction = serde_yaml::from_str(yaml).unwrap();
1071        match action {
1072            TaskAction::Fetch { fetch } => {
1073                assert_eq!(fetch.url, "https://api.example.com/users");
1074                assert_eq!(fetch.method, "POST");
1075                assert_eq!(fetch.headers.len(), 2);
1076                assert_eq!(fetch.body, Some(r#"{"name": "Alice"}"#.to_string()));
1077            }
1078            _ => panic!("Expected TaskAction::Fetch"),
1079        }
1080    }
1081
1082    #[test]
1083    fn test_fetch_params_with_json() {
1084        let yaml = r#"
1085fetch:
1086  url: "https://api.example.com/users"
1087  method: "POST"
1088  json:
1089    name: "Alice"
1090    age: 30
1091    active: true
1092"#;
1093        let action: TaskAction = serde_yaml::from_str(yaml).unwrap();
1094        match action {
1095            TaskAction::Fetch { fetch } => {
1096                assert_eq!(fetch.url, "https://api.example.com/users");
1097                assert_eq!(fetch.method, "POST");
1098                assert!(fetch.json.is_some());
1099                let json = fetch.json.unwrap();
1100                assert_eq!(json["name"], "Alice");
1101                assert_eq!(json["age"], 30);
1102                assert_eq!(json["active"], true);
1103                assert!(fetch.body.is_none()); // body not set, json is used
1104            }
1105            _ => panic!("Expected TaskAction::Fetch"),
1106        }
1107    }
1108
1109    #[test]
1110    fn test_fetch_params_json_with_nested_objects() {
1111        let yaml = r#"
1112fetch:
1113  url: "https://api.example.com/data"
1114  method: "POST"
1115  json:
1116    user:
1117      name: "Bob"
1118      email: "bob@example.com"
1119    tags:
1120      - "admin"
1121      - "active"
1122"#;
1123        let action: TaskAction = serde_yaml::from_str(yaml).unwrap();
1124        match action {
1125            TaskAction::Fetch { fetch } => {
1126                let json = fetch.json.unwrap();
1127                assert_eq!(json["user"]["name"], "Bob");
1128                assert_eq!(json["user"]["email"], "bob@example.com");
1129                assert_eq!(json["tags"][0], "admin");
1130                assert_eq!(json["tags"][1], "active");
1131            }
1132            _ => panic!("Expected TaskAction::Fetch"),
1133        }
1134    }
1135
1136    #[test]
1137    fn test_fetch_params_follow_redirects_true() {
1138        let yaml = r#"
1139fetch:
1140  url: "https://example.com/redirect"
1141  follow_redirects: true
1142"#;
1143        let action: TaskAction = serde_yaml::from_str(yaml).unwrap();
1144        match action {
1145            TaskAction::Fetch { fetch } => {
1146                assert_eq!(fetch.url, "https://example.com/redirect");
1147                assert_eq!(fetch.follow_redirects, Some(true));
1148            }
1149            _ => panic!("Expected TaskAction::Fetch"),
1150        }
1151    }
1152
1153    #[test]
1154    fn test_fetch_params_follow_redirects_false() {
1155        let yaml = r#"
1156fetch:
1157  url: "https://example.com/redirect"
1158  follow_redirects: false
1159"#;
1160        let action: TaskAction = serde_yaml::from_str(yaml).unwrap();
1161        match action {
1162            TaskAction::Fetch { fetch } => {
1163                assert_eq!(fetch.url, "https://example.com/redirect");
1164                assert_eq!(fetch.follow_redirects, Some(false));
1165            }
1166            _ => panic!("Expected TaskAction::Fetch"),
1167        }
1168    }
1169
1170    #[test]
1171    fn test_fetch_params_follow_redirects_default_none() {
1172        let yaml = r#"
1173fetch:
1174  url: "https://example.com/api"
1175"#;
1176        let action: TaskAction = serde_yaml::from_str(yaml).unwrap();
1177        match action {
1178            TaskAction::Fetch { fetch } => {
1179                assert_eq!(fetch.url, "https://example.com/api");
1180                assert!(fetch.follow_redirects.is_none()); // Defaults to None (meaning true)
1181            }
1182            _ => panic!("Expected TaskAction::Fetch"),
1183        }
1184    }
1185
1186    // =========================================================================
1187    // InvokeParams Tests
1188    // =========================================================================
1189
1190    #[test]
1191    fn test_invoke_params_tool_call() {
1192        let yaml = r#"
1193invoke:
1194  mcp: novanet
1195  tool: novanet_context
1196  params:
1197    entity: qr-code
1198    locale: fr-FR
1199"#;
1200        let action: TaskAction = serde_yaml::from_str(yaml).unwrap();
1201        match action {
1202            TaskAction::Invoke { invoke } => {
1203                assert_eq!(invoke.mcp, Some("novanet".to_string()));
1204                assert_eq!(invoke.tool, Some("novanet_context".to_string()));
1205                assert_eq!(
1206                    invoke.params,
1207                    Some(json!({"entity": "qr-code", "locale": "fr-FR"}))
1208                );
1209                assert!(invoke.resource.is_none());
1210            }
1211            _ => panic!("Expected TaskAction::Invoke"),
1212        }
1213    }
1214
1215    #[test]
1216    fn test_invoke_params_resource_read() {
1217        let yaml = r#"
1218invoke:
1219  mcp: novanet
1220  resource: entity://qr-code/fr-FR
1221"#;
1222        let action: TaskAction = serde_yaml::from_str(yaml).unwrap();
1223        match action {
1224            TaskAction::Invoke { invoke } => {
1225                assert_eq!(invoke.mcp, Some("novanet".to_string()));
1226                assert!(invoke.tool.is_none());
1227                assert_eq!(invoke.resource, Some("entity://qr-code/fr-FR".to_string()));
1228                assert!(invoke.params.is_none());
1229            }
1230            _ => panic!("Expected TaskAction::Invoke"),
1231        }
1232    }
1233
1234    #[test]
1235    fn test_invoke_params_tool_without_params() {
1236        let yaml = r#"
1237invoke:
1238  mcp: test_server
1239  tool: simple_tool
1240"#;
1241        let action: TaskAction = serde_yaml::from_str(yaml).unwrap();
1242        match action {
1243            TaskAction::Invoke { invoke } => {
1244                assert_eq!(invoke.mcp, Some("test_server".to_string()));
1245                assert_eq!(invoke.tool, Some("simple_tool".to_string()));
1246                assert!(invoke.params.is_none());
1247            }
1248            _ => panic!("Expected TaskAction::Invoke"),
1249        }
1250    }
1251
1252    // =========================================================================
1253    // AgentParams Tests
1254    // =========================================================================
1255
1256    #[test]
1257    fn test_agent_params_minimal() {
1258        let yaml = r#"
1259agent:
1260  prompt: "Generate content for homepage"
1261"#;
1262        let action: TaskAction = serde_yaml::from_str(yaml).unwrap();
1263        match action {
1264            TaskAction::Agent { agent } => {
1265                assert_eq!(agent.prompt, "Generate content for homepage");
1266                assert!(agent.system.is_none());
1267                assert!(agent.provider.is_none());
1268                assert!(agent.model.is_none());
1269                assert!(agent.mcp.is_empty());
1270            }
1271            _ => panic!("Expected TaskAction::Agent"),
1272        }
1273    }
1274
1275    #[test]
1276    fn test_agent_params_with_mcp() {
1277        let yaml = r#"
1278agent:
1279  prompt: "Generate with MCP tools"
1280  mcp:
1281    - novanet
1282    - perplexity
1283"#;
1284        let action: TaskAction = serde_yaml::from_str(yaml).unwrap();
1285        match action {
1286            TaskAction::Agent { agent } => {
1287                assert_eq!(agent.mcp.len(), 2);
1288                assert!(agent.mcp.contains(&"novanet".to_string()));
1289                assert!(agent.mcp.contains(&"perplexity".to_string()));
1290            }
1291            _ => panic!("Expected TaskAction::Agent"),
1292        }
1293    }
1294
1295    #[test]
1296    fn test_agent_params_with_max_turns() {
1297        let yaml = r#"
1298agent:
1299  prompt: "Test prompt"
1300  max_turns: 5
1301"#;
1302        let action: TaskAction = serde_yaml::from_str(yaml).unwrap();
1303        match action {
1304            TaskAction::Agent { agent } => {
1305                assert_eq!(agent.max_turns, Some(5));
1306            }
1307            _ => panic!("Expected TaskAction::Agent"),
1308        }
1309    }
1310
1311    #[test]
1312    fn test_agent_params_with_extended_thinking() {
1313        let yaml = r#"
1314agent:
1315  prompt: "Test prompt"
1316  extended_thinking: true
1317  thinking_budget: 8192
1318"#;
1319        let action: TaskAction = serde_yaml::from_str(yaml).unwrap();
1320        match action {
1321            TaskAction::Agent { agent } => {
1322                assert_eq!(agent.extended_thinking, Some(true));
1323                assert_eq!(agent.thinking_budget, Some(8192));
1324            }
1325            _ => panic!("Expected TaskAction::Agent"),
1326        }
1327    }
1328
1329    #[test]
1330    fn test_agent_params_with_provider_and_model() {
1331        let yaml = r#"
1332agent:
1333  prompt: "Test prompt"
1334  provider: claude
1335  model: claude-sonnet-4-6
1336"#;
1337        let action: TaskAction = serde_yaml::from_str(yaml).unwrap();
1338        match action {
1339            TaskAction::Agent { agent } => {
1340                assert_eq!(agent.provider, Some("claude".to_string()));
1341                assert_eq!(agent.model, Some("claude-sonnet-4-6".to_string()));
1342            }
1343            _ => panic!("Expected TaskAction::Agent"),
1344        }
1345    }
1346
1347    #[test]
1348    fn test_agent_params_complete() {
1349        let yaml = r#"
1350agent:
1351  prompt: "Generate landing page content"
1352  system: "You are a web content expert"
1353  provider: claude
1354  model: claude-sonnet-4-6
1355  mcp:
1356    - novanet
1357  max_turns: 10
1358  token_budget: 10000
1359  scope: full
1360  extended_thinking: true
1361  thinking_budget: 4096
1362"#;
1363        let action: TaskAction = serde_yaml::from_str(yaml).unwrap();
1364        match action {
1365            TaskAction::Agent { agent } => {
1366                assert_eq!(agent.prompt, "Generate landing page content");
1367                assert_eq!(
1368                    agent.system,
1369                    Some("You are a web content expert".to_string())
1370                );
1371                assert_eq!(agent.provider, Some("claude".to_string()));
1372                assert_eq!(agent.model, Some("claude-sonnet-4-6".to_string()));
1373                assert_eq!(agent.mcp.len(), 1);
1374                assert_eq!(agent.max_turns, Some(10));
1375                assert_eq!(agent.token_budget, Some(10000));
1376                assert_eq!(agent.scope, Some("full".to_string()));
1377                assert_eq!(agent.extended_thinking, Some(true));
1378                assert_eq!(agent.thinking_budget, Some(4096));
1379            }
1380            _ => panic!("Expected TaskAction::Agent"),
1381        }
1382    }
1383
1384    // =========================================================================
1385    // TaskAction::verb_name() Tests
1386    // =========================================================================
1387
1388    #[test]
1389    fn test_verb_name_infer() {
1390        let action = TaskAction::Infer {
1391            infer: InferParams {
1392                prompt: "test".to_string(),
1393                ..Default::default()
1394            },
1395        };
1396        assert_eq!(action.verb_name(), "infer");
1397    }
1398
1399    #[test]
1400    fn test_verb_name_exec() {
1401        let action = TaskAction::Exec {
1402            exec: ExecParams {
1403                command: "echo test".to_string(),
1404                shell: None,
1405                timeout: None,
1406                cwd: None,
1407                env: None,
1408            },
1409        };
1410        assert_eq!(action.verb_name(), "exec");
1411    }
1412
1413    #[test]
1414    fn test_verb_name_fetch() {
1415        let action = TaskAction::Fetch {
1416            fetch: FetchParams {
1417                url: "https://example.com".to_string(),
1418                method: "GET".to_string(),
1419                headers: FxHashMap::default(),
1420                body: None,
1421                json: None,
1422                timeout: None,
1423                retry: None,
1424                follow_redirects: None,
1425                response: None,
1426                extract: None,
1427                selector: None,
1428            },
1429        };
1430        assert_eq!(action.verb_name(), "fetch");
1431    }
1432
1433    #[test]
1434    fn test_verb_name_invoke() {
1435        let action = TaskAction::Invoke {
1436            invoke: InvokeParams {
1437                mcp: Some("test".to_string()),
1438                tool: Some("test_tool".to_string()),
1439                params: None,
1440                resource: None,
1441                timeout: None,
1442            },
1443        };
1444        assert_eq!(action.verb_name(), "invoke");
1445    }
1446
1447    #[test]
1448    fn test_verb_name_agent() {
1449        let action = TaskAction::Agent {
1450            agent: AgentParams {
1451                prompt: "test".to_string(),
1452                ..Default::default()
1453            },
1454        };
1455        assert_eq!(action.verb_name(), "agent");
1456    }
1457
1458    // =========================================================================
1459    // Mixed Action Type Tests
1460    // =========================================================================
1461
1462    #[test]
1463    fn test_parse_multiple_action_types() {
1464        let infer_yaml = r#"infer: "test""#;
1465        let exec_yaml = r#"exec: "test""#;
1466        let fetch_yaml = r#"fetch: { url: "http://example.com" }"#;
1467
1468        let infer_action: TaskAction = serde_yaml::from_str(infer_yaml).unwrap();
1469        let exec_action: TaskAction = serde_yaml::from_str(exec_yaml).unwrap();
1470        let fetch_action: TaskAction = serde_yaml::from_str(fetch_yaml).unwrap();
1471
1472        assert_eq!(infer_action.verb_name(), "infer");
1473        assert_eq!(exec_action.verb_name(), "exec");
1474        assert_eq!(fetch_action.verb_name(), "fetch");
1475    }
1476
1477    // =========================================================================
1478    // Edge Cases and Error Handling
1479    // =========================================================================
1480
1481    #[test]
1482    fn test_infer_params_empty_prompt() {
1483        let yaml = r#"
1484infer: ""
1485"#;
1486        let action: TaskAction = serde_yaml::from_str(yaml).unwrap();
1487        match action {
1488            TaskAction::Infer { infer } => {
1489                assert_eq!(infer.prompt, "");
1490            }
1491            _ => panic!("Expected TaskAction::Infer"),
1492        }
1493    }
1494
1495    #[test]
1496    fn test_exec_params_empty_command() {
1497        let yaml = r#"
1498exec: ""
1499"#;
1500        let action: TaskAction = serde_yaml::from_str(yaml).unwrap();
1501        match action {
1502            TaskAction::Exec { exec } => {
1503                assert_eq!(exec.command, "");
1504            }
1505            _ => panic!("Expected TaskAction::Exec"),
1506        }
1507    }
1508
1509    #[test]
1510    fn test_fetch_params_empty_headers() {
1511        let yaml = r#"
1512fetch:
1513  url: "https://example.com"
1514  headers: {}
1515"#;
1516        let action: TaskAction = serde_yaml::from_str(yaml).unwrap();
1517        match action {
1518            TaskAction::Fetch { fetch } => {
1519                assert!(fetch.headers.is_empty());
1520            }
1521            _ => panic!("Expected TaskAction::Fetch"),
1522        }
1523    }
1524
1525    #[test]
1526    fn test_agent_params_empty_mcp_list() {
1527        let yaml = r#"
1528agent:
1529  prompt: "test"
1530  mcp: []
1531"#;
1532        let action: TaskAction = serde_yaml::from_str(yaml).unwrap();
1533        match action {
1534            TaskAction::Agent { agent } => {
1535                assert!(agent.mcp.is_empty());
1536            }
1537            _ => panic!("Expected TaskAction::Agent"),
1538        }
1539    }
1540
1541    // =========================================================================
1542    // Special Characters and Unicode Tests
1543    // =========================================================================
1544
1545    #[test]
1546    fn test_infer_params_special_characters() {
1547        let yaml = r#"
1548infer: "Generate content with special chars: !@#$%^&*()"
1549"#;
1550        let action: TaskAction = serde_yaml::from_str(yaml).unwrap();
1551        match action {
1552            TaskAction::Infer { infer } => {
1553                assert!(infer.prompt.contains("!@#$%^&*()"));
1554            }
1555            _ => panic!("Expected TaskAction::Infer"),
1556        }
1557    }
1558
1559    #[test]
1560    fn test_infer_params_unicode() {
1561        let yaml = r#"
1562infer: "Generate content en français: résumé, café, naïve"
1563"#;
1564        let action: TaskAction = serde_yaml::from_str(yaml).unwrap();
1565        match action {
1566            TaskAction::Infer { infer } => {
1567                assert!(infer.prompt.contains("français"));
1568                assert!(infer.prompt.contains("résumé"));
1569            }
1570            _ => panic!("Expected TaskAction::Infer"),
1571        }
1572    }
1573
1574    #[test]
1575    fn test_fetch_params_url_with_query_string() {
1576        let yaml = r#"
1577fetch:
1578  url: "https://api.example.com/search?q=rust&limit=10&offset=5"
1579"#;
1580        let action: TaskAction = serde_yaml::from_str(yaml).unwrap();
1581        match action {
1582            TaskAction::Fetch { fetch } => {
1583                assert!(fetch.url.contains("search?q=rust"));
1584                assert!(fetch.url.contains("limit=10"));
1585            }
1586            _ => panic!("Expected TaskAction::Fetch"),
1587        }
1588    }
1589
1590    // =========================================================================
1591    // Cloning Tests
1592    // =========================================================================
1593
1594    #[test]
1595    fn test_infer_action_clone() {
1596        let action = TaskAction::Infer {
1597            infer: InferParams {
1598                prompt: "test".to_string(),
1599                provider: Some("claude".to_string()),
1600                model: Some("claude-sonnet-4-6".to_string()),
1601                ..Default::default()
1602            },
1603        };
1604        let cloned = action.clone();
1605        assert_eq!(action.verb_name(), cloned.verb_name());
1606    }
1607
1608    #[test]
1609    fn test_all_action_types_cloneable() {
1610        let infer = TaskAction::Infer {
1611            infer: InferParams {
1612                prompt: "test".to_string(),
1613                ..Default::default()
1614            },
1615        };
1616        let exec = TaskAction::Exec {
1617            exec: ExecParams {
1618                command: "echo".to_string(),
1619                shell: None,
1620                timeout: None,
1621                cwd: None,
1622                env: None,
1623            },
1624        };
1625        let fetch = TaskAction::Fetch {
1626            fetch: FetchParams {
1627                url: "http://example.com".to_string(),
1628                method: "GET".to_string(),
1629                headers: FxHashMap::default(),
1630                body: None,
1631                json: None,
1632                timeout: None,
1633                retry: None,
1634                follow_redirects: None,
1635                response: None,
1636                extract: None,
1637                selector: None,
1638            },
1639        };
1640
1641        let _ = infer.clone();
1642        let _ = exec.clone();
1643        let _ = fetch.clone();
1644    }
1645
1646    // =========================================================================
1647    // FetchParams extract/selector validation
1648    // =========================================================================
1649
1650    #[test]
1651    fn test_fetch_validate_valid_extract_modes() {
1652        let valid_modes = [
1653            "markdown", "article", "text", "selector", "metadata", "links", "jsonpath", "feed",
1654            "llm_txt",
1655        ];
1656        for mode in &valid_modes {
1657            let params = FetchParams {
1658                url: "https://example.com".to_string(),
1659                method: "GET".to_string(),
1660                headers: FxHashMap::default(),
1661                body: None,
1662                json: None,
1663                timeout: None,
1664                retry: None,
1665                follow_redirects: None,
1666                response: None,
1667                extract: Some(mode.to_string()),
1668                selector: None,
1669            };
1670            assert!(
1671                params.validate().is_ok(),
1672                "extract mode '{}' should be valid",
1673                mode
1674            );
1675        }
1676    }
1677
1678    #[test]
1679    fn test_fetch_validate_invalid_extract_mode() {
1680        let params = FetchParams {
1681            url: "https://example.com".to_string(),
1682            method: "GET".to_string(),
1683            headers: FxHashMap::default(),
1684            body: None,
1685            json: None,
1686            timeout: None,
1687            retry: None,
1688            follow_redirects: None,
1689            response: None,
1690            extract: Some("invalid_mode".to_string()),
1691            selector: None,
1692        };
1693        let err = params.validate().unwrap_err();
1694        assert!(err.to_string().contains("extract must be one of"));
1695        assert!(err.to_string().contains("invalid_mode"));
1696    }
1697
1698    #[test]
1699    fn test_fetch_validate_selector_without_extract() {
1700        let params = FetchParams {
1701            url: "https://example.com".to_string(),
1702            method: "GET".to_string(),
1703            headers: FxHashMap::default(),
1704            body: None,
1705            json: None,
1706            timeout: None,
1707            retry: None,
1708            follow_redirects: None,
1709            response: None,
1710            extract: None,
1711            selector: Some("div.content".to_string()),
1712        };
1713        let err = params.validate().unwrap_err();
1714        assert!(err.to_string().contains("selector"));
1715        assert!(err.to_string().contains("requires"));
1716    }
1717
1718    #[test]
1719    fn test_fetch_validate_selector_with_extract() {
1720        let params = FetchParams {
1721            url: "https://example.com".to_string(),
1722            method: "GET".to_string(),
1723            headers: FxHashMap::default(),
1724            body: None,
1725            json: None,
1726            timeout: None,
1727            retry: None,
1728            follow_redirects: None,
1729            response: None,
1730            extract: Some("text".to_string()),
1731            selector: Some("p.intro".to_string()),
1732        };
1733        assert!(params.validate().is_ok());
1734    }
1735
1736    #[test]
1737    fn test_fetch_validate_no_extract_no_selector() {
1738        let params = FetchParams {
1739            url: "https://example.com".to_string(),
1740            method: "GET".to_string(),
1741            headers: FxHashMap::default(),
1742            body: None,
1743            json: None,
1744            timeout: None,
1745            retry: None,
1746            follow_redirects: None,
1747            response: None,
1748            extract: None,
1749            selector: None,
1750        };
1751        assert!(params.validate().is_ok());
1752    }
1753
1754    #[test]
1755    fn test_fetch_params_extract_deserialize() {
1756        let yaml = r#"
1757fetch:
1758  url: "https://example.com"
1759  extract: markdown
1760"#;
1761        let action: TaskAction = serde_yaml::from_str(yaml).unwrap();
1762        match action {
1763            TaskAction::Fetch { fetch } => {
1764                assert_eq!(fetch.url, "https://example.com");
1765                assert_eq!(fetch.extract, Some("markdown".to_string()));
1766                assert!(fetch.selector.is_none());
1767            }
1768            _ => panic!("Expected TaskAction::Fetch"),
1769        }
1770    }
1771
1772    #[test]
1773    fn test_fetch_params_extract_with_selector_deserialize() {
1774        let yaml = r#"
1775fetch:
1776  url: "https://example.com"
1777  extract: selector
1778  selector: "div.content"
1779"#;
1780        let action: TaskAction = serde_yaml::from_str(yaml).unwrap();
1781        match action {
1782            TaskAction::Fetch { fetch } => {
1783                assert_eq!(fetch.extract, Some("selector".to_string()));
1784                assert_eq!(fetch.selector, Some("div.content".to_string()));
1785            }
1786            _ => panic!("Expected TaskAction::Fetch"),
1787        }
1788    }
1789
1790    #[test]
1791    fn test_fetch_params_no_extract_backward_compatible() {
1792        let yaml = r#"
1793fetch:
1794  url: "https://example.com"
1795"#;
1796        let action: TaskAction = serde_yaml::from_str(yaml).unwrap();
1797        match action {
1798            TaskAction::Fetch { fetch } => {
1799                assert!(fetch.extract.is_none());
1800                assert!(fetch.selector.is_none());
1801            }
1802            _ => panic!("Expected TaskAction::Fetch"),
1803        }
1804    }
1805}