Skip to main content

agent_types/
plan.rs

1//! Plan-related pure types: PlanItem, PlanStepStatus, UpdatePlanArgs.
2
3use serde::{Deserialize, Serialize};
4
5/// Lightweight plan step status — display-only, no execution semantics.
6#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
7#[serde(rename_all = "snake_case")]
8pub enum PlanStepStatus {
9    Pending,
10    InProgress,
11    Completed,
12}
13
14/// A single step in a lightweight plan checklist.
15///
16/// Contains only human-readable display text and a status.
17/// Does NOT carry tool names, command payloads, host info, or dependency graphs.
18#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct PlanItem {
20    pub step: String,
21    pub status: PlanStepStatus,
22}
23
24/// Arguments for the `update_plan` tool.
25///
26/// This is a lightweight progress-display protocol — the tool broadcasts a
27/// structured snapshot to the UI and does nothing else.
28#[derive(Debug, Clone, Serialize, Deserialize)]
29pub struct UpdatePlanArgs {
30    /// A one-sentence summary of the user's goal.
31    /// Optional on subsequent calls — the tool remembers the last objective.
32    /// Example: "安装 Casdoor 身份认证系统"
33    #[serde(default, skip_serializing_if = "Option::is_none")]
34    pub objective: Option<String>,
35    /// Optional explanation of why the plan changed.
36    #[serde(default, skip_serializing_if = "Option::is_none")]
37    pub explanation: Option<String>,
38    /// The full plan checklist (replaces any previous display).
39    pub plan: Vec<PlanItem>,
40}
41
42impl UpdatePlanArgs {
43    /// Validate the plan arguments.
44    ///
45    /// Rules:
46    /// - `objective`, if provided, must be non-empty after trimming and at most 200 chars.
47    /// - `plan` must contain 1–50 steps.
48    /// - Each `step` must be non-empty.
49    /// - At most one step may be `InProgress`.
50    pub fn validate(&self) -> Result<(), String> {
51        if let Some(ref objective) = self.objective {
52            let objective_trimmed = objective.trim();
53            if objective_trimmed.is_empty() {
54                return Err("objective must not be empty when provided".to_string());
55            }
56            if objective_trimmed.chars().count() > 200 {
57                return Err(format!(
58                    "objective must be at most 200 characters, got {}",
59                    objective_trimmed.chars().count()
60                ));
61            }
62        }
63        if self.plan.is_empty() {
64            return Err("plan must contain at least one step".to_string());
65        }
66        if self.plan.len() > 50 {
67            return Err(format!(
68                "plan must contain at most 50 steps, got {}",
69                self.plan.len()
70            ));
71        }
72
73        let in_progress_count = self
74            .plan
75            .iter()
76            .filter(|item| item.status == PlanStepStatus::InProgress)
77            .count();
78
79        if in_progress_count > 1 {
80            return Err(format!(
81                "at most one step may be in_progress, found {}",
82                in_progress_count
83            ));
84        }
85
86        for (i, item) in self.plan.iter().enumerate() {
87            if item.step.trim().is_empty() {
88                return Err(format!("step[{}] must not be empty", i));
89            }
90        }
91
92        Ok(())
93    }
94}
95
96#[cfg(test)]
97mod tests {
98    use super::*;
99
100    #[test]
101    fn test_optional_objective_deserialization() {
102        // objective present — should parse fine
103        let json =
104            r#"{"objective": "Install Docker", "plan": [{"step": "Step 1", "status": "pending"}]}"#;
105        let args: UpdatePlanArgs = serde_json::from_str(json).unwrap();
106        assert_eq!(args.objective.as_deref(), Some("Install Docker"));
107        assert!(args.validate().is_ok());
108
109        // objective absent — should parse with None
110        let json = r#"{"plan": [{"step": "Step 1", "status": "pending"}]}"#;
111        let args: UpdatePlanArgs = serde_json::from_str(json).unwrap();
112        assert!(args.objective.is_none());
113        assert!(args.validate().is_ok());
114
115        // objective present but empty — should fail validation
116        let json = r#"{"objective": "  ", "plan": [{"step": "Step 1", "status": "pending"}]}"#;
117        let args: UpdatePlanArgs = serde_json::from_str(json).unwrap();
118        assert!(args.validate().is_err());
119    }
120}
121
122#[cfg(test)]
123mod proptest_tests {
124    use super::*;
125    use proptest::prelude::*;
126
127    #[test]
128    fn validate_rejects_empty_plan() {
129        let args = UpdatePlanArgs {
130            objective: None,
131            explanation: None,
132            plan: vec![],
133        };
134        assert!(args.validate().is_err());
135    }
136
137    proptest! {
138        #[test]
139        fn validate_accepts_1_to_50_steps(count in 1usize..=50) {
140            let plan: Vec<PlanItem> = (0..count)
141                .map(|i| PlanItem {
142                    step: format!("step {}", i),
143                    status: PlanStepStatus::Pending,
144                })
145                .collect();
146            let args = UpdatePlanArgs {
147                objective: None,
148                explanation: None,
149                plan,
150            };
151            assert!(args.validate().is_ok());
152        }
153
154        #[test]
155        fn validate_rejects_more_than_50_steps(count in 51usize..100) {
156            let plan: Vec<PlanItem> = (0..count)
157                .map(|i| PlanItem {
158                    step: format!("step {}", i),
159                    status: PlanStepStatus::Pending,
160                })
161                .collect();
162            let args = UpdatePlanArgs {
163                objective: None,
164                explanation: None,
165                plan,
166            };
167            assert!(args.validate().is_err());
168        }
169
170        #[test]
171        fn validate_rejects_empty_step_text(step_text in r"[ \t\r\n]{0,20}") {
172            let args = UpdatePlanArgs {
173                objective: None,
174                explanation: None,
175                plan: vec![PlanItem {
176                    step: step_text,
177                    status: PlanStepStatus::Pending,
178                }],
179            };
180            assert!(args.validate().is_err());
181        }
182
183        #[test]
184        fn validate_objective_length_boundary(len in 0usize..300) {
185            let objective = "x".repeat(len);
186            let args = UpdatePlanArgs {
187                objective: Some(objective),
188                explanation: None,
189                plan: vec![PlanItem {
190                    step: "do something".into(),
191                    status: PlanStepStatus::Pending,
192                }],
193            };
194            if len == 0 {
195                // Empty after trim → fails
196                assert!(args.validate().is_err());
197            } else if len <= 200 {
198                assert!(args.validate().is_ok());
199            } else {
200                // Over 200 chars → fails
201                assert!(args.validate().is_err());
202            }
203        }
204    }
205}