1use crate::tool::ToolKind;
13use serde::{Deserialize, Serialize};
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
17#[serde(rename_all = "snake_case")]
18pub enum AgentMode {
19 Default,
21 Plan,
23}
24
25impl AgentMode {
26 pub fn as_str(&self) -> &'static str {
27 match self {
28 Self::Default => "default",
29 Self::Plan => "plan",
30 }
31 }
32
33 pub fn restricts_tools(&self) -> bool {
35 matches!(self, Self::Plan)
36 }
37}
38
39impl Default for AgentMode {
40 fn default() -> Self {
41 Self::Default
42 }
43}
44
45impl std::fmt::Display for AgentMode {
46 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
47 write!(f, "{}", self.as_str())
48 }
49}
50
51#[derive(Debug, Clone, Serialize, Deserialize)]
53pub struct ProposedPlan {
54 pub title: String,
56 pub steps: Vec<String>,
58}
59
60impl ProposedPlan {
61 pub fn new(title: String, steps: Vec<String>) -> Self {
62 Self { title, steps }
63 }
64
65 pub fn is_empty(&self) -> bool {
66 self.steps.is_empty() && self.title.is_empty()
67 }
68}
69
70#[derive(Debug, Default)]
84pub struct ProposedPlanParser {
85 buffer: String,
86 in_plan: bool,
87 plan_title: Option<String>,
88 plan_body: String,
89 completed_plans: Vec<ProposedPlan>,
90}
91
92impl ProposedPlanParser {
93 pub fn new() -> Self {
94 Self::default()
95 }
96
97 pub fn push_text(&mut self, text: &str) -> Vec<ProposedPlan> {
100 self.buffer.push_str(text);
101 self.drain_completed()
102 }
103
104 pub fn drain(&mut self) -> Vec<ProposedPlan> {
106 if self.in_plan {
108 self.plan_body.push_str(&self.buffer);
109 self.buffer.clear();
110 if !self.plan_body.is_empty() {
111 let plan = self.finalize_plan();
112 self.completed_plans.push(plan);
113 }
114 }
115 std::mem::take(&mut self.completed_plans)
116 }
117
118 pub fn is_in_plan(&self) -> bool {
120 self.in_plan
121 }
122
123 pub fn partial_body(&self) -> &str {
125 if self.in_plan { &self.plan_body } else { "" }
126 }
127
128 pub fn partial_title(&self) -> Option<&str> {
130 if self.in_plan {
131 self.plan_title.as_deref()
132 } else {
133 None
134 }
135 }
136
137 fn drain_completed(&mut self) -> Vec<ProposedPlan> {
138 loop {
139 if self.in_plan {
140 if let Some(pos) = self.buffer.find("</proposed_plan>") {
142 self.plan_body.push_str(&self.buffer[..pos]);
143 self.buffer = self.buffer[pos + "</proposed_plan>".len()..].to_string();
144 self.in_plan = false;
145 let plan = self.finalize_plan();
146 self.completed_plans.push(plan);
147 } else {
148 let tag = "</proposed_plan>";
151 if self.buffer.len() >= tag.len() {
152 let safe = self.buffer.len() - tag.len() + 1;
153 self.plan_body.push_str(&self.buffer[..safe]);
154 self.buffer = self.buffer[safe..].to_string();
155 } else {
156 self.plan_body.push_str(&self.buffer);
158 self.buffer.clear();
159 }
160 break;
161 }
162 } else {
163 if let Some(tag_info) = self.find_opening_tag() {
165 self.buffer = self.buffer[tag_info.consume_len..].to_string();
166 self.in_plan = true;
167 self.plan_title = tag_info.title;
168 self.plan_body.clear();
169 } else {
170 let tag = "<proposed_plan";
173 let safe = self.buffer.len().saturating_sub(tag.len() - 1);
174 self.buffer = self.buffer[safe..].to_string();
175 break;
176 }
177 }
178 }
179 std::mem::take(&mut self.completed_plans)
180 }
181
182 fn finalize_plan(&mut self) -> ProposedPlan {
183 let title = self.plan_title.take().unwrap_or_default();
184 let body = std::mem::take(&mut self.plan_body);
185 let steps = parse_plan_steps(&body);
186 ProposedPlan::new(title, steps)
187 }
188
189 fn find_opening_tag(&self) -> Option<OpeningTagInfo> {
190 let start = self.buffer.find("<proposed_plan")?;
191 let rest = &self.buffer[start..];
192 let tag_end = rest.find('>')?;
193 let tag_content = &rest[..tag_end];
194 let consume_len = start + tag_end + 1;
195
196 let title = tag_content
197 .find("title=\"")
198 .and_then(|t_pos| {
199 let value_start = t_pos + "title=\"".len();
200 tag_content[value_start..]
201 .find('"')
202 .map(|end| tag_content[value_start..value_start + end].to_string())
203 })
204 .or_else(|| {
205 tag_content.find("title='").and_then(|t_pos| {
206 let value_start = t_pos + "title='".len();
207 tag_content[value_start..]
208 .find('\'')
209 .map(|end| tag_content[value_start..value_start + end].to_string())
210 })
211 });
212
213 Some(OpeningTagInfo { consume_len, title })
214 }
215}
216
217struct OpeningTagInfo {
218 consume_len: usize,
219 title: Option<String>,
220}
221
222fn parse_plan_steps(body: &str) -> Vec<String> {
225 body.lines()
226 .map(|line| line.trim())
227 .filter(|line| !line.is_empty())
228 .map(|line| {
229 let stripped = if let Some(rest) = line.strip_prefix("- ") {
231 rest.to_string()
232 } else if let Some(rest) = line.strip_prefix("* ") {
233 rest.to_string()
234 } else if let Some(rest) = line.strip_prefix("• ") {
235 rest.to_string()
236 } else {
237 let chars: Vec<char> = line.chars().collect();
239 let mut idx = 0;
240 while idx < chars.len() && chars[idx].is_ascii_digit() {
241 idx += 1;
242 }
243 if idx > 0 && idx < chars.len() && (chars[idx] == '.' || chars[idx] == ')') {
244 idx += 1;
245 while idx < chars.len() && chars[idx] == ' ' {
246 idx += 1;
247 }
248 line[idx..].to_string()
249 } else {
250 line.to_string()
251 }
252 };
253 if stripped.is_empty() {
254 line.to_string()
255 } else {
256 stripped
257 }
258 })
259 .collect()
260}
261
262pub fn is_tool_allowed_in_plan_mode(kind: ToolKind) -> bool {
265 matches!(kind, ToolKind::Read)
266}
267
268#[cfg(test)]
269mod tests {
270 use super::*;
271
272 #[test]
273 fn parser_extracts_simple_plan() {
274 let mut parser = ProposedPlanParser::new();
275 let plans = parser.push_text(
276 "Let me analyze this.\n\
277 <proposed_plan title=\"Fix the bug\">\n\
278 1. Read the file\n\
279 2. Fix the function\n\
280 3. Run tests\n\
281 </proposed_plan>\n\
282 Done.",
283 );
284 assert_eq!(plans.len(), 1);
285 assert_eq!(plans[0].title, "Fix the bug");
286 assert_eq!(plans[0].steps.len(), 3);
287 assert_eq!(plans[0].steps[0], "Read the file");
288 assert_eq!(plans[0].steps[1], "Fix the function");
289 assert_eq!(plans[0].steps[2], "Run tests");
290 }
291
292 #[test]
293 fn parser_extracts_plan_without_title() {
294 let mut parser = ProposedPlanParser::new();
295 let plans = parser.push_text(
296 "<proposed_plan>\n\
297 - Step one\n\
298 - Step two\n\
299 </proposed_plan>",
300 );
301 assert_eq!(plans.len(), 1);
302 assert_eq!(plans[0].title, "");
303 assert_eq!(plans[0].steps, vec!["Step one", "Step two"]);
304 }
305
306 #[test]
307 fn parser_handles_chunked_stream() {
308 let mut parser = ProposedPlanParser::new();
309 let chunks = [
310 "Let me think.\n<propos",
311 "ed_plan title=\"My Plan\">\n",
312 "1. First step\n2. Second ",
313 "step\n</proposed_",
314 "plan>\nDone.",
315 ];
316
317 let mut all_plans = Vec::new();
318 for chunk in &chunks {
319 all_plans.extend(parser.push_text(chunk));
320 }
321
322 assert_eq!(all_plans.len(), 1);
323 assert_eq!(all_plans[0].title, "My Plan");
324 assert_eq!(all_plans[0].steps, vec!["First step", "Second step"]);
325 }
326
327 #[test]
328 fn parser_handles_multiple_plans() {
329 let mut parser = ProposedPlanParser::new();
330 let plans = parser.push_text(
331 "<proposed_plan title=\"Plan A\">\n1. A1\n</proposed_plan>\n\
332 <proposed_plan title=\"Plan B\">\n1. B1\n</proposed_plan>",
333 );
334 assert_eq!(plans.len(), 2);
335 assert_eq!(plans[0].title, "Plan A");
336 assert_eq!(plans[1].title, "Plan B");
337 }
338
339 #[test]
340 fn parser_drain_unclosed_plan() {
341 let mut parser = ProposedPlanParser::new();
342 parser.push_text("<proposed_plan title=\"Unclosed\">\n1. Step\n");
343 let plans = parser.drain();
344 assert_eq!(plans.len(), 1);
345 assert_eq!(plans[0].title, "Unclosed");
346 assert_eq!(plans[0].steps, vec!["Step"]);
347 }
348
349 #[test]
350 fn parser_partial_preview() {
351 let mut parser = ProposedPlanParser::new();
352 parser.push_text("<proposed_plan title=\"Live\">\n1. First");
353 assert!(parser.is_in_plan());
354 assert_eq!(parser.partial_title(), Some("Live"));
355 assert!(parser.partial_body().contains("First"));
356 }
357
358 #[test]
359 fn parser_no_plan_in_regular_text() {
360 let mut parser = ProposedPlanParser::new();
361 let plans = parser.push_text("Just regular text without any plan tags.");
362 assert!(plans.is_empty());
363 assert!(!parser.is_in_plan());
364 }
365
366 #[test]
367 fn parse_plan_steps_strips_numbering() {
368 let steps = parse_plan_steps("1. First\n2. Second\n3. Third");
369 assert_eq!(steps, vec!["First", "Second", "Third"]);
370 }
371
372 #[test]
373 fn parse_plan_steps_strips_bullets() {
374 let steps = parse_plan_steps("- Alpha\n* Beta");
375 assert_eq!(steps, vec!["Alpha", "Beta"]);
376 }
377
378 #[test]
379 fn agent_mode_default_is_default() {
380 assert_eq!(AgentMode::default(), AgentMode::Default);
381 }
382
383 #[test]
384 fn agent_mode_plan_restricts_tools() {
385 assert!(AgentMode::Plan.restricts_tools());
386 assert!(!AgentMode::Default.restricts_tools());
387 }
388
389 #[test]
390 fn only_read_tools_allowed_in_plan_mode() {
391 assert!(is_tool_allowed_in_plan_mode(ToolKind::Read));
392 assert!(!is_tool_allowed_in_plan_mode(ToolKind::Write));
393 assert!(!is_tool_allowed_in_plan_mode(ToolKind::Command));
394 assert!(!is_tool_allowed_in_plan_mode(ToolKind::Custom));
395 }
396}