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        thread_id: None,
207    };
208    backend
209        .run(task, ctx)
210        .await
211        .map(|r| r.output)
212        .map_err(|e| e.to_string())
213}
214
215/// Validate a generated script: syntax + structure + heuristic checks.
216fn validate_generated(script: &str) -> Result<(), String> {
217    let result = validate_workflow(script).map_err(|e| format!("lua validation error: {}", e))?;
218    if result.errors.is_empty() {
219        Ok(())
220    } else {
221        Err(result.errors.join("; "))
222    }
223}
224
225/// Extract the ```json block from planner output (for mock data).
226fn extract_mock_block(output: &serde_json::Value) -> Option<serde_json::Value> {
227    let text = output_to_text(output)?;
228    let json_str = find_fenced_block_by_lang(&text, "json")?;
229    let trimmed = json_str.trim();
230    if trimmed.is_empty() {
231        return None;
232    }
233    serde_json::from_str(trimmed).ok()
234}
235
236/// Find a fenced code block by exact language tag.
237fn find_fenced_block_by_lang(text: &str, lang: &str) -> Option<String> {
238    let marker = format!("```{}", lang);
239    let mut from = 0;
240    while let Some(rel) = text[from..].find(&marker) {
241        let fence = from + rel;
242        let after = &text[fence + marker.len()..];
243        let line_end = match after.find('\n') {
244            Some(n) => n,
245            None => {
246                from = fence + marker.len();
247                continue;
248            }
249        };
250        let body_start = fence + marker.len() + line_end + 1;
251        let rest = &text[body_start..];
252        let close = match rest.find("```") {
253            Some(c) => c,
254            None => {
255                from = body_start;
256                continue;
257            }
258        };
259        return Some(rest[..close].trim_end().to_string());
260    }
261    None
262}
263
264/// Coerce the agent output into text and pull out the Lua script.
265fn extract_script(output: &serde_json::Value) -> Option<String> {
266    let text = output_to_text(output)?;
267    let block = extract_lua_block(&text);
268    if block.trim().is_empty() {
269        None
270    } else {
271        Some(block)
272    }
273}
274
275/// Best-effort extraction of assistant text from a backend's structured output.
276///
277/// The exact shape depends on the backend (real ACP backends land in P0-A); this
278/// handles the common cases and falls back to a JSON stringification.
279fn output_to_text(output: &serde_json::Value) -> Option<String> {
280    match output {
281        serde_json::Value::String(s) => Some(s.clone()),
282        serde_json::Value::Object(map) => {
283            for key in ["script", "message", "text", "content", "output"] {
284                if let Some(serde_json::Value::String(s)) = map.get(key) {
285                    return Some(s.clone());
286                }
287            }
288            Some(output.to_string())
289        }
290        serde_json::Value::Null => None,
291        other => Some(other.to_string()),
292    }
293}
294
295/// Return the first ```lua (or unlabelled) fenced block; otherwise try to
296/// strip surrounding prose from the trimmed input.
297fn extract_lua_block(text: &str) -> String {
298    if let Some(block) = find_fenced_block(text) {
299        return block;
300    }
301    let trimmed = text.trim();
302    if validate_script(trimmed).is_ok() {
303        return trimmed.to_string();
304    }
305    strip_prose(trimmed)
306}
307
308/// Heuristically strip leading/trailing prose lines that aren't valid Lua.
309/// Keeps lines from the first Lua-looking line to the last.
310fn strip_prose(text: &str) -> String {
311    let lines: Vec<&str> = text.lines().collect();
312    let lua_start = lines.iter().position(|l| looks_like_lua(l)).unwrap_or(0);
313    let lua_end = lines
314        .iter()
315        .rposition(|l| looks_like_lua(l))
316        .map(|i| i + 1)
317        .unwrap_or(lines.len());
318    lines[lua_start.min(lua_end.max(1))..lua_end.max(lines.len().min(lua_start + 1))]
319        .join("\n")
320        .trim()
321        .to_string()
322}
323
324/// Check if a line looks like it could be Lua code (not prose).
325fn looks_like_lua(line: &str) -> bool {
326    let t = line.trim();
327    if t.is_empty() {
328        return false;
329    }
330    if t.starts_with("--") {
331        return true;
332    }
333    const KEYWORDS: &[&str] = &[
334        "local", "phase", "report", "agent", "parallel", "pipeline", "for", "if", "while",
335        "function", "return", "log", "budget", "workflow", "json", "do", "end", "else", "elseif",
336        "then", "and", "or", "not", "true", "false", "nil", "args", "ctx",
337    ];
338    let first_word = t
339        .split(|c: char| !c.is_alphanumeric() && c != '_')
340        .next()
341        .unwrap_or("");
342    KEYWORDS.contains(&first_word)
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        || t.starts_with('#')
355}
356
357fn find_fenced_block(text: &str) -> Option<String> {
358    let mut from = 0;
359    while let Some(rel) = text[from..].find("```") {
360        let fence = from + rel;
361        let after = &text[fence + 3..];
362        let line_end = match after.find('\n') {
363            Some(n) => n,
364            None => {
365                from = fence + 3;
366                continue;
367            }
368        };
369        let lang = after[..line_end].trim();
370        let body_start = fence + 3 + line_end + 1;
371        let rest = &text[body_start..];
372        let close = match rest.find("```") {
373            Some(c) => c,
374            None => {
375                from = body_start;
376                continue;
377            }
378        };
379        if lang.eq_ignore_ascii_case("lua") || lang.is_empty() {
380            return Some(rest[..close].trim_end().to_string());
381        }
382        from = body_start + close + 3;
383    }
384    None
385}
386
387/// Build the planner prompt: DSL reference + task (+ optional fix-up error).
388fn build_prompt(task: &str, fix_error: Option<&str>, generate_mock: bool) -> String {
389    let mut p = String::with_capacity(LUA_DSL_REFERENCE.len() + task.len() + 1024);
390    p.push_str(LUA_DSL_REFERENCE);
391    p.push_str("\n\n# Task\n\n");
392    p.push_str(task);
393    p.push('\n');
394    if let Some(err) = fix_error {
395        p.push_str("\n# Your previous attempt was rejected\n\n");
396        p.push_str(err);
397        p.push_str("\n\nFix the script and output a corrected version.\n");
398    }
399    if generate_mock {
400        p.push_str(MOCK_GENERATION_INSTRUCTIONS);
401    }
402    p.push_str("\nOutput ONLY one ```lua code block");
403    if generate_mock {
404        p.push_str(" followed by one ```json code block");
405    }
406    p.push_str(" — no prose before or after.\n");
407    p
408}
409
410const MOCK_GENERATION_INSTRUCTIONS: &str = r#"
411
412# Mock Data Generation
413
414In ADDITION to the ```lua block, output a second ```json block with mock
415responses for EVERY named agent call. Format:
416
417```json
418{
419  "responses": {
420    "<agent_name>": {
421      "output": <representative JSON matching the agent's expected output>,
422      "tokens": {"input": 100, "output": 50},
423      "status": "ok"
424    }
425  },
426  "default": {
427    "output": {"text": "mock response"},
428    "tokens": {"input": 0, "output": 0}
429  }
430}
431```
432
433Rules:
4341. EVERY agent() call in the Lua script MUST include a unique `name=` field.
4352. For parallel() fan-out, all items share ONE name — the mock response is reused.
4363. Mock output MUST match the agent's schema structure if a schema is defined.
4374. Keep mock output concise but structurally valid.
4385. Output BOTH blocks: ```lua first, then ```json.
439"#;
440
441/// The orchestration DSL spec handed to the planner agent.
442///
443/// ## Role
444///
445/// This constant is the **system prompt** for the planner LLM. When a user runs
446/// `luft run --nl "<task>"`, the planner sends this text + the user's task
447/// description to the LLM, and expects a single ```lua code block back. The
448/// returned script is then validated (syntax + `report()` presence + span pairing)
449/// and executed by the sandboxed [`crate::runtime`].
450///
451/// ## Architecture: NL → Lua compilation
452///
453/// ```text
454///   User NL task ──► planner LLM ──► Lua script ──► sandbox execute
455///        │              ▲                              │
456///        │              │                              ▼
457///        └──── LUA_DSL_REFERENCE (this const)     agent() calls ──► scheduler
458///                                                     │
459///                                                     ▼
460///                                                   report()
461/// ```
462///
463/// The LLM acts as a "compiler" from natural language to the Luft Lua DSL.
464/// The script is pure orchestration — no I/O, no filesystem, no shell. All real
465/// work (file reads, grep, edit, web search) happens inside the `agent()` prompts.
466///
467/// ## Keeping in sync
468///
469/// Every primitive documented here MUST be registered in [`luft_runtime::sandbox`]
470/// via `register_sdk()`. If you add a new Lua global, document it here and register
471/// it there. If you remove one, remove it from both places.
472///
473/// ## Token cost
474///
475/// This reference is sent on every planner call (~4K tokens). Keep examples
476/// concise but illustrative. Rules are authoritative — primitives are reference.
477const LUA_DSL_REFERENCE: &str = include_str!("lua_dsl_reference.md");
478
479#[cfg(test)]
480mod tests {
481    use super::*;
482    use luft_core::{FailKind, MockBackend, MockBehavior, TokenUsage};
483    use std::time::Duration;
484
485    fn mock_returning(output: serde_json::Value) -> Arc<dyn AgentBackend> {
486        Arc::new(MockBackend::new(
487            "mock",
488            vec![MockBehavior::Success {
489                output,
490                tokens: TokenUsage::default(),
491                delay: Duration::ZERO,
492            }],
493        ))
494    }
495
496    #[tokio::test]
497    async fn test_plan_extracts_and_validates_script() {
498        let script = "```lua\nmeta = { reasoning = \"test\", phases = {{ label = \"work\" }} }\nfunction main()\n  local r = agent({prompt='hi'})\n  report({ok=true})\nend\n```";
499        let backend = mock_returning(serde_json::json!(script));
500        let planned = plan_workflow("do something", backend, &PlannerConfig::default())
501            .await
502            .unwrap();
503        assert!(planned.script.contains("report("));
504        assert!(!planned.script.contains("```"));
505        assert!(validate_script(&planned.script).is_ok());
506    }
507
508    #[tokio::test]
509    async fn test_plan_retries_on_invalid_then_fails() {
510        // Unbalanced parenthesis → syntax error, repeated for every retry.
511        let bad = "```lua\nlocal x = (\nreport({})\n```";
512        let backend = mock_returning(serde_json::json!(bad));
513        let cfg = PlannerConfig {
514            planner_model: None,
515            max_retries: 2,
516            ..Default::default()
517        };
518        match plan_workflow("x", backend, &cfg).await.unwrap_err() {
519            PlannerError::ExhaustedRetries { attempts, .. } => assert_eq!(attempts, 2),
520            other => panic!("expected ExhaustedRetries, got {:?}", other),
521        }
522    }
523
524    #[tokio::test]
525    async fn test_plan_rejects_missing_report() {
526        // Valid Lua but no report() call → rejected, retries exhausted.
527        let no_report = "```lua\nlocal r = agent({prompt='hi'})\n```";
528        let backend = mock_returning(serde_json::json!(no_report));
529        let cfg = PlannerConfig {
530            planner_model: None,
531            max_retries: 1,
532            ..Default::default()
533        };
534        assert!(matches!(
535            plan_workflow("x", backend, &cfg).await,
536            Err(PlannerError::ExhaustedRetries { .. })
537        ));
538    }
539
540    #[test]
541    fn test_extract_script_fenced_bare_and_object() {
542        // Fenced lua block with surrounding prose.
543        let v = serde_json::json!("prefix\n```lua\nreport({})\n```\nsuffix");
544        assert_eq!(extract_script(&v).unwrap().trim(), "report({})");
545
546        // Bare text (no fence) → trimmed.
547        let v = serde_json::json!("  report({})  ");
548        assert_eq!(extract_script(&v).unwrap(), "report({})");
549
550        // Object with a `message` field.
551        let v = serde_json::json!({ "message": "```lua\nreport({})\n```" });
552        assert_eq!(extract_script(&v).unwrap().trim(), "report({})");
553
554        // Null → None.
555        assert!(extract_script(&serde_json::Value::Null).is_none());
556    }
557
558    #[test]
559    fn test_extract_skips_non_lua_block() {
560        let v = serde_json::json!("```text\nnot code\n```\n```lua\nreport({})\n```");
561        assert_eq!(extract_script(&v).unwrap().trim(), "report({})");
562    }
563
564    // ── Additional coverage: backend error path ──────────────────────────
565
566    #[tokio::test]
567    async fn test_plan_backend_error() {
568        let backend: Arc<dyn AgentBackend> = Arc::new(MockBackend::new(
569            "mock",
570            vec![MockBehavior::fail(FailKind::Protocol)],
571        ));
572        let err = plan_workflow("x", backend, &PlannerConfig::default())
573            .await
574            .unwrap_err();
575        assert!(matches!(err, PlannerError::Backend(_)));
576    }
577
578    // ── Coverage: agent output with no lua block (extract_script → None) ─
579
580    #[tokio::test]
581    async fn test_plan_no_lua_block_retries() {
582        let backend = mock_returning(serde_json::Value::Null);
583        let cfg = PlannerConfig {
584            max_retries: 2,
585            ..Default::default()
586        };
587        let err = plan_workflow("x", backend, &cfg).await.unwrap_err();
588        assert!(matches!(err, PlannerError::ExhaustedRetries { .. }));
589    }
590
591    // ── Coverage: validation failure followed by successful retry ────────
592
593    #[tokio::test]
594    async fn test_plan_retry_then_succeeds() {
595        let valid = "```lua\nmeta = { reasoning = \"test\", phases = {{ label = \"work\" }} }\nfunction main()\n  local r = agent({prompt='hi'})\n  report({ok=true})\nend\n```";
596        let backend: Arc<dyn AgentBackend> = Arc::new(MockBackend::new(
597            "mock",
598            vec![
599                MockBehavior::Success {
600                    output: serde_json::json!("this is pure garbage"),
601                    tokens: TokenUsage::default(),
602                    delay: Duration::ZERO,
603                },
604                MockBehavior::Success {
605                    output: serde_json::json!(valid),
606                    tokens: TokenUsage::default(),
607                    delay: Duration::ZERO,
608                },
609            ],
610        ));
611        let planned = plan_workflow("x", backend, &PlannerConfig::default())
612            .await
613            .unwrap();
614        assert!(planned.script.contains("report({ok=true})"));
615    }
616
617    // ── Coverage: strip_prose edge cases (lines 191, 195, 201) ──────────
618
619    #[test]
620    fn test_strip_prose_edge_cases() {
621        // No lines look like Lua → lua_start = 0, lua_end = lines.len()
622        let s = strip_prose("hello world\nsome prose");
623        assert_eq!(s, "hello world\nsome prose");
624
625        // Lua in middle, prose before/after → slices correctly
626        let s = strip_prose("intro\nlocal x = 1\noutro");
627        assert_eq!(s, "local x = 1");
628
629        // Only Lua lines
630        let s = strip_prose("local x = 1\nreport({})");
631        assert_eq!(s, "local x = 1\nreport({})");
632
633        // Lines with leading/trailing whitespace → trim exercised
634        let s = strip_prose("  local x = 1  ");
635        assert_eq!(s, "local x = 1");
636    }
637
638    // ── Coverage: looks_like_lua branches ────────────────────────────────
639
640    #[test]
641    fn test_looks_like_lua_variants() {
642        assert!(!looks_like_lua(""));
643        assert!(!looks_like_lua("   "));
644        assert!(looks_like_lua("-- comment"));
645        assert!(looks_like_lua("local x = 1"));
646        assert!(looks_like_lua("{hello}"));
647        assert!(looks_like_lua("}"));
648        assert!(looks_like_lua("(arg)"));
649        assert!(looks_like_lua(")"));
650        assert!(looks_like_lua("\"string\""));
651        assert!(looks_like_lua("'string'"));
652        assert!(looks_like_lua("[1, 2]"));
653        assert!(looks_like_lua("]"));
654        assert!(looks_like_lua("= value"));
655        assert!(looks_like_lua(".method"));
656        assert!(looks_like_lua(":method"));
657        assert!(looks_like_lua("#list"));
658        assert!(!looks_like_lua("plain prose text"));
659        assert!(!looks_like_lua("1234 number line"));
660    }
661
662    // ── Coverage: find_fenced_block edge cases ──────────────────────────
663
664    #[test]
665    fn test_find_fenced_block_edge_cases() {
666        // Non-lua fence skipped, lua fence found
667        let s = find_fenced_block("```text\nstuff\n```\n```lua\nreport({})\n```");
668        assert_eq!(s.unwrap().trim(), "report({})");
669
670        // Missing closing fence → None
671        assert!(find_fenced_block("```lua\nreport({})").is_none());
672
673        // No fence at all
674        assert!(find_fenced_block("just text").is_none());
675    }
676
677    // ── Coverage: output_to_text remaining match arms ───────────────────
678
679    #[test]
680    fn test_output_to_text_variants() {
681        // Object with no recognized key → fallback to_string
682        let obj = serde_json::json!({"unknown": "value"});
683        assert!(output_to_text(&obj).is_some());
684
685        // Array → `other` arm
686        assert_eq!(
687            output_to_text(&serde_json::json!([1, 2, 3])).unwrap(),
688            "[1,2,3]"
689        );
690
691        // Number → `other` arm
692        assert_eq!(output_to_text(&serde_json::json!(42)).unwrap(), "42");
693    }
694
695    // ── Coverage: build_prompt fix_error branch ─────────────────────────
696
697    #[test]
698    fn test_build_prompt_with_fix_error() {
699        let p = build_prompt("do task", Some("script had syntax error"), false);
700        assert!(p.contains("do task"));
701        assert!(p.contains("previous attempt was rejected"));
702        assert!(p.contains("script had syntax error"));
703        assert!(p.contains("Output ONLY"));
704    }
705
706    #[test]
707    fn test_build_prompt_without_fix_error() {
708        let p = build_prompt("do task", None, false);
709        assert!(p.contains("do task"));
710        assert!(!p.contains("previous attempt was rejected"));
711        assert!(p.contains("Output ONLY"));
712    }
713
714    // ── Span pairing validation ─────────────────────────────────────────
715
716    #[test]
717    fn test_validate_span_unpaired() {
718        let script = "meta = { reasoning = \"test\", phases = {} }\nfunction main()\nlocal m = phase_begin(\"x\")\nreport({})\nend";
719        assert!(validate_generated(script).is_err());
720    }
721
722    #[test]
723    fn test_validate_span_paired() {
724        let script = "meta = { reasoning = \"test\", phases = {} }\nfunction main()\nlocal m = phase_begin(\"x\")\nphase_end(m)\nreport({})\nend";
725        assert!(validate_generated(script).is_ok());
726    }
727
728    #[test]
729    fn test_validate_no_span_ok() {
730        let script =
731            "meta = { reasoning = \"test\", phases = {} }\nfunction main() report({ok=true}) end";
732        assert!(validate_generated(script).is_ok());
733    }
734
735    #[test]
736    fn test_build_prompt_contains_decomposition_section() {
737        let p = build_prompt("refactor everything", None, false);
738        assert!(p.contains("Task Decomposition"));
739        assert!(p.contains("meta.phases") || p.contains("phases"));
740    }
741}