Skip to main content

luft_planner/
lib.rs

1//! # luft-planner
2//!
3//! **Natural-language → Lua orchestration script planner.**
4//!
5//! Instead of classifying the task with keywords and filling fixed templates,
6//! the planner asks an LLM *agent* to generate a Lua orchestration script for
7//! the task. The model acts as a compiler (NL → DSL); the runtime then executes
8//! the script deterministically.
9//!
10//! ## Flow
11//!
12//! ```text
13//!  User NL task
14//!       │
15//!       ▼
16//!  ┌─────────────────┐     ┌──────────────────┐
17//!  │ LUA_DSL_REFERENCE│ ──► │  Planner Agent   │
18//!  │ (system prompt)  │     │  (backend.run)   │
19//!  └─────────────────┘     └────────┬─────────┘
20//!                                    │
21//!                            ┌───────▼────────┐
22//!                            │ validate_script │ ── fail ──► retry (up to max_retries)
23//!                            └───────┬────────┘
24//!                                    │ pass
25//!                            ┌───────▼────────┐
26//!                            │ PlannedWorkflow │
27//!                            │  (script+meta)  │
28//!                            └────────────────┘
29//! ```
30//!
31//! The generated script orchestrates only — it never touches the filesystem or
32//! shell (the Lua sandbox forbids `io`/`os`). All real work — reading files,
33//! grepping, editing, web search — happens inside the `agent()` prompts the
34//! script spawns at runtime.
35
36use luft_core::contract::backend::{AgentBackend, AgentTask, RunContext};
37use luft_core::contract::event::AgentEvent;
38use luft_runtime::{validate_script, validate_workflow};
39use std::path::PathBuf;
40use std::sync::Arc;
41
42pub mod meta;
43pub use meta::{
44    extract_meta, validate_meta as validate_meta_extracted, MetaPhase, MetaValidation, PlanMeta,
45};
46
47/// Planning configuration.
48#[derive(Debug, Clone)]
49pub struct PlannerConfig {
50    /// Model used by the planner agent (`None` = backend default).
51    pub planner_model: Option<String>,
52    /// Max attempts to (re)generate a valid script before giving up.
53    pub max_retries: usize,
54    /// When true, the planner also generates a `.mock.json` companion block.
55    pub generate_mock: bool,
56}
57
58impl Default for PlannerConfig {
59    fn default() -> Self {
60        Self {
61            planner_model: None,
62            max_retries: 3,
63            generate_mock: false,
64        }
65    }
66}
67
68/// A planned workflow: the generated Lua script + its extracted metadata.
69#[derive(Debug, Clone)]
70pub struct PlannedWorkflow {
71    /// The generated Lua script (validated, fence-stripped).
72    pub script: String,
73    /// Mock data for `--with-mock` runs (`None` when mock generation is off).
74    pub mock_data: Option<serde_json::Value>,
75    /// Extracted phase structure (`meta = {...}`), if the script declared one.
76    pub meta: Option<PlanMeta>,
77}
78
79/// Planner errors.
80#[derive(thiserror::Error, Debug)]
81pub enum PlannerError {
82    /// The backend agent call itself failed.
83    #[error("planner backend error: {0}")]
84    Backend(String),
85    /// Ran out of attempts without producing a valid script.
86    #[error("planner exhausted {attempts} attempt(s); last error: {last_error}")]
87    ExhaustedRetries { attempts: usize, last_error: String },
88}
89
90/// Generate a Lua orchestration script for `task` by asking the backend agent.
91///
92/// Retries up to `cfg.max_retries` times, feeding the validation error back to
93/// the agent so it can self-correct.
94pub async fn plan_workflow(
95    task: &str,
96    backend: Arc<dyn AgentBackend>,
97    cfg: &PlannerConfig,
98) -> Result<PlannedWorkflow, PlannerError> {
99    let attempts = cfg.max_retries.max(1);
100    let mut last_error = String::new();
101
102    for attempt in 0..attempts {
103        if attempt > 0 {
104            tracing::warn!(
105                attempt,
106                total = attempts,
107                "retrying script generation after validation failure"
108            );
109        }
110
111        let prompt = build_prompt(
112            task,
113            (attempt > 0).then_some(last_error.as_str()),
114            cfg.generate_mock,
115        );
116
117        let output = run_planner_agent(&*backend, &prompt, cfg.planner_model.clone())
118            .await
119            .map_err(PlannerError::Backend)?;
120
121        let script = match extract_script(&output) {
122            Some(s) => s,
123            None => {
124                tracing::warn!(attempt, "agent output contained no lua code block");
125                last_error = "no ```lua code block (or text) found in agent output".to_string();
126                continue;
127            }
128        };
129
130        match validate_generated(&script) {
131            Ok(()) => {
132                let mock_data = if cfg.generate_mock {
133                    let mock = extract_mock_block(&output);
134                    if mock.is_none() {
135                        tracing::warn!(attempt, "--with-mock requested but no mock block found");
136                    }
137                    mock
138                } else {
139                    None
140                };
141                let meta = match meta::extract_meta(&script) {
142                    Ok(Some(m)) => {
143                        let v = meta::validate_meta(&m, &script);
144                        for w in &v.warnings {
145                            tracing::warn!(warning = %w, "planner meta validation warning");
146                        }
147                        if !v.is_valid() {
148                            last_error = format!("meta validation failed: {}", v.errors.join("; "));
149                            continue;
150                        }
151                        Some(m)
152                    }
153                    Ok(None) => None,
154                    Err(e) => {
155                        tracing::warn!(error = %e, "meta extraction failed; ignoring");
156                        None
157                    }
158                };
159                return Ok(PlannedWorkflow {
160                    script,
161                    mock_data,
162                    meta,
163                });
164            }
165            Err(e) => {
166                tracing::warn!(attempt, error = %e, "generated script failed validation");
167                last_error = e;
168            }
169        }
170    }
171
172    Err(PlannerError::ExhaustedRetries {
173        attempts,
174        last_error,
175    })
176}
177
178/// Run a single planning agent through the backend, returning its raw output.
179async fn run_planner_agent(
180    backend: &dyn AgentBackend,
181    prompt: &str,
182    model: Option<String>,
183) -> Result<serde_json::Value, String> {
184    // The planner needs a one-shot RunContext; events are discarded.
185    let (events, _rx) = tokio::sync::broadcast::channel::<AgentEvent>(16);
186    let ctx = RunContext {
187        run_id: uuid::Uuid::now_v7(),
188        cancel: tokio_util::sync::CancellationToken::new(),
189        events,
190    };
191    let task = AgentTask {
192        agent_id: uuid::Uuid::now_v7(),
193        phase_id: 0,
194        prompt: prompt.to_string(),
195        model,
196        description: None,
197        role: None,
198        name: None,
199        agent_seq: 0,
200        allowlist: None,
201        workdir: PathBuf::from("."),
202        mcp_endpoint: None,
203        timeout: None,
204        output_schema: None,
205        workdir_override: None,
206    };
207    backend
208        .run(task, ctx)
209        .await
210        .map(|r| r.output)
211        .map_err(|e| e.to_string())
212}
213
214/// Validate a generated script: syntax + structure + heuristic checks.
215fn validate_generated(script: &str) -> Result<(), String> {
216    let result = validate_workflow(script).map_err(|e| format!("lua validation error: {}", e))?;
217    if result.errors.is_empty() {
218        Ok(())
219    } else {
220        Err(result.errors.join("; "))
221    }
222}
223
224/// Extract the ```json block from planner output (for mock data).
225fn extract_mock_block(output: &serde_json::Value) -> Option<serde_json::Value> {
226    let text = output_to_text(output)?;
227    let json_str = find_fenced_block_by_lang(&text, "json")?;
228    let trimmed = json_str.trim();
229    if trimmed.is_empty() {
230        return None;
231    }
232    serde_json::from_str(trimmed).ok()
233}
234
235/// Find a fenced code block by exact language tag.
236fn find_fenced_block_by_lang(text: &str, lang: &str) -> Option<String> {
237    let marker = format!("```{}", lang);
238    let mut from = 0;
239    while let Some(rel) = text[from..].find(&marker) {
240        let fence = from + rel;
241        let after = &text[fence + marker.len()..];
242        let line_end = match after.find('\n') {
243            Some(n) => n,
244            None => {
245                from = fence + marker.len();
246                continue;
247            }
248        };
249        let body_start = fence + marker.len() + line_end + 1;
250        let rest = &text[body_start..];
251        let close = match rest.find("```") {
252            Some(c) => c,
253            None => {
254                from = body_start;
255                continue;
256            }
257        };
258        return Some(rest[..close].trim_end().to_string());
259    }
260    None
261}
262
263/// Coerce the agent output into text and pull out the Lua script.
264fn extract_script(output: &serde_json::Value) -> Option<String> {
265    let text = output_to_text(output)?;
266    let block = extract_lua_block(&text);
267    if block.trim().is_empty() {
268        None
269    } else {
270        Some(block)
271    }
272}
273
274/// Best-effort extraction of assistant text from a backend's structured output.
275///
276/// The exact shape depends on the backend (real ACP backends land in P0-A); this
277/// handles the common cases and falls back to a JSON stringification.
278fn output_to_text(output: &serde_json::Value) -> Option<String> {
279    match output {
280        serde_json::Value::String(s) => Some(s.clone()),
281        serde_json::Value::Object(map) => {
282            for key in ["script", "message", "text", "content", "output"] {
283                if let Some(serde_json::Value::String(s)) = map.get(key) {
284                    return Some(s.clone());
285                }
286            }
287            Some(output.to_string())
288        }
289        serde_json::Value::Null => None,
290        other => Some(other.to_string()),
291    }
292}
293
294/// Return the first ```lua (or unlabelled) fenced block; otherwise try to
295/// strip surrounding prose from the trimmed input.
296fn extract_lua_block(text: &str) -> String {
297    if let Some(block) = find_fenced_block(text) {
298        return block;
299    }
300    let trimmed = text.trim();
301    if validate_script(trimmed).is_ok() {
302        return trimmed.to_string();
303    }
304    strip_prose(trimmed)
305}
306
307/// Heuristically strip leading/trailing prose lines that aren't valid Lua.
308/// Keeps lines from the first Lua-looking line to the last.
309fn strip_prose(text: &str) -> String {
310    let lines: Vec<&str> = text.lines().collect();
311    let lua_start = lines.iter().position(|l| looks_like_lua(l)).unwrap_or(0);
312    let lua_end = lines
313        .iter()
314        .rposition(|l| looks_like_lua(l))
315        .map(|i| i + 1)
316        .unwrap_or(lines.len());
317    lines[lua_start.min(lua_end.max(1))..lua_end.max(lines.len().min(lua_start + 1))]
318        .join("\n")
319        .trim()
320        .to_string()
321}
322
323/// Check if a line looks like it could be Lua code (not prose).
324fn looks_like_lua(line: &str) -> bool {
325    let t = line.trim();
326    if t.is_empty() {
327        return false;
328    }
329    if t.starts_with("--") {
330        return true;
331    }
332    const KEYWORDS: &[&str] = &[
333        "local", "phase", "report", "agent", "parallel", "pipeline", "for", "if", "while",
334        "function", "return", "log", "budget", "workflow", "json", "do", "end", "else", "elseif",
335        "then", "and", "or", "not", "true", "false", "nil", "args", "ctx",
336    ];
337    let first_word = t
338        .split(|c: char| !c.is_alphanumeric() && c != '_')
339        .next()
340        .unwrap_or("");
341    KEYWORDS.contains(&first_word)
342        || t.starts_with('{')
343        || t.starts_with('}')
344        || t.starts_with('(')
345        || t.starts_with(')')
346        || t.starts_with('"')
347        || t.starts_with('\'')
348        || t.starts_with('[')
349        || t.starts_with(']')
350        || t.starts_with('=')
351        || t.starts_with('.')
352        || t.starts_with(':')
353        || t.starts_with('#')
354}
355
356fn find_fenced_block(text: &str) -> Option<String> {
357    let mut from = 0;
358    while let Some(rel) = text[from..].find("```") {
359        let fence = from + rel;
360        let after = &text[fence + 3..];
361        let line_end = match after.find('\n') {
362            Some(n) => n,
363            None => {
364                from = fence + 3;
365                continue;
366            }
367        };
368        let lang = after[..line_end].trim();
369        let body_start = fence + 3 + line_end + 1;
370        let rest = &text[body_start..];
371        let close = match rest.find("```") {
372            Some(c) => c,
373            None => {
374                from = body_start;
375                continue;
376            }
377        };
378        if lang.eq_ignore_ascii_case("lua") || lang.is_empty() {
379            return Some(rest[..close].trim_end().to_string());
380        }
381        from = body_start + close + 3;
382    }
383    None
384}
385
386/// Build the planner prompt: DSL reference + task (+ optional fix-up error).
387fn build_prompt(task: &str, fix_error: Option<&str>, generate_mock: bool) -> String {
388    let mut p = String::with_capacity(LUA_DSL_REFERENCE.len() + task.len() + 1024);
389    p.push_str(LUA_DSL_REFERENCE);
390    p.push_str("\n\n# Task\n\n");
391    p.push_str(task);
392    p.push('\n');
393    if let Some(err) = fix_error {
394        p.push_str("\n# Your previous attempt was rejected\n\n");
395        p.push_str(err);
396        p.push_str("\n\nFix the script and output a corrected version.\n");
397    }
398    if generate_mock {
399        p.push_str(MOCK_GENERATION_INSTRUCTIONS);
400    }
401    p.push_str("\nOutput ONLY one ```lua code block");
402    if generate_mock {
403        p.push_str(" followed by one ```json code block");
404    }
405    p.push_str(" — no prose before or after.\n");
406    p
407}
408
409const MOCK_GENERATION_INSTRUCTIONS: &str = r#"
410
411# Mock Data Generation
412
413In ADDITION to the ```lua block, output a second ```json block with mock
414responses for EVERY named agent call. Format:
415
416```json
417{
418  "responses": {
419    "<agent_name>": {
420      "output": <representative JSON matching the agent's expected output>,
421      "tokens": {"input": 100, "output": 50},
422      "status": "ok"
423    }
424  },
425  "default": {
426    "output": {"text": "mock response"},
427    "tokens": {"input": 0, "output": 0}
428  }
429}
430```
431
432Rules:
4331. EVERY agent() call in the Lua script MUST include a unique `name=` field.
4342. For parallel() fan-out, all items share ONE name — the mock response is reused.
4353. Mock output MUST match the agent's schema structure if a schema is defined.
4364. Keep mock output concise but structurally valid.
4375. Output BOTH blocks: ```lua first, then ```json.
438"#;
439
440/// The orchestration DSL spec handed to the planner agent.
441///
442/// ## Role
443///
444/// This constant is the **system prompt** for the planner LLM. When a user runs
445/// `luft run --nl "<task>"`, the planner sends this text + the user's task
446/// description to the LLM, and expects a single ```lua code block back. The
447/// returned script is then validated (syntax + `report()` presence + span pairing)
448/// and executed by the sandboxed [`crate::runtime`].
449///
450/// ## Architecture: NL → Lua compilation
451///
452/// ```text
453///   User NL task ──► planner LLM ──► Lua script ──► sandbox execute
454///        │              ▲                              │
455///        │              │                              ▼
456///        └──── LUA_DSL_REFERENCE (this const)     agent() calls ──► scheduler
457///                                                     │
458///                                                     ▼
459///                                                   report()
460/// ```
461///
462/// The LLM acts as a "compiler" from natural language to the Luft Lua DSL.
463/// The script is pure orchestration — no I/O, no filesystem, no shell. All real
464/// work (file reads, grep, edit, web search) happens inside the `agent()` prompts.
465///
466/// ## Keeping in sync
467///
468/// Every primitive documented here MUST be registered in [`luft_runtime::sandbox`]
469/// via `register_sdk()`. If you add a new Lua global, document it here and register
470/// it there. If you remove one, remove it from both places.
471///
472/// ## Token cost
473///
474/// This reference is sent on every planner call (~4K tokens). Keep examples
475/// concise but illustrative. Rules are authoritative — primitives are reference.
476const LUA_DSL_REFERENCE: &str = include_str!("lua_dsl_reference.md");
477
478#[cfg(test)]
479mod tests {
480    use super::*;
481    use luft_core::{FailKind, MockBackend, MockBehavior, TokenUsage};
482    use std::time::Duration;
483
484    fn mock_returning(output: serde_json::Value) -> Arc<dyn AgentBackend> {
485        Arc::new(MockBackend::new(
486            "mock",
487            vec![MockBehavior::Success {
488                output,
489                tokens: TokenUsage::default(),
490                delay: Duration::ZERO,
491            }],
492        ))
493    }
494
495    #[tokio::test]
496    async fn test_plan_extracts_and_validates_script() {
497        let script = "```lua\nmeta = { reasoning = \"test\", phases = {{ label = \"work\" }} }\nfunction main()\n  local r = agent({prompt='hi'})\n  report({ok=true})\nend\n```";
498        let backend = mock_returning(serde_json::json!(script));
499        let planned = plan_workflow("do something", backend, &PlannerConfig::default())
500            .await
501            .unwrap();
502        assert!(planned.script.contains("report("));
503        assert!(!planned.script.contains("```"));
504        assert!(validate_script(&planned.script).is_ok());
505    }
506
507    #[tokio::test]
508    async fn test_plan_retries_on_invalid_then_fails() {
509        // Unbalanced parenthesis → syntax error, repeated for every retry.
510        let bad = "```lua\nlocal x = (\nreport({})\n```";
511        let backend = mock_returning(serde_json::json!(bad));
512        let cfg = PlannerConfig {
513            planner_model: None,
514            max_retries: 2,
515            ..Default::default()
516        };
517        match plan_workflow("x", backend, &cfg).await.unwrap_err() {
518            PlannerError::ExhaustedRetries { attempts, .. } => assert_eq!(attempts, 2),
519            other => panic!("expected ExhaustedRetries, got {:?}", other),
520        }
521    }
522
523    #[tokio::test]
524    async fn test_plan_rejects_missing_report() {
525        // Valid Lua but no report() call → rejected, retries exhausted.
526        let no_report = "```lua\nlocal r = agent({prompt='hi'})\n```";
527        let backend = mock_returning(serde_json::json!(no_report));
528        let cfg = PlannerConfig {
529            planner_model: None,
530            max_retries: 1,
531            ..Default::default()
532        };
533        assert!(matches!(
534            plan_workflow("x", backend, &cfg).await,
535            Err(PlannerError::ExhaustedRetries { .. })
536        ));
537    }
538
539    #[test]
540    fn test_extract_script_fenced_bare_and_object() {
541        // Fenced lua block with surrounding prose.
542        let v = serde_json::json!("prefix\n```lua\nreport({})\n```\nsuffix");
543        assert_eq!(extract_script(&v).unwrap().trim(), "report({})");
544
545        // Bare text (no fence) → trimmed.
546        let v = serde_json::json!("  report({})  ");
547        assert_eq!(extract_script(&v).unwrap(), "report({})");
548
549        // Object with a `message` field.
550        let v = serde_json::json!({ "message": "```lua\nreport({})\n```" });
551        assert_eq!(extract_script(&v).unwrap().trim(), "report({})");
552
553        // Null → None.
554        assert!(extract_script(&serde_json::Value::Null).is_none());
555    }
556
557    #[test]
558    fn test_extract_skips_non_lua_block() {
559        let v = serde_json::json!("```text\nnot code\n```\n```lua\nreport({})\n```");
560        assert_eq!(extract_script(&v).unwrap().trim(), "report({})");
561    }
562
563    // ── Additional coverage: backend error path ──────────────────────────
564
565    #[tokio::test]
566    async fn test_plan_backend_error() {
567        let backend: Arc<dyn AgentBackend> = Arc::new(MockBackend::new(
568            "mock",
569            vec![MockBehavior::fail(FailKind::Protocol)],
570        ));
571        let err = plan_workflow("x", backend, &PlannerConfig::default())
572            .await
573            .unwrap_err();
574        assert!(matches!(err, PlannerError::Backend(_)));
575    }
576
577    // ── Coverage: agent output with no lua block (extract_script → None) ─
578
579    #[tokio::test]
580    async fn test_plan_no_lua_block_retries() {
581        let backend = mock_returning(serde_json::Value::Null);
582        let cfg = PlannerConfig {
583            max_retries: 2,
584            ..Default::default()
585        };
586        let err = plan_workflow("x", backend, &cfg).await.unwrap_err();
587        assert!(matches!(err, PlannerError::ExhaustedRetries { .. }));
588    }
589
590    // ── Coverage: validation failure followed by successful retry ────────
591
592    #[tokio::test]
593    async fn test_plan_retry_then_succeeds() {
594        let valid = "```lua\nmeta = { reasoning = \"test\", phases = {{ label = \"work\" }} }\nfunction main()\n  local r = agent({prompt='hi'})\n  report({ok=true})\nend\n```";
595        let backend: Arc<dyn AgentBackend> = Arc::new(MockBackend::new(
596            "mock",
597            vec![
598                MockBehavior::Success {
599                    output: serde_json::json!("this is pure garbage"),
600                    tokens: TokenUsage::default(),
601                    delay: Duration::ZERO,
602                },
603                MockBehavior::Success {
604                    output: serde_json::json!(valid),
605                    tokens: TokenUsage::default(),
606                    delay: Duration::ZERO,
607                },
608            ],
609        ));
610        let planned = plan_workflow("x", backend, &PlannerConfig::default())
611            .await
612            .unwrap();
613        assert!(planned.script.contains("report({ok=true})"));
614    }
615
616    // ── Coverage: strip_prose edge cases (lines 191, 195, 201) ──────────
617
618    #[test]
619    fn test_strip_prose_edge_cases() {
620        // No lines look like Lua → lua_start = 0, lua_end = lines.len()
621        let s = strip_prose("hello world\nsome prose");
622        assert_eq!(s, "hello world\nsome prose");
623
624        // Lua in middle, prose before/after → slices correctly
625        let s = strip_prose("intro\nlocal x = 1\noutro");
626        assert_eq!(s, "local x = 1");
627
628        // Only Lua lines
629        let s = strip_prose("local x = 1\nreport({})");
630        assert_eq!(s, "local x = 1\nreport({})");
631
632        // Lines with leading/trailing whitespace → trim exercised
633        let s = strip_prose("  local x = 1  ");
634        assert_eq!(s, "local x = 1");
635    }
636
637    // ── Coverage: looks_like_lua branches ────────────────────────────────
638
639    #[test]
640    fn test_looks_like_lua_variants() {
641        assert!(!looks_like_lua(""));
642        assert!(!looks_like_lua("   "));
643        assert!(looks_like_lua("-- comment"));
644        assert!(looks_like_lua("local x = 1"));
645        assert!(looks_like_lua("{hello}"));
646        assert!(looks_like_lua("}"));
647        assert!(looks_like_lua("(arg)"));
648        assert!(looks_like_lua(")"));
649        assert!(looks_like_lua("\"string\""));
650        assert!(looks_like_lua("'string'"));
651        assert!(looks_like_lua("[1, 2]"));
652        assert!(looks_like_lua("]"));
653        assert!(looks_like_lua("= value"));
654        assert!(looks_like_lua(".method"));
655        assert!(looks_like_lua(":method"));
656        assert!(looks_like_lua("#list"));
657        assert!(!looks_like_lua("plain prose text"));
658        assert!(!looks_like_lua("1234 number line"));
659    }
660
661    // ── Coverage: find_fenced_block edge cases ──────────────────────────
662
663    #[test]
664    fn test_find_fenced_block_edge_cases() {
665        // Non-lua fence skipped, lua fence found
666        let s = find_fenced_block("```text\nstuff\n```\n```lua\nreport({})\n```");
667        assert_eq!(s.unwrap().trim(), "report({})");
668
669        // Missing closing fence → None
670        assert!(find_fenced_block("```lua\nreport({})").is_none());
671
672        // No fence at all
673        assert!(find_fenced_block("just text").is_none());
674    }
675
676    // ── Coverage: output_to_text remaining match arms ───────────────────
677
678    #[test]
679    fn test_output_to_text_variants() {
680        // Object with no recognized key → fallback to_string
681        let obj = serde_json::json!({"unknown": "value"});
682        assert!(output_to_text(&obj).is_some());
683
684        // Array → `other` arm
685        assert_eq!(
686            output_to_text(&serde_json::json!([1, 2, 3])).unwrap(),
687            "[1,2,3]"
688        );
689
690        // Number → `other` arm
691        assert_eq!(output_to_text(&serde_json::json!(42)).unwrap(), "42");
692    }
693
694    // ── Coverage: build_prompt fix_error branch ─────────────────────────
695
696    #[test]
697    fn test_build_prompt_with_fix_error() {
698        let p = build_prompt("do task", Some("script had syntax error"), false);
699        assert!(p.contains("do task"));
700        assert!(p.contains("previous attempt was rejected"));
701        assert!(p.contains("script had syntax error"));
702        assert!(p.contains("Output ONLY"));
703    }
704
705    #[test]
706    fn test_build_prompt_without_fix_error() {
707        let p = build_prompt("do task", None, false);
708        assert!(p.contains("do task"));
709        assert!(!p.contains("previous attempt was rejected"));
710        assert!(p.contains("Output ONLY"));
711    }
712
713    // ── Span pairing validation ─────────────────────────────────────────
714
715    #[test]
716    fn test_validate_span_unpaired() {
717        let script = "meta = { reasoning = \"test\", phases = {} }\nfunction main()\nlocal m = phase_begin(\"x\")\nreport({})\nend";
718        assert!(validate_generated(script).is_err());
719    }
720
721    #[test]
722    fn test_validate_span_paired() {
723        let script = "meta = { reasoning = \"test\", phases = {} }\nfunction main()\nlocal m = phase_begin(\"x\")\nphase_end(m)\nreport({})\nend";
724        assert!(validate_generated(script).is_ok());
725    }
726
727    #[test]
728    fn test_validate_no_span_ok() {
729        let script =
730            "meta = { reasoning = \"test\", phases = {} }\nfunction main() report({ok=true}) end";
731        assert!(validate_generated(script).is_ok());
732    }
733
734    #[test]
735    fn test_build_prompt_contains_decomposition_section() {
736        let p = build_prompt("refactor everything", None, false);
737        assert!(p.contains("Task Decomposition"));
738        assert!(p.contains("meta.phases") || p.contains("phases"));
739    }
740}