Skip to main content

a3s_code_core/planning/
llm_planner.rs

1//! LLM-powered planning logic
2//!
3//! Provides intelligent plan generation, goal extraction, and achievement
4//! evaluation by sending structured prompts to an LLM and parsing JSON responses.
5//! Falls back to heuristic logic when no LLM client is available.
6
7use crate::llm::structured::{generate_blocking, StructuredMode, StructuredRequest};
8use crate::llm::{LlmClient, Message};
9use crate::planning::{AgentGoal, Complexity, ExecutionPlan, Task};
10use anyhow::{Context, Result};
11use serde::{Deserialize, Serialize};
12use std::sync::Arc;
13
14/// Result of evaluating goal achievement
15#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct AchievementResult {
17    /// Whether the goal has been achieved
18    pub achieved: bool,
19    /// Progress toward goal (0.0 - 1.0)
20    pub progress: f32,
21    /// Criteria that remain unmet
22    pub remaining_criteria: Vec<String>,
23}
24
25/// Pre-analysis result — intent, goal, plan, and optimized input in one LLM call.
26#[derive(Debug, Clone)]
27pub struct PreAnalysis {
28    pub intent: crate::prompts::AgentStyle,
29    pub requires_planning: bool,
30    pub goal: AgentGoal,
31    pub execution_plan: ExecutionPlan,
32    /// LLM-rewritten version of the user input with ambiguities resolved.
33    pub optimized_input: String,
34}
35
36/// LLM-powered planner that generates plans, extracts goals, and evaluates achievement
37pub struct LlmPlanner;
38
39// ============================================================================
40// JSON response schemas for LLM parsing
41// ============================================================================
42
43#[derive(Debug, Deserialize)]
44struct PlanResponse {
45    goal: String,
46    complexity: String,
47    steps: Vec<StepResponse>,
48    #[serde(default)]
49    required_tools: Vec<String>,
50}
51
52#[derive(Debug, Deserialize)]
53struct StepResponse {
54    id: String,
55    description: String,
56    #[serde(default)]
57    tool: Option<String>,
58    #[serde(default)]
59    dependencies: Vec<String>,
60    #[serde(default)]
61    success_criteria: Option<String>,
62}
63
64#[derive(Debug, Deserialize)]
65struct GoalResponse {
66    description: String,
67    success_criteria: Vec<String>,
68}
69
70#[derive(Debug, Deserialize)]
71struct AchievementResponse {
72    achieved: bool,
73    progress: f32,
74    #[serde(default)]
75    remaining_criteria: Vec<String>,
76}
77
78#[derive(Debug, Deserialize)]
79struct PreAnalysisResponse {
80    intent: String,
81    requires_planning: bool,
82    goal: GoalResponse,
83    execution_plan: PreAnalysisPlan,
84    optimized_input: String,
85}
86
87#[derive(Debug, Deserialize)]
88struct PreAnalysisPlan {
89    complexity: String,
90    steps: Vec<StepResponse>,
91    #[serde(default)]
92    required_tools: Vec<String>,
93}
94
95impl LlmPlanner {
96    /// Generate an execution plan from a prompt using LLM
97    pub async fn create_plan(llm: &Arc<dyn LlmClient>, prompt: &str) -> Result<ExecutionPlan> {
98        let system = crate::prompts::LLM_PLAN_SYSTEM;
99
100        let messages = vec![Message::user(prompt)];
101        let response = llm
102            .complete(&messages, Some(system), &[])
103            .await
104            .context("LLM call failed during plan creation")?;
105
106        let text = response.text();
107        Self::parse_plan_response(&text)
108    }
109
110    /// Extract a goal with success criteria from a prompt using LLM
111    pub async fn extract_goal(llm: &Arc<dyn LlmClient>, prompt: &str) -> Result<AgentGoal> {
112        let system = crate::prompts::LLM_GOAL_EXTRACT_SYSTEM;
113
114        let messages = vec![Message::user(prompt)];
115        let response = llm
116            .complete(&messages, Some(system), &[])
117            .await
118            .context("LLM call failed during goal extraction")?;
119
120        let text = response.text();
121        Self::parse_goal_response(&text)
122    }
123
124    /// Evaluate whether a goal has been achieved given current state
125    pub async fn check_achievement(
126        llm: &Arc<dyn LlmClient>,
127        goal: &AgentGoal,
128        current_state: &str,
129    ) -> Result<AchievementResult> {
130        let prompt = format!(
131            "Goal: {}\nSuccess Criteria: {}\nCurrent State: {}",
132            goal.description,
133            goal.success_criteria.join("; "),
134            current_state,
135        );
136        let req = StructuredRequest {
137            prompt,
138            system: Some(crate::prompts::LLM_GOAL_CHECK_SYSTEM.to_string()),
139            schema: Self::achievement_schema(),
140            schema_name: "goal_achievement".to_string(),
141            schema_description: Some(
142                "Strict, evidence-backed evaluation of whether every goal criterion is met"
143                    .to_string(),
144            ),
145            mode: StructuredMode::Auto,
146            max_repair_attempts: 2,
147        };
148
149        let result = generate_blocking(&**llm, &req)
150            .await
151            .context("LLM achievement structured generation failed")?;
152        Self::achievement_from_value(result.object)
153    }
154
155    /// Create a fallback plan using heuristic logic (no LLM required)
156    pub fn fallback_plan(prompt: &str) -> ExecutionPlan {
157        let complexity = if prompt.len() < 50 {
158            Complexity::Simple
159        } else if prompt.len() < 150 {
160            Complexity::Medium
161        } else if prompt.len() < 300 {
162            Complexity::Complex
163        } else {
164            Complexity::VeryComplex
165        };
166
167        let mut plan = ExecutionPlan::new(prompt, complexity);
168
169        let step_count = match complexity {
170            Complexity::Simple => 2,
171            Complexity::Medium => 4,
172            Complexity::Complex => 7,
173            Complexity::VeryComplex => 10,
174        };
175
176        for i in 0..step_count {
177            let step = Task::new(
178                format!("step-{}", i + 1),
179                crate::prompts::render(
180                    crate::prompts::PLAN_FALLBACK_STEP,
181                    &[("step_num", &(i + 1).to_string())],
182                ),
183            );
184            plan.add_step(step);
185        }
186
187        plan
188    }
189
190    /// Create a fallback goal using heuristic logic (no LLM required)
191    pub fn fallback_goal(prompt: &str) -> AgentGoal {
192        AgentGoal::new(prompt).with_criteria(vec![
193            "Task is completed successfully".to_string(),
194            "All requirements are met".to_string(),
195        ])
196    }
197
198    /// Create a fail-closed fallback achievement result (no LLM required).
199    ///
200    /// Goal completion is a control-plane decision: a host may stop an
201    /// unbounded engineering loop as soon as `GoalAchieved` is emitted. Plain
202    /// answer text such as "done" (or even "not complete") is not sufficient
203    /// evidence for that decision. If the structured evaluator is unavailable,
204    /// keep every success criterion open and let the host retry in a later
205    /// iteration.
206    pub fn fallback_check_achievement(goal: &AgentGoal, current_state: &str) -> AchievementResult {
207        let _ = current_state;
208
209        AchievementResult {
210            achieved: false,
211            progress: goal.progress,
212            remaining_criteria: goal.success_criteria.clone(),
213        }
214    }
215
216    /// Perform pre-analysis in a single LLM call: intent classification, goal extraction,
217    /// execution plan, and input optimization. Falls back to heuristics on failure.
218    pub async fn pre_analyze(llm: &Arc<dyn LlmClient>, prompt: &str) -> Result<PreAnalysis> {
219        let req = StructuredRequest {
220            prompt: format!(
221                "Analyze this user request and return a compact pre-analysis object. \
222                 Use at most 5 execution steps.\n\nUser request:\n{prompt}"
223            ),
224            system: Some(crate::prompts::PRE_ANALYSIS_SYSTEM.to_string()),
225            schema: Self::pre_analysis_schema(),
226            schema_name: "pre_analysis".to_string(),
227            schema_description: Some(
228                "Intent, goal, plan, and optimized input for an agent turn".to_string(),
229            ),
230            mode: StructuredMode::Auto,
231            max_repair_attempts: 2,
232        };
233
234        let result = generate_blocking(&**llm, &req)
235            .await
236            .context("LLM pre-analysis structured generation failed")?;
237
238        Self::pre_analysis_from_value(result.object, prompt)
239            .context("Failed to parse pre-analysis JSON from LLM response")
240    }
241
242    fn pre_analysis_from_value(
243        value: serde_json::Value,
244        original_prompt: &str,
245    ) -> Result<PreAnalysis> {
246        let parsed: PreAnalysisResponse = serde_json::from_value(value)
247            .context("pre-analysis object did not match the expected response shape")?;
248        Self::pre_analysis_from_response(parsed, original_prompt)
249    }
250
251    fn pre_analysis_from_response(
252        parsed: PreAnalysisResponse,
253        original_prompt: &str,
254    ) -> Result<PreAnalysis> {
255        let intent = match parsed.intent.to_lowercase().as_str() {
256            "plan" => crate::prompts::AgentStyle::Plan,
257            "explore" => crate::prompts::AgentStyle::Explore,
258            "verification" => crate::prompts::AgentStyle::Verification,
259            "codereview" | "code review" => crate::prompts::AgentStyle::CodeReview,
260            _ => crate::prompts::AgentStyle::GeneralPurpose,
261        };
262
263        let goal_description = parsed.goal.description.clone();
264        let goal =
265            AgentGoal::new(goal_description.clone()).with_criteria(parsed.goal.success_criteria);
266
267        let complexity = match parsed.execution_plan.complexity.as_str() {
268            "Simple" => Complexity::Simple,
269            "Medium" => Complexity::Medium,
270            "Complex" => Complexity::Complex,
271            "VeryComplex" => Complexity::VeryComplex,
272            _ => Complexity::Medium,
273        };
274
275        let mut plan = ExecutionPlan::new(goal_description, complexity);
276        for step_resp in parsed.execution_plan.steps {
277            let mut task = Task::new(step_resp.id, step_resp.description);
278            if let Some(tool) = step_resp.tool {
279                task = task.with_tool(tool);
280            }
281            if !step_resp.dependencies.is_empty() {
282                task = task.with_dependencies(step_resp.dependencies);
283            }
284            if let Some(criteria) = step_resp.success_criteria {
285                task = task.with_success_criteria(criteria);
286            }
287            plan.add_step(task);
288        }
289        for tool in parsed.execution_plan.required_tools {
290            plan.add_required_tool(tool);
291        }
292
293        Ok(PreAnalysis {
294            intent,
295            requires_planning: parsed.requires_planning,
296            goal,
297            execution_plan: plan,
298            optimized_input: if parsed.optimized_input.is_empty() {
299                original_prompt.to_string()
300            } else {
301                parsed.optimized_input
302            },
303        })
304    }
305
306    fn pre_analysis_schema() -> serde_json::Value {
307        serde_json::json!({
308            "type": "object",
309            "required": ["intent", "requires_planning", "goal", "execution_plan", "optimized_input"],
310            "properties": {
311                "intent": { "type": "string" },
312                "requires_planning": { "type": "boolean" },
313                "goal": {
314                    "type": "object",
315                    "required": ["description", "success_criteria"],
316                    "properties": {
317                        "description": { "type": "string", "minLength": 1 },
318                        "success_criteria": {
319                            "type": "array",
320                            "items": { "type": "string" }
321                        }
322                    }
323                },
324                "execution_plan": {
325                    "type": "object",
326                    "required": ["complexity", "steps"],
327                    "properties": {
328                        "complexity": { "type": "string" },
329                        "steps": {
330                            "type": "array",
331                            "items": {
332                                "type": "object",
333                                "required": ["id", "description"],
334                                "properties": {
335                                    "id": { "type": "string" },
336                                    "description": { "type": "string" },
337                                    "tool": { "type": "string" },
338                                    "dependencies": {
339                                        "type": "array",
340                                        "items": { "type": "string" }
341                                    },
342                                    "success_criteria": { "type": "string" }
343                                }
344                            }
345                        },
346                        "required_tools": {
347                            "type": "array",
348                            "items": { "type": "string" }
349                        }
350                    }
351                },
352                "optimized_input": { "type": "string" }
353            }
354        })
355    }
356
357    fn achievement_schema() -> serde_json::Value {
358        serde_json::json!({
359            "type": "object",
360            "additionalProperties": false,
361            "required": ["achieved", "progress", "remaining_criteria"],
362            "properties": {
363                "achieved": { "type": "boolean" },
364                "progress": {
365                    "type": "number",
366                    "minimum": 0.0,
367                    "maximum": 1.0
368                },
369                "remaining_criteria": {
370                    "type": "array",
371                    "items": { "type": "string" }
372                }
373            }
374        })
375    }
376
377    fn achievement_from_value(value: serde_json::Value) -> Result<AchievementResult> {
378        let parsed: AchievementResponse = serde_json::from_value(value)
379            .context("achievement object did not match the expected response shape")?;
380        Ok(AchievementResult {
381            achieved: parsed.achieved,
382            progress: parsed.progress.clamp(0.0, 1.0),
383            remaining_criteria: parsed.remaining_criteria,
384        })
385    }
386
387    // ========================================================================
388    // JSON parsing helpers
389    // ========================================================================
390
391    fn parse_plan_response(text: &str) -> Result<ExecutionPlan> {
392        let parsed: PlanResponse = Self::parse_json_lenient(text)
393            .context("Failed to parse plan JSON from LLM response")?;
394
395        let complexity = match parsed.complexity.as_str() {
396            "Simple" => Complexity::Simple,
397            "Medium" => Complexity::Medium,
398            "Complex" => Complexity::Complex,
399            "VeryComplex" => Complexity::VeryComplex,
400            _ => Complexity::Medium,
401        };
402
403        let mut plan = ExecutionPlan::new(parsed.goal, complexity);
404
405        for step_resp in parsed.steps {
406            let mut task = Task::new(step_resp.id, step_resp.description);
407            if let Some(tool) = step_resp.tool {
408                task = task.with_tool(tool);
409            }
410            if !step_resp.dependencies.is_empty() {
411                task = task.with_dependencies(step_resp.dependencies);
412            }
413            if let Some(criteria) = step_resp.success_criteria {
414                task = task.with_success_criteria(criteria);
415            }
416            plan.add_step(task);
417        }
418
419        for tool in parsed.required_tools {
420            plan.add_required_tool(tool);
421        }
422
423        Ok(plan)
424    }
425
426    fn parse_goal_response(text: &str) -> Result<AgentGoal> {
427        let parsed: GoalResponse = Self::parse_json_lenient(text)
428            .context("Failed to parse goal JSON from LLM response")?;
429
430        Ok(AgentGoal::new(parsed.description).with_criteria(parsed.success_criteria))
431    }
432
433    #[cfg(test)]
434    fn parse_achievement_response(text: &str) -> Result<AchievementResult> {
435        let parsed: AchievementResponse = Self::parse_json_lenient(text)
436            .context("Failed to parse achievement JSON from LLM response")?;
437
438        Ok(AchievementResult {
439            achieved: parsed.achieved,
440            progress: parsed.progress.clamp(0.0, 1.0),
441            remaining_criteria: parsed.remaining_criteria,
442        })
443    }
444
445    /// Parse JSON from possibly-dirty LLM output into the target type.
446    ///
447    /// Uses the shared robust extractor ([`crate::llm::structured::extract_json_value`]),
448    /// which handles ```json fences, surrounding prose, and braces embedded in
449    /// strings — unlike the previous naive first-`{`/last-`}` slice, which broke
450    /// on fenced output or any `}` inside a string value.
451    fn parse_json_lenient<T: serde::de::DeserializeOwned>(text: &str) -> Result<T> {
452        let value = crate::llm::structured::extract_json_value(text)?;
453        Ok(serde_json::from_value(value)?)
454    }
455}
456
457// ============================================================================
458// Tests
459// ============================================================================
460
461#[cfg(test)]
462mod tests {
463    use super::*;
464
465    #[test]
466    fn test_parse_plan_response() {
467        let json = r#"{
468            "goal": "Build a REST API",
469            "complexity": "Complex",
470            "steps": [
471                {
472                    "id": "step-1",
473                    "description": "Set up project structure",
474                    "tool": "bash",
475                    "dependencies": [],
476                    "success_criteria": "Project directory created"
477                },
478                {
479                    "id": "step-2",
480                    "description": "Implement endpoints",
481                    "tool": "write",
482                    "dependencies": ["step-1"],
483                    "success_criteria": "Endpoints respond correctly"
484                }
485            ],
486            "required_tools": ["bash", "write", "read"]
487        }"#;
488
489        let plan = LlmPlanner::parse_plan_response(json).unwrap();
490        assert_eq!(plan.goal, "Build a REST API");
491        assert_eq!(plan.complexity, Complexity::Complex);
492        assert_eq!(plan.steps.len(), 2);
493        assert_eq!(plan.steps[0].id, "step-1");
494        assert_eq!(plan.steps[0].tool, Some("bash".to_string()));
495        assert_eq!(plan.steps[1].dependencies, vec!["step-1".to_string()]);
496        assert_eq!(plan.required_tools, vec!["bash", "write", "read"]);
497    }
498
499    #[test]
500    fn test_parse_plan_response_with_markdown_fences() {
501        let json = "```json\n{\"goal\": \"Test\", \"complexity\": \"Simple\", \"steps\": [{\"id\": \"step-1\", \"description\": \"Do it\"}], \"required_tools\": []}\n```";
502
503        let plan = LlmPlanner::parse_plan_response(json).unwrap();
504        assert_eq!(plan.goal, "Test");
505        assert_eq!(plan.complexity, Complexity::Simple);
506        assert_eq!(plan.steps.len(), 1);
507    }
508
509    #[test]
510    fn test_parse_plan_response_invalid() {
511        let bad_json = "This is not JSON at all";
512        let result = LlmPlanner::parse_plan_response(bad_json);
513        assert!(result.is_err());
514    }
515
516    #[test]
517    fn test_parse_plan_response_unknown_complexity() {
518        let json =
519            r#"{"goal": "Test", "complexity": "Unknown", "steps": [], "required_tools": []}"#;
520        let plan = LlmPlanner::parse_plan_response(json).unwrap();
521        assert_eq!(plan.complexity, Complexity::Medium); // falls back to Medium
522    }
523
524    #[test]
525    fn test_parse_goal_response() {
526        let json = r#"{
527            "description": "Deploy the application to production",
528            "success_criteria": [
529                "All tests pass",
530                "Application is accessible at production URL",
531                "Health check returns 200"
532            ]
533        }"#;
534
535        let goal = LlmPlanner::parse_goal_response(json).unwrap();
536        assert_eq!(goal.description, "Deploy the application to production");
537        assert_eq!(goal.success_criteria.len(), 3);
538        assert_eq!(goal.success_criteria[0], "All tests pass");
539    }
540
541    #[test]
542    fn test_parse_goal_response_invalid() {
543        let result = LlmPlanner::parse_goal_response("not json");
544        assert!(result.is_err());
545    }
546
547    #[test]
548    fn test_parse_achievement_response() {
549        let json = r#"{
550            "achieved": false,
551            "progress": 0.65,
552            "remaining_criteria": ["Health check not verified"]
553        }"#;
554
555        let result = LlmPlanner::parse_achievement_response(json).unwrap();
556        assert!(!result.achieved);
557        assert!((result.progress - 0.65).abs() < f32::EPSILON);
558        assert_eq!(result.remaining_criteria, vec!["Health check not verified"]);
559    }
560
561    #[test]
562    fn test_parse_achievement_response_achieved() {
563        let json = r#"{"achieved": true, "progress": 1.0, "remaining_criteria": []}"#;
564        let result = LlmPlanner::parse_achievement_response(json).unwrap();
565        assert!(result.achieved);
566        assert!((result.progress - 1.0).abs() < f32::EPSILON);
567        assert!(result.remaining_criteria.is_empty());
568    }
569
570    #[test]
571    fn test_parse_achievement_response_clamps_progress() {
572        let json = r#"{"achieved": false, "progress": 1.5, "remaining_criteria": []}"#;
573        let result = LlmPlanner::parse_achievement_response(json).unwrap();
574        assert!((result.progress - 1.0).abs() < f32::EPSILON);
575    }
576
577    #[test]
578    fn test_fallback_plan() {
579        let short_prompt = "Fix bug";
580        let plan = LlmPlanner::fallback_plan(short_prompt);
581        assert_eq!(plan.complexity, Complexity::Simple);
582        assert_eq!(plan.steps.len(), 2);
583        assert_eq!(plan.goal, short_prompt);
584
585        let long_prompt = "Implement a comprehensive authentication system with OAuth2 support, JWT tokens, refresh token rotation, multi-factor authentication, and role-based access control across all API endpoints with proper audit logging and session management capabilities for both web and mobile clients, including password reset flows, account lockout policies, and integration with external identity providers such as Google, GitHub, and SAML-based enterprise SSO systems";
586        let plan = LlmPlanner::fallback_plan(long_prompt);
587        assert_eq!(plan.complexity, Complexity::VeryComplex);
588        assert_eq!(plan.steps.len(), 10);
589    }
590
591    #[test]
592    fn test_fallback_goal() {
593        let goal = LlmPlanner::fallback_goal("Fix the login bug");
594        assert_eq!(goal.description, "Fix the login bug");
595        assert_eq!(goal.success_criteria.len(), 2);
596        assert_eq!(goal.success_criteria[0], "Task is completed successfully");
597    }
598
599    #[test]
600    fn test_fallback_check_achievement_fails_closed_on_done_text() {
601        let goal = AgentGoal::new("Test task").with_criteria(vec!["Criterion 1".to_string()]);
602
603        let result = LlmPlanner::fallback_check_achievement(&goal, "The task is done.");
604        assert!(!result.achieved);
605        assert!(result.progress.abs() < f32::EPSILON);
606        assert_eq!(result.remaining_criteria, vec!["Criterion 1"]);
607    }
608
609    #[test]
610    fn test_fallback_check_achievement_rejects_negated_completion_text() {
611        let goal = AgentGoal::new("Test task").with_criteria(vec!["Criterion 1".to_string()]);
612
613        for state in [
614            "The task is not complete.",
615            "Verification is not done.",
616            "The implementation is unfinished.",
617        ] {
618            let result = LlmPlanner::fallback_check_achievement(&goal, state);
619            assert!(!result.achieved, "must fail closed for {state:?}");
620            assert_eq!(result.remaining_criteria, vec!["Criterion 1"]);
621        }
622    }
623
624    #[test]
625    fn test_fallback_check_achievement_not_done() {
626        let goal = AgentGoal::new("Test task")
627            .with_criteria(vec!["Criterion 1".to_string(), "Criterion 2".to_string()]);
628
629        let result = LlmPlanner::fallback_check_achievement(&goal, "Work in progress");
630        assert!(!result.achieved);
631        assert_eq!(result.remaining_criteria.len(), 2);
632    }
633
634    #[test]
635    fn test_parse_json_lenient_plain() {
636        let v: serde_json::Value = LlmPlanner::parse_json_lenient("  {\"a\": 1}  ").unwrap();
637        assert_eq!(v["a"], 1);
638    }
639
640    #[test]
641    fn test_parse_json_lenient_with_fences() {
642        let text = "```json\n{\"a\": 1}\n```";
643        let v: serde_json::Value = LlmPlanner::parse_json_lenient(text).unwrap();
644        assert_eq!(v["a"], 1);
645    }
646
647    #[test]
648    fn test_parse_json_lenient_with_surrounding_prose() {
649        let text = "Here is the plan:\n{\"goal\": \"test\"}\nDone.";
650        let v: serde_json::Value = LlmPlanner::parse_json_lenient(text).unwrap();
651        assert_eq!(v["goal"], "test");
652    }
653
654    #[test]
655    fn test_parse_json_lenient_brace_inside_string_value() {
656        // The old naive first-`{`/last-`}` slice broke when a string value
657        // contained a `}` followed by trailing prose; the robust extractor
658        // balances braces while respecting string boundaries.
659        let text = "Result: {\"note\": \"use a closing brace } here\"} -- end.";
660        let v: serde_json::Value = LlmPlanner::parse_json_lenient(text).unwrap();
661        assert_eq!(v["note"], "use a closing brace } here");
662    }
663
664    #[test]
665    fn test_parse_json_lenient_fenced_with_trailing_prose() {
666        // ```json fence followed by an explanation. The naive parser's
667        // `rfind('}')` could grab a brace from the trailing prose.
668        let text = "```json\n{\"goal\": \"ship\"}\n```\nNote: revisit the `plan` later.";
669        let v: serde_json::Value = LlmPlanner::parse_json_lenient(text).unwrap();
670        assert_eq!(v["goal"], "ship");
671    }
672
673    #[test]
674    fn test_parse_json_lenient_rejects_non_json() {
675        let err = LlmPlanner::parse_json_lenient::<serde_json::Value>("no json here at all");
676        assert!(err.is_err());
677    }
678
679    /// Replays a fixed sequence of assistant text responses, one per call.
680    struct ReplayClient {
681        responses: std::sync::Mutex<Vec<String>>,
682    }
683
684    impl ReplayClient {
685        fn new(responses: Vec<String>) -> Self {
686            Self {
687                responses: std::sync::Mutex::new(responses),
688            }
689        }
690    }
691
692    #[async_trait::async_trait]
693    impl LlmClient for ReplayClient {
694        async fn complete(
695            &self,
696            _messages: &[Message],
697            _system: Option<&str>,
698            _tools: &[crate::llm::ToolDefinition],
699        ) -> anyhow::Result<crate::llm::LlmResponse> {
700            let text = {
701                let mut r = self.responses.lock().unwrap();
702                if r.is_empty() {
703                    String::new()
704                } else {
705                    r.remove(0)
706                }
707            };
708            Ok(crate::llm::LlmResponse {
709                message: Message {
710                    role: "assistant".to_string(),
711                    content: vec![crate::llm::ContentBlock::Text { text }],
712                    reasoning_content: None,
713                },
714                usage: crate::llm::TokenUsage::default(),
715                stop_reason: None,
716                token_logprobs: Vec::new(),
717                meta: None,
718            })
719        }
720
721        async fn complete_streaming(
722            &self,
723            _messages: &[Message],
724            _system: Option<&str>,
725            _tools: &[crate::llm::ToolDefinition],
726            _cancel_token: tokio_util::sync::CancellationToken,
727        ) -> anyhow::Result<tokio::sync::mpsc::Receiver<crate::llm::StreamEvent>> {
728            anyhow::bail!("streaming not used in planner tests")
729        }
730    }
731
732    #[tokio::test]
733    async fn test_pre_analyze_repairs_invalid_json() {
734        // First response is unparseable; pre_analyze must re-prompt and succeed
735        // on the second (valid) response.
736        let good = r#"{"intent":"explore","requires_planning":false,"goal":{"description":"Do x","success_criteria":["done"]},"execution_plan":{"complexity":"Simple","steps":[],"required_tools":[]},"optimized_input":"Do x carefully"}"#;
737        let client: Arc<dyn LlmClient> = Arc::new(ReplayClient::new(vec![
738            "Sorry — here's the plan, but not as JSON.".to_string(),
739            good.to_string(),
740        ]));
741        let pa = LlmPlanner::pre_analyze(&client, "do x").await.unwrap();
742        assert_eq!(pa.optimized_input, "Do x carefully");
743    }
744
745    #[tokio::test]
746    async fn test_pre_analyze_first_try_with_fenced_json() {
747        // A single ```json-fenced response must parse on the first attempt
748        // (robust extractor), with no repair round needed.
749        let good = format!(
750            "```json\n{}\n```",
751            r#"{"intent":"plan","requires_planning":true,"goal":{"description":"g","success_criteria":[]},"execution_plan":{"complexity":"Medium","steps":[],"required_tools":[]},"optimized_input":"opt"}"#
752        );
753        let client: Arc<dyn LlmClient> = Arc::new(ReplayClient::new(vec![good]));
754        let pa = LlmPlanner::pre_analyze(&client, "do x").await.unwrap();
755        assert_eq!(pa.optimized_input, "opt");
756    }
757}