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 minimal fallback plan when explicit planning cannot use the LLM.
156    ///
157    /// The original request is the only honest executable step available here.
158    /// Fabricating numbered placeholder steps makes the task tracker look active
159    /// without conveying useful progress.
160    pub fn fallback_plan(prompt: &str) -> ExecutionPlan {
161        let content = match prompt.trim() {
162            "" => "Complete the requested task",
163            prompt => prompt,
164        };
165        let mut plan = ExecutionPlan::new(content, Complexity::Simple);
166        plan.add_step(Task::new("step-1", content));
167
168        plan
169    }
170
171    /// Create a fallback goal using heuristic logic (no LLM required)
172    pub fn fallback_goal(prompt: &str) -> AgentGoal {
173        AgentGoal::new(prompt).with_criteria(vec![
174            "Task is completed successfully".to_string(),
175            "All requirements are met".to_string(),
176        ])
177    }
178
179    /// Create a fail-closed fallback achievement result (no LLM required).
180    ///
181    /// Goal completion is a control-plane decision: a host may stop an
182    /// unbounded engineering loop as soon as `GoalAchieved` is emitted. Plain
183    /// answer text such as "done" (or even "not complete") is not sufficient
184    /// evidence for that decision. If the structured evaluator is unavailable,
185    /// keep every success criterion open and let the host retry in a later
186    /// iteration.
187    pub fn fallback_check_achievement(goal: &AgentGoal, current_state: &str) -> AchievementResult {
188        let _ = current_state;
189
190        AchievementResult {
191            achieved: false,
192            progress: goal.progress,
193            remaining_criteria: goal.success_criteria.clone(),
194        }
195    }
196
197    /// Perform pre-analysis in a single LLM call: intent classification, goal extraction,
198    /// execution plan, and input optimization. Falls back to heuristics on failure.
199    pub async fn pre_analyze(llm: &Arc<dyn LlmClient>, prompt: &str) -> Result<PreAnalysis> {
200        let req = StructuredRequest {
201            prompt: format!(
202                "Analyze this user request and return a compact pre-analysis object. \
203                 Use at most 5 execution steps.\n\nUser request:\n{prompt}"
204            ),
205            system: Some(crate::prompts::PRE_ANALYSIS_SYSTEM.to_string()),
206            schema: Self::pre_analysis_schema(),
207            schema_name: "pre_analysis".to_string(),
208            schema_description: Some(
209                "Intent, goal, plan, and optimized input for an agent turn".to_string(),
210            ),
211            mode: StructuredMode::Auto,
212            max_repair_attempts: 2,
213        };
214
215        let result = generate_blocking(&**llm, &req)
216            .await
217            .context("LLM pre-analysis structured generation failed")?;
218
219        Self::pre_analysis_from_value(result.object, prompt)
220            .context("Failed to parse pre-analysis JSON from LLM response")
221    }
222
223    fn pre_analysis_from_value(
224        value: serde_json::Value,
225        original_prompt: &str,
226    ) -> Result<PreAnalysis> {
227        let parsed: PreAnalysisResponse = serde_json::from_value(value)
228            .context("pre-analysis object did not match the expected response shape")?;
229        Self::pre_analysis_from_response(parsed, original_prompt)
230    }
231
232    fn pre_analysis_from_response(
233        parsed: PreAnalysisResponse,
234        original_prompt: &str,
235    ) -> Result<PreAnalysis> {
236        let intent = match parsed.intent.to_lowercase().as_str() {
237            "plan" => crate::prompts::AgentStyle::Plan,
238            "explore" => crate::prompts::AgentStyle::Explore,
239            "verification" => crate::prompts::AgentStyle::Verification,
240            "codereview" | "code review" => crate::prompts::AgentStyle::CodeReview,
241            _ => crate::prompts::AgentStyle::GeneralPurpose,
242        };
243
244        let goal_description = parsed.goal.description.clone();
245        let goal =
246            AgentGoal::new(goal_description.clone()).with_criteria(parsed.goal.success_criteria);
247
248        let complexity = match parsed.execution_plan.complexity.as_str() {
249            "Simple" => Complexity::Simple,
250            "Medium" => Complexity::Medium,
251            "Complex" => Complexity::Complex,
252            "VeryComplex" => Complexity::VeryComplex,
253            _ => Complexity::Medium,
254        };
255
256        let mut plan = ExecutionPlan::new(goal_description, complexity);
257        for step_resp in parsed.execution_plan.steps {
258            let mut task = Task::new(step_resp.id, step_resp.description);
259            if let Some(tool) = step_resp.tool {
260                task = task.with_tool(tool);
261            }
262            if !step_resp.dependencies.is_empty() {
263                task = task.with_dependencies(step_resp.dependencies);
264            }
265            if let Some(criteria) = step_resp.success_criteria {
266                task = task.with_success_criteria(criteria);
267            }
268            plan.add_step(task);
269        }
270        for tool in parsed.execution_plan.required_tools {
271            plan.add_required_tool(tool);
272        }
273
274        Ok(PreAnalysis {
275            intent,
276            requires_planning: parsed.requires_planning,
277            goal,
278            execution_plan: plan,
279            optimized_input: if parsed.optimized_input.is_empty() {
280                original_prompt.to_string()
281            } else {
282                parsed.optimized_input
283            },
284        })
285    }
286
287    fn pre_analysis_schema() -> serde_json::Value {
288        serde_json::json!({
289            "type": "object",
290            "required": ["intent", "requires_planning", "goal", "execution_plan", "optimized_input"],
291            "properties": {
292                "intent": { "type": "string" },
293                "requires_planning": { "type": "boolean" },
294                "goal": {
295                    "type": "object",
296                    "required": ["description", "success_criteria"],
297                    "properties": {
298                        "description": { "type": "string", "minLength": 1 },
299                        "success_criteria": {
300                            "type": "array",
301                            "items": { "type": "string" }
302                        }
303                    }
304                },
305                "execution_plan": {
306                    "type": "object",
307                    "required": ["complexity", "steps"],
308                    "properties": {
309                        "complexity": { "type": "string" },
310                        "steps": {
311                            "type": "array",
312                            "items": {
313                                "type": "object",
314                                "required": ["id", "description"],
315                                "properties": {
316                                    "id": { "type": "string" },
317                                    "description": { "type": "string" },
318                                    "tool": { "type": "string" },
319                                    "dependencies": {
320                                        "type": "array",
321                                        "items": { "type": "string" }
322                                    },
323                                    "success_criteria": { "type": "string" }
324                                }
325                            }
326                        },
327                        "required_tools": {
328                            "type": "array",
329                            "items": { "type": "string" }
330                        }
331                    }
332                },
333                "optimized_input": { "type": "string" }
334            }
335        })
336    }
337
338    fn achievement_schema() -> serde_json::Value {
339        serde_json::json!({
340            "type": "object",
341            "additionalProperties": false,
342            "required": ["achieved", "progress", "remaining_criteria"],
343            "properties": {
344                "achieved": { "type": "boolean" },
345                "progress": {
346                    "type": "number",
347                    "minimum": 0.0,
348                    "maximum": 1.0
349                },
350                "remaining_criteria": {
351                    "type": "array",
352                    "items": { "type": "string" }
353                }
354            }
355        })
356    }
357
358    fn achievement_from_value(value: serde_json::Value) -> Result<AchievementResult> {
359        let parsed: AchievementResponse = serde_json::from_value(value)
360            .context("achievement object did not match the expected response shape")?;
361        Ok(AchievementResult {
362            achieved: parsed.achieved,
363            progress: parsed.progress.clamp(0.0, 1.0),
364            remaining_criteria: parsed.remaining_criteria,
365        })
366    }
367
368    // ========================================================================
369    // JSON parsing helpers
370    // ========================================================================
371
372    fn parse_plan_response(text: &str) -> Result<ExecutionPlan> {
373        let parsed: PlanResponse = Self::parse_json_lenient(text)
374            .context("Failed to parse plan JSON from LLM response")?;
375
376        let complexity = match parsed.complexity.as_str() {
377            "Simple" => Complexity::Simple,
378            "Medium" => Complexity::Medium,
379            "Complex" => Complexity::Complex,
380            "VeryComplex" => Complexity::VeryComplex,
381            _ => Complexity::Medium,
382        };
383
384        let mut plan = ExecutionPlan::new(parsed.goal, complexity);
385
386        for step_resp in parsed.steps {
387            let mut task = Task::new(step_resp.id, step_resp.description);
388            if let Some(tool) = step_resp.tool {
389                task = task.with_tool(tool);
390            }
391            if !step_resp.dependencies.is_empty() {
392                task = task.with_dependencies(step_resp.dependencies);
393            }
394            if let Some(criteria) = step_resp.success_criteria {
395                task = task.with_success_criteria(criteria);
396            }
397            plan.add_step(task);
398        }
399
400        for tool in parsed.required_tools {
401            plan.add_required_tool(tool);
402        }
403
404        Ok(plan)
405    }
406
407    fn parse_goal_response(text: &str) -> Result<AgentGoal> {
408        let parsed: GoalResponse = Self::parse_json_lenient(text)
409            .context("Failed to parse goal JSON from LLM response")?;
410
411        Ok(AgentGoal::new(parsed.description).with_criteria(parsed.success_criteria))
412    }
413
414    #[cfg(test)]
415    fn parse_achievement_response(text: &str) -> Result<AchievementResult> {
416        let parsed: AchievementResponse = Self::parse_json_lenient(text)
417            .context("Failed to parse achievement JSON from LLM response")?;
418
419        Ok(AchievementResult {
420            achieved: parsed.achieved,
421            progress: parsed.progress.clamp(0.0, 1.0),
422            remaining_criteria: parsed.remaining_criteria,
423        })
424    }
425
426    /// Parse JSON from possibly-dirty LLM output into the target type.
427    ///
428    /// Uses the shared robust extractor ([`crate::llm::structured::extract_json_value`]),
429    /// which handles ```json fences, surrounding prose, and braces embedded in
430    /// strings — unlike the previous naive first-`{`/last-`}` slice, which broke
431    /// on fenced output or any `}` inside a string value.
432    fn parse_json_lenient<T: serde::de::DeserializeOwned>(text: &str) -> Result<T> {
433        let value = crate::llm::structured::extract_json_value(text)?;
434        Ok(serde_json::from_value(value)?)
435    }
436}
437
438// ============================================================================
439// Tests
440// ============================================================================
441
442#[cfg(test)]
443mod tests {
444    use super::*;
445
446    #[test]
447    fn test_parse_plan_response() {
448        let json = r#"{
449            "goal": "Build a REST API",
450            "complexity": "Complex",
451            "steps": [
452                {
453                    "id": "step-1",
454                    "description": "Set up project structure",
455                    "tool": "bash",
456                    "dependencies": [],
457                    "success_criteria": "Project directory created"
458                },
459                {
460                    "id": "step-2",
461                    "description": "Implement endpoints",
462                    "tool": "write",
463                    "dependencies": ["step-1"],
464                    "success_criteria": "Endpoints respond correctly"
465                }
466            ],
467            "required_tools": ["bash", "write", "read"]
468        }"#;
469
470        let plan = LlmPlanner::parse_plan_response(json).unwrap();
471        assert_eq!(plan.goal, "Build a REST API");
472        assert_eq!(plan.complexity, Complexity::Complex);
473        assert_eq!(plan.steps.len(), 2);
474        assert_eq!(plan.steps[0].id, "step-1");
475        assert_eq!(plan.steps[0].tool, Some("bash".to_string()));
476        assert_eq!(plan.steps[1].dependencies, vec!["step-1".to_string()]);
477        assert_eq!(plan.required_tools, vec!["bash", "write", "read"]);
478    }
479
480    #[test]
481    fn test_parse_plan_response_with_markdown_fences() {
482        let json = "```json\n{\"goal\": \"Test\", \"complexity\": \"Simple\", \"steps\": [{\"id\": \"step-1\", \"description\": \"Do it\"}], \"required_tools\": []}\n```";
483
484        let plan = LlmPlanner::parse_plan_response(json).unwrap();
485        assert_eq!(plan.goal, "Test");
486        assert_eq!(plan.complexity, Complexity::Simple);
487        assert_eq!(plan.steps.len(), 1);
488    }
489
490    #[test]
491    fn test_parse_plan_response_invalid() {
492        let bad_json = "This is not JSON at all";
493        let result = LlmPlanner::parse_plan_response(bad_json);
494        assert!(result.is_err());
495    }
496
497    #[test]
498    fn test_parse_plan_response_unknown_complexity() {
499        let json =
500            r#"{"goal": "Test", "complexity": "Unknown", "steps": [], "required_tools": []}"#;
501        let plan = LlmPlanner::parse_plan_response(json).unwrap();
502        assert_eq!(plan.complexity, Complexity::Medium); // falls back to Medium
503    }
504
505    #[test]
506    fn test_parse_goal_response() {
507        let json = r#"{
508            "description": "Deploy the application to production",
509            "success_criteria": [
510                "All tests pass",
511                "Application is accessible at production URL",
512                "Health check returns 200"
513            ]
514        }"#;
515
516        let goal = LlmPlanner::parse_goal_response(json).unwrap();
517        assert_eq!(goal.description, "Deploy the application to production");
518        assert_eq!(goal.success_criteria.len(), 3);
519        assert_eq!(goal.success_criteria[0], "All tests pass");
520    }
521
522    #[test]
523    fn test_parse_goal_response_invalid() {
524        let result = LlmPlanner::parse_goal_response("not json");
525        assert!(result.is_err());
526    }
527
528    #[test]
529    fn test_parse_achievement_response() {
530        let json = r#"{
531            "achieved": false,
532            "progress": 0.65,
533            "remaining_criteria": ["Health check not verified"]
534        }"#;
535
536        let result = LlmPlanner::parse_achievement_response(json).unwrap();
537        assert!(!result.achieved);
538        assert!((result.progress - 0.65).abs() < f32::EPSILON);
539        assert_eq!(result.remaining_criteria, vec!["Health check not verified"]);
540    }
541
542    #[test]
543    fn test_parse_achievement_response_achieved() {
544        let json = r#"{"achieved": true, "progress": 1.0, "remaining_criteria": []}"#;
545        let result = LlmPlanner::parse_achievement_response(json).unwrap();
546        assert!(result.achieved);
547        assert!((result.progress - 1.0).abs() < f32::EPSILON);
548        assert!(result.remaining_criteria.is_empty());
549    }
550
551    #[test]
552    fn test_parse_achievement_response_clamps_progress() {
553        let json = r#"{"achieved": false, "progress": 1.5, "remaining_criteria": []}"#;
554        let result = LlmPlanner::parse_achievement_response(json).unwrap();
555        assert!((result.progress - 1.0).abs() < f32::EPSILON);
556    }
557
558    #[test]
559    fn test_fallback_plan() {
560        let short_prompt = "Fix bug";
561        let plan = LlmPlanner::fallback_plan(short_prompt);
562        assert_eq!(plan.complexity, Complexity::Simple);
563        assert_eq!(plan.steps.len(), 1);
564        assert_eq!(plan.goal, short_prompt);
565        assert_eq!(plan.steps[0].content, short_prompt);
566
567        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";
568        let plan = LlmPlanner::fallback_plan(long_prompt);
569        assert_eq!(plan.complexity, Complexity::Simple);
570        assert_eq!(plan.steps.len(), 1);
571        assert_eq!(plan.steps[0].content, long_prompt);
572        assert!(
573            !plan.steps[0].content.contains("Execute step"),
574            "fallback plans must not expose placeholder task text"
575        );
576
577        let plan = LlmPlanner::fallback_plan("   ");
578        assert_eq!(plan.goal, "Complete the requested task");
579        assert_eq!(plan.steps[0].content, "Complete the requested task");
580    }
581
582    #[test]
583    fn test_fallback_goal() {
584        let goal = LlmPlanner::fallback_goal("Fix the login bug");
585        assert_eq!(goal.description, "Fix the login bug");
586        assert_eq!(goal.success_criteria.len(), 2);
587        assert_eq!(goal.success_criteria[0], "Task is completed successfully");
588    }
589
590    #[test]
591    fn test_fallback_check_achievement_fails_closed_on_done_text() {
592        let goal = AgentGoal::new("Test task").with_criteria(vec!["Criterion 1".to_string()]);
593
594        let result = LlmPlanner::fallback_check_achievement(&goal, "The task is done.");
595        assert!(!result.achieved);
596        assert!(result.progress.abs() < f32::EPSILON);
597        assert_eq!(result.remaining_criteria, vec!["Criterion 1"]);
598    }
599
600    #[test]
601    fn test_fallback_check_achievement_rejects_negated_completion_text() {
602        let goal = AgentGoal::new("Test task").with_criteria(vec!["Criterion 1".to_string()]);
603
604        for state in [
605            "The task is not complete.",
606            "Verification is not done.",
607            "The implementation is unfinished.",
608        ] {
609            let result = LlmPlanner::fallback_check_achievement(&goal, state);
610            assert!(!result.achieved, "must fail closed for {state:?}");
611            assert_eq!(result.remaining_criteria, vec!["Criterion 1"]);
612        }
613    }
614
615    #[test]
616    fn test_fallback_check_achievement_not_done() {
617        let goal = AgentGoal::new("Test task")
618            .with_criteria(vec!["Criterion 1".to_string(), "Criterion 2".to_string()]);
619
620        let result = LlmPlanner::fallback_check_achievement(&goal, "Work in progress");
621        assert!(!result.achieved);
622        assert_eq!(result.remaining_criteria.len(), 2);
623    }
624
625    #[test]
626    fn test_parse_json_lenient_plain() {
627        let v: serde_json::Value = LlmPlanner::parse_json_lenient("  {\"a\": 1}  ").unwrap();
628        assert_eq!(v["a"], 1);
629    }
630
631    #[test]
632    fn test_parse_json_lenient_with_fences() {
633        let text = "```json\n{\"a\": 1}\n```";
634        let v: serde_json::Value = LlmPlanner::parse_json_lenient(text).unwrap();
635        assert_eq!(v["a"], 1);
636    }
637
638    #[test]
639    fn test_parse_json_lenient_with_surrounding_prose() {
640        let text = "Here is the plan:\n{\"goal\": \"test\"}\nDone.";
641        let v: serde_json::Value = LlmPlanner::parse_json_lenient(text).unwrap();
642        assert_eq!(v["goal"], "test");
643    }
644
645    #[test]
646    fn test_parse_json_lenient_brace_inside_string_value() {
647        // The old naive first-`{`/last-`}` slice broke when a string value
648        // contained a `}` followed by trailing prose; the robust extractor
649        // balances braces while respecting string boundaries.
650        let text = "Result: {\"note\": \"use a closing brace } here\"} -- end.";
651        let v: serde_json::Value = LlmPlanner::parse_json_lenient(text).unwrap();
652        assert_eq!(v["note"], "use a closing brace } here");
653    }
654
655    #[test]
656    fn test_parse_json_lenient_fenced_with_trailing_prose() {
657        // ```json fence followed by an explanation. The naive parser's
658        // `rfind('}')` could grab a brace from the trailing prose.
659        let text = "```json\n{\"goal\": \"ship\"}\n```\nNote: revisit the `plan` later.";
660        let v: serde_json::Value = LlmPlanner::parse_json_lenient(text).unwrap();
661        assert_eq!(v["goal"], "ship");
662    }
663
664    #[test]
665    fn test_parse_json_lenient_rejects_non_json() {
666        let err = LlmPlanner::parse_json_lenient::<serde_json::Value>("no json here at all");
667        assert!(err.is_err());
668    }
669
670    /// Replays a fixed sequence of assistant text responses, one per call.
671    struct ReplayClient {
672        responses: std::sync::Mutex<Vec<String>>,
673    }
674
675    impl ReplayClient {
676        fn new(responses: Vec<String>) -> Self {
677            Self {
678                responses: std::sync::Mutex::new(responses),
679            }
680        }
681    }
682
683    #[async_trait::async_trait]
684    impl LlmClient for ReplayClient {
685        async fn complete(
686            &self,
687            _messages: &[Message],
688            _system: Option<&str>,
689            _tools: &[crate::llm::ToolDefinition],
690        ) -> anyhow::Result<crate::llm::LlmResponse> {
691            let text = {
692                let mut r = self.responses.lock().unwrap();
693                if r.is_empty() {
694                    String::new()
695                } else {
696                    r.remove(0)
697                }
698            };
699            Ok(crate::llm::LlmResponse {
700                message: Message {
701                    role: "assistant".to_string(),
702                    content: vec![crate::llm::ContentBlock::Text { text }],
703                    reasoning_content: None,
704                },
705                usage: crate::llm::TokenUsage::default(),
706                stop_reason: None,
707                token_logprobs: Vec::new(),
708                meta: None,
709            })
710        }
711
712        async fn complete_streaming(
713            &self,
714            _messages: &[Message],
715            _system: Option<&str>,
716            _tools: &[crate::llm::ToolDefinition],
717            _cancel_token: tokio_util::sync::CancellationToken,
718        ) -> anyhow::Result<tokio::sync::mpsc::Receiver<crate::llm::StreamEvent>> {
719            anyhow::bail!("streaming not used in planner tests")
720        }
721    }
722
723    #[tokio::test]
724    async fn test_pre_analyze_repairs_invalid_json() {
725        // First response is unparseable; pre_analyze must re-prompt and succeed
726        // on the second (valid) response.
727        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"}"#;
728        let client: Arc<dyn LlmClient> = Arc::new(ReplayClient::new(vec![
729            "Sorry — here's the plan, but not as JSON.".to_string(),
730            good.to_string(),
731        ]));
732        let pa = LlmPlanner::pre_analyze(&client, "do x").await.unwrap();
733        assert_eq!(pa.optimized_input, "Do x carefully");
734    }
735
736    #[tokio::test]
737    async fn test_pre_analyze_first_try_with_fenced_json() {
738        // A single ```json-fenced response must parse on the first attempt
739        // (robust extractor), with no repair round needed.
740        let good = format!(
741            "```json\n{}\n```",
742            r#"{"intent":"plan","requires_planning":true,"goal":{"description":"g","success_criteria":[]},"execution_plan":{"complexity":"Medium","steps":[],"required_tools":[]},"optimized_input":"opt"}"#
743        );
744        let client: Arc<dyn LlmClient> = Arc::new(ReplayClient::new(vec![good]));
745        let pa = LlmPlanner::pre_analyze(&client, "do x").await.unwrap();
746        assert_eq!(pa.optimized_input, "opt");
747    }
748}