Skip to main content

leviath_cli/commands/
test.rs

1//! `lev test` - Run agent tests
2
3use clap::Args;
4use leviath_providers::InferenceRequest;
5use leviath_runtime::{ContextWindow, ProviderRegistry, context_setup};
6use serde::Deserialize;
7use std::fs;
8use std::path::Path;
9use std::sync::Arc;
10
11use crate::config::Config;
12use leviath_core::manifest::parse_manifest;
13use leviath_core::truncate_at_boundary;
14
15#[derive(Args)]
16pub struct TestArgs {
17    /// Path to agent project
18    #[arg(value_name = "PATH")]
19    pub path: Option<String>,
20
21    /// Test filter pattern
22    #[arg(short, long)]
23    pub filter: Option<String>,
24
25    /// Validate test structure without running agents (no API calls)
26    #[arg(long)]
27    pub dry_run: bool,
28}
29
30/// A test case loaded from a TOML test file.
31#[derive(Debug, Deserialize)]
32#[allow(dead_code)]
33struct TestCase {
34    name: String,
35    input: String,
36    #[serde(default)]
37    expect_contains: Option<String>,
38    #[serde(default)]
39    expect_tool_call: Option<String>,
40    #[serde(default)]
41    max_tokens: Option<usize>,
42}
43
44#[derive(Debug, Deserialize)]
45struct TestFile {
46    test: Vec<TestCase>,
47}
48
49pub async fn execute(args: TestArgs) -> anyhow::Result<()> {
50    execute_with_registry(args, Box::new(build_registry_from_config)).await
51}
52
53/// Builds the real provider registry from a loaded [`Config`] - the
54/// production `build_registry` passed to [`execute_with_registry`] by
55/// [`execute`].
56fn build_registry_from_config(config: &Config) -> ProviderRegistry {
57    let mut reg = ProviderRegistry::new();
58
59    if let Some(ref key) = config.providers.anthropic_api_key {
60        reg.register(
61            "anthropic".to_string(),
62            Arc::new(leviath_providers::AnthropicProvider::new(key.clone())),
63        );
64    }
65    if let Some(ref key) = config.providers.openai_api_key {
66        reg.register(
67            "openai".to_string(),
68            Arc::new(leviath_providers::OpenAIProvider::new(key.clone())),
69        );
70    }
71    if let Some(ref key) = config.providers.google_api_key {
72        reg.register(
73            "google".to_string(),
74            Arc::new(leviath_providers::GeminiProvider::new(key.clone())),
75        );
76    }
77    if let Some(ref key) = config.openrouter_api_key {
78        reg.register(
79            "openrouter".to_string(),
80            Arc::new(leviath_providers::OpenRouterProvider::new(key.clone())),
81        );
82    }
83    let ollama_url = config
84        .ollama_base_url
85        .as_deref()
86        .unwrap_or("http://localhost:11434");
87    reg.register(
88        "ollama".to_string(),
89        Arc::new(leviath_providers::OllamaProvider::with_base_url(
90            ollama_url.to_string(),
91        )),
92    );
93
94    reg
95}
96
97/// Core of [`execute`], with provider-registry construction injected so
98/// tests can drive the non-dry-run path with a mock [`Provider`] instead of
99/// either skipping it (dry-run only) or making a real, billed network call
100/// through whatever the developer's real `~/.leviath/config.toml` happens to
101/// contain.
102///
103/// `build_registry` is a boxed trait object (`Box<dyn FnOnce(&Config) ->
104/// ProviderRegistry>`) rather than `impl FnOnce(&Config) -> ProviderRegistry`
105/// so every caller - production's `build_registry_from_config` and every
106/// test's distinct `mock_registry_builder(...)` closure - shares exactly
107/// ONE monomorphization of this (large, many-branch) function instead of
108/// one per closure type. This was a confirmed generic-monomorphization
109/// coverage-attribution artifact: every source position had a covered
110/// instantiation (confirmed via HTML/JSON segment inspection showing no
111/// red/uncovered regions anywhere in this function), but the summary table
112/// still reported 32 regions / 21 lines missed - the largest such residual
113/// in this crate.
114async fn execute_with_registry(
115    args: TestArgs,
116    build_registry: Box<dyn FnOnce(&Config) -> ProviderRegistry>,
117) -> anyhow::Result<()> {
118    let path = args.path.unwrap_or_else(|| ".".to_string());
119    tracing::info!(path = %path, "Running agent tests");
120
121    let project_path = Path::new(&path);
122
123    // Verify agent.leviath exists
124    let manifest_path = project_path.join("agent.leviath");
125    if !manifest_path.exists() {
126        anyhow::bail!(
127            "No agent.leviath found in '{}'. Not an agent project.",
128            project_path.display()
129        );
130    }
131
132    let tests_dir = project_path.join("tests");
133    if !tests_dir.exists() {
134        println!("No tests directory found. Create tests/ with .toml or .rhai files.");
135        println!("\nExample test file (tests/basic.toml):");
136        println!("  [[test]]");
137        println!("  name = \"basic_response\"");
138        println!("  input = \"Hello\"");
139        println!("  expect_contains = \"hello\"");
140        return Ok(());
141    }
142
143    if args.dry_run {
144        println!("Dry run mode: validating test structure only (no API calls)\n");
145    }
146
147    // Parse blueprint and set up providers (only if not dry_run)
148    let manifest_content = fs::read_to_string(&manifest_path)?;
149    let blueprint = parse_manifest(&manifest_content)?;
150
151    // Custom regions' Rhai scripts, resolved exactly as a real spawn would
152    // (blueprint-dir-relative, compile-checked, hard error) - `lev test` is
153    // precisely the preview loop where a hook author wants the hook to run.
154    let region_scripts =
155        crate::daemon::spawn::resolve_region_scripts(&blueprint, &manifest_path.to_string_lossy())
156            .map_err(|e| anyhow::anyhow!(e))?;
157
158    let registry = if !args.dry_run {
159        let config = Config::load()?;
160        Some(build_registry(&config))
161    } else {
162        None
163    };
164
165    let mut total = 0;
166    let mut passed = 0;
167    let mut failed = 0;
168    let mut failures: Vec<String> = Vec::new();
169
170    // Run .toml test files and .rhai test scripts (single directory scan)
171    for entry in fs::read_dir(&tests_dir)?.flatten() {
172        let test_path = entry.path();
173
174        if test_path.extension().and_then(|e| e.to_str()) == Some("toml") {
175            let file_name = test_path
176                .file_name()
177                .and_then(|n| n.to_str())
178                .unwrap_or("unknown");
179
180            println!("Running test file: {}", file_name);
181
182            let content = fs::read_to_string(&test_path)?;
183            let test_file: TestFile = toml::from_str(&content)
184                .map_err(|e| anyhow::anyhow!("Failed to parse test file '{}': {}", file_name, e))?;
185
186            for test_case in &test_file.test {
187                // Apply filter if provided
188                if let Some(ref filter) = args.filter
189                    && !test_case.name.contains(filter.as_str())
190                {
191                    continue;
192                }
193
194                total += 1;
195
196                if args.dry_run {
197                    // Dry-run: validate structure only
198                    let test_valid = validate_test_case(test_case);
199                    if test_valid {
200                        passed += 1;
201                        println!("  PASS (dry-run): {}", test_case.name);
202                    } else {
203                        failed += 1;
204                        let msg = format!("{}: test case validation failed", test_case.name);
205                        println!("  FAIL (dry-run): {}", msg);
206                        failures.push(msg);
207                    }
208                } else {
209                    // Real run: execute inference and check assertions
210                    let registry = registry
211                        .as_ref()
212                        .expect("registry should exist in non-dry-run");
213                    match run_test_case(&blueprint, registry, test_case, &region_scripts).await {
214                        Ok(true) => {
215                            passed += 1;
216                            println!("  PASS: {}", test_case.name);
217                        }
218                        Ok(false) => {
219                            failed += 1;
220                            let msg = format!("{}: assertions failed", test_case.name);
221                            println!("  FAIL: {}", msg);
222                            failures.push(msg);
223                        }
224                        Err(e) => {
225                            failed += 1;
226                            let msg = format!("{}: {}", test_case.name, e);
227                            println!("  FAIL: {}", msg);
228                            failures.push(msg);
229                        }
230                    }
231                }
232            }
233        } else if test_path.extension().and_then(|e| e.to_str()) == Some("rhai") {
234            let file_name = test_path
235                .file_name()
236                .and_then(|n| n.to_str())
237                .unwrap_or("unknown");
238
239            // Apply filter if provided
240            if let Some(ref filter) = args.filter
241                && !file_name.contains(filter.as_str())
242            {
243                continue;
244            }
245
246            total += 1;
247            println!("Running script: {}", file_name);
248
249            let script = fs::read_to_string(&test_path)?;
250            let engine = leviath_scripting::ScriptEngine::new();
251            let mut scope = rhai::Scope::new();
252
253            match engine.execute(&script, &mut scope) {
254                Ok(result) => {
255                    if let Ok(success) = result.as_bool() {
256                        if success {
257                            passed += 1;
258                            println!("  PASS: {}", file_name);
259                        } else {
260                            failed += 1;
261                            let msg = format!("{}: script returned false", file_name);
262                            println!("  FAIL: {}", msg);
263                            failures.push(msg);
264                        }
265                    } else {
266                        passed += 1;
267                        println!("  PASS: {} (returned: {})", file_name, result);
268                    }
269                }
270                Err(e) => {
271                    failed += 1;
272                    let msg = format!("{}: {}", file_name, e);
273                    println!("  FAIL: {}", msg);
274                    failures.push(msg);
275                }
276            }
277        }
278    }
279
280    // Report results
281    println!("\n--- Results ---");
282    println!("{} passed, {} failed, {} total", passed, failed, total);
283
284    if !failures.is_empty() {
285        println!("\nFailures:");
286        for f in &failures {
287            println!("  - {}", f);
288        }
289        anyhow::bail!("{} test(s) failed", failed);
290    }
291
292    if total == 0 {
293        println!("No test files found in tests/ directory.");
294    }
295
296    Ok(())
297}
298
299/// Run a single test case: build a one-off context window from the blueprint,
300/// run one inference against the resolved provider, and check the assertions.
301async fn run_test_case(
302    blueprint: &leviath_core::Blueprint,
303    registry: &ProviderRegistry,
304    test: &TestCase,
305    region_scripts: &std::collections::HashMap<
306        String,
307        std::sync::Arc<leviath_scripting::region_hook::RegionScript>,
308    >,
309) -> anyhow::Result<bool> {
310    // Model config comes from the first stage.
311    let stage = blueprint
312        .stages
313        .first()
314        .ok_or(anyhow::anyhow!("Blueprint has no stages"))?;
315    let provider_name = stage.model.provider();
316    let model_name = stage.model.model();
317
318    let provider = registry.get(provider_name).ok_or_else(|| {
319        anyhow::anyhow!(
320            "Provider '{}' is not configured. Set API key in ~/.leviath/config.toml",
321            provider_name
322        )
323    })?;
324
325    // Build a standalone context window from the blueprint's layout, seeding the
326    // test input as the task, then assemble a single inference request. This
327    // mirrors what the ECS pipeline's spawner does, without the shared world:
328    // `lev test` only needs one inference to validate a stage's first response.
329    let mut window = ContextWindow::new(blueprint.context_layout.total_budget_tokens);
330    window.region_scripts = region_scripts.clone();
331    context_setup::init_window(&mut window, blueprint, &test.input);
332
333    // Assemble with real stage metadata so custom-region render hooks see
334    // what a live run's first inference would (iteration 0).
335    let assembled = window.assemble_with_meta(&leviath_runtime::custom_region::AssembleMeta {
336        stage_name: stage.name.clone(),
337        stage_iterations: 0,
338        model: model_name.to_string(),
339    });
340    let caps = provider.capabilities(model_name);
341    let remaining = window.max_tokens.saturating_sub(window.current_tokens);
342    let temperature = if caps.supports_temperature { 0.7 } else { 0.0 };
343    let request = InferenceRequest {
344        system: assembled.system_blocks,
345        messages: assembled.messages,
346        model: model_name.to_string(),
347        max_tokens: remaining.min(caps.max_output_tokens),
348        temperature,
349        // `lev test` advertises no tools (matches prior single-shot behaviour).
350        tools: Vec::new(),
351        extra: serde_json::Value::Null,
352        request_timeout_secs: None,
353    };
354
355    let response = provider
356        .infer(request)
357        .await
358        .map_err(|e| anyhow::anyhow!("Inference failed: {}", e))?;
359
360    // Check assertions
361    let mut all_passed = true;
362
363    if let Some(ref expected) = test.expect_contains {
364        let content_lower = response.content.to_lowercase();
365        let expected_lower = expected.to_lowercase();
366        if !content_lower.contains(&expected_lower) {
367            println!(
368                "    expect_contains failed: response does not contain '{}'",
369                expected
370            );
371            println!("    response: {}", truncate_str(&response.content, 200));
372            all_passed = false;
373        }
374    }
375
376    if let Some(ref expected_tool) = test.expect_tool_call {
377        let has_tool = response
378            .tool_calls
379            .iter()
380            .any(|tc| tc.name == *expected_tool);
381        if !has_tool {
382            println!(
383                "    expect_tool_call failed: no tool call to '{}'",
384                expected_tool
385            );
386            let tool_names: Vec<&str> = response
387                .tool_calls
388                .iter()
389                .map(|tc| tc.name.as_str())
390                .collect();
391            println!("    actual tool calls: {:?}", tool_names);
392            all_passed = false;
393        }
394    }
395
396    Ok(all_passed)
397}
398
399/// Validate a test case structure (checks that it's well-formed).
400fn validate_test_case(test: &TestCase) -> bool {
401    if test.name.is_empty() {
402        return false;
403    }
404    if test.input.is_empty() {
405        return false;
406    }
407    // Must have at least one assertion
408    if test.expect_contains.is_none() && test.expect_tool_call.is_none() {
409        return false;
410    }
411    true
412}
413
414/// Shorten a model response for the assertion-failure preview.
415///
416/// Cuts on a char boundary: this runs on raw model output, and a byte cut-off
417/// through an emoji once panicked `lev test` outright.
418fn truncate_str(s: &str, max: usize) -> String {
419    if s.len() <= max {
420        s.to_string()
421    } else {
422        format!("{}...", truncate_at_boundary(s, max))
423    }
424}
425
426#[cfg(test)]
427mod tests {
428    use super::*;
429    use crate::test_support::{with_tracing, write_test_agent};
430
431    // ─── validate_test_case ────────────────────────────────────────────────
432
433    #[test]
434    fn validate_test_case_valid_with_expect_contains() {
435        let tc = TestCase {
436            name: "basic".to_string(),
437            input: "hello".to_string(),
438            expect_contains: Some("world".to_string()),
439            expect_tool_call: None,
440            max_tokens: None,
441        };
442        assert!(validate_test_case(&tc));
443    }
444
445    #[test]
446    fn validate_test_case_valid_with_expect_tool_call() {
447        let tc = TestCase {
448            name: "tool_test".to_string(),
449            input: "do something".to_string(),
450            expect_contains: None,
451            expect_tool_call: Some("bash".to_string()),
452            max_tokens: None,
453        };
454        assert!(validate_test_case(&tc));
455    }
456
457    #[test]
458    fn validate_test_case_valid_with_both_assertions() {
459        let tc = TestCase {
460            name: "both".to_string(),
461            input: "test".to_string(),
462            expect_contains: Some("output".to_string()),
463            expect_tool_call: Some("read_file".to_string()),
464            max_tokens: Some(100),
465        };
466        assert!(validate_test_case(&tc));
467    }
468
469    #[test]
470    fn validate_test_case_empty_name_fails() {
471        let tc = TestCase {
472            name: String::new(),
473            input: "hello".to_string(),
474            expect_contains: Some("world".to_string()),
475            expect_tool_call: None,
476            max_tokens: None,
477        };
478        assert!(!validate_test_case(&tc));
479    }
480
481    #[test]
482    fn validate_test_case_empty_input_fails() {
483        let tc = TestCase {
484            name: "test".to_string(),
485            input: String::new(),
486            expect_contains: Some("world".to_string()),
487            expect_tool_call: None,
488            max_tokens: None,
489        };
490        assert!(!validate_test_case(&tc));
491    }
492
493    #[test]
494    fn validate_test_case_no_assertions_fails() {
495        let tc = TestCase {
496            name: "test".to_string(),
497            input: "hello".to_string(),
498            expect_contains: None,
499            expect_tool_call: None,
500            max_tokens: None,
501        };
502        assert!(!validate_test_case(&tc));
503    }
504
505    // ─── truncate_str ──────────────────────────────────────────────────────
506
507    #[test]
508    fn truncate_str_short() {
509        assert_eq!(truncate_str("hello", 10), "hello");
510    }
511
512    #[test]
513    fn truncate_str_exact() {
514        assert_eq!(truncate_str("hello", 5), "hello");
515    }
516
517    #[test]
518    fn truncate_str_long() {
519        assert_eq!(truncate_str("hello world", 5), "hello...");
520    }
521
522    #[test]
523    fn truncate_str_empty() {
524        assert_eq!(truncate_str("", 5), "");
525    }
526
527    // ─── TestFile TOML parsing ─────────────────────────────────────────────
528
529    #[test]
530    fn parse_test_file_toml() {
531        let toml_content = r#"
532[[test]]
533name = "greeting"
534input = "Say hello"
535expect_contains = "hello"
536
537[[test]]
538name = "tool_use"
539input = "Read file.txt"
540expect_tool_call = "read_file"
541max_tokens = 500
542"#;
543        let test_file: TestFile = toml::from_str(toml_content).unwrap();
544        assert_eq!(test_file.test.len(), 2);
545        assert_eq!(test_file.test[0].name, "greeting");
546        assert_eq!(test_file.test[0].input, "Say hello");
547        assert_eq!(test_file.test[0].expect_contains.as_deref(), Some("hello"));
548        assert!(test_file.test[0].expect_tool_call.is_none());
549        assert!(test_file.test[0].max_tokens.is_none());
550
551        assert_eq!(test_file.test[1].name, "tool_use");
552        assert_eq!(
553            test_file.test[1].expect_tool_call.as_deref(),
554            Some("read_file")
555        );
556        assert_eq!(test_file.test[1].max_tokens, Some(500));
557    }
558
559    #[test]
560    fn parse_test_file_minimal() {
561        let toml_content = r#"
562[[test]]
563name = "min"
564input = "test"
565expect_contains = "ok"
566"#;
567        let test_file: TestFile = toml::from_str(toml_content).unwrap();
568        assert_eq!(test_file.test.len(), 1);
569    }
570
571    #[test]
572    fn parse_test_file_invalid_toml_errors() {
573        let result: Result<TestFile, _> = toml::from_str("not valid toml {{{{");
574        assert!(result.is_err());
575    }
576
577    // ─── dry_run flag ──────────────────────────────────────────────────────
578
579    #[tokio::test]
580    async fn dry_run_with_temp_project() {
581        let dir = tempfile::tempdir().unwrap();
582        let project = dir.path();
583
584        // Create minimal agent.leviath
585        let manifest = r#"
586[agent]
587name = "test-agent"
588version = "0.1.0"
589description = "test"
590
591[stages.main]
592model = { provider = "anthropic", model = "claude-sonnet-4-6" }
593"#;
594        write_test_agent(project, manifest);
595
596        // Create tests directory with a test file
597        let tests_dir = project.join("tests");
598        std::fs::create_dir_all(&tests_dir).unwrap();
599        let test_toml = r#"
600[[test]]
601name = "valid_test"
602input = "hello"
603expect_contains = "world"
604"#;
605        std::fs::write(tests_dir.join("basic.toml"), test_toml).unwrap();
606
607        let args = TestArgs {
608            path: Some(project.to_str().unwrap().to_string()),
609            filter: None,
610            dry_run: true,
611        };
612
613        let result = execute(args).await;
614        assert!(result.is_ok());
615    }
616
617    #[tokio::test]
618    async fn dry_run_no_tests_dir() {
619        let dir = tempfile::tempdir().unwrap();
620        let project = dir.path();
621
622        let manifest = r#"
623[agent]
624name = "test-agent"
625version = "0.1.0"
626description = "test"
627
628[stages.main]
629model = { provider = "anthropic", model = "claude-sonnet-4-6" }
630"#;
631        write_test_agent(project, manifest);
632
633        let args = TestArgs {
634            path: Some(project.to_str().unwrap().to_string()),
635            filter: None,
636            dry_run: true,
637        };
638
639        // Should succeed but report no tests found
640        let result = execute(args).await;
641        assert!(result.is_ok());
642    }
643
644    #[tokio::test]
645    async fn execute_no_manifest_errors() {
646        let dir = tempfile::tempdir().unwrap();
647        let args = TestArgs {
648            path: Some(dir.path().to_str().unwrap().to_string()),
649            filter: None,
650            dry_run: true,
651        };
652        let result = execute(args).await;
653        assert!(result.is_err());
654        assert!(result.unwrap_err().to_string().contains("agent.leviath"));
655    }
656
657    // ─── TestCase struct construction ──────────────────────────────────────
658
659    #[test]
660    fn test_case_all_fields_from_toml() {
661        let toml_content = r#"
662[[test]]
663name = "full_test"
664input = "full input"
665expect_contains = "expected"
666expect_tool_call = "bash"
667max_tokens = 1000
668"#;
669        let test_file: TestFile = toml::from_str(toml_content).unwrap();
670        let tc = &test_file.test[0];
671        assert_eq!(tc.name, "full_test");
672        assert_eq!(tc.input, "full input");
673        assert_eq!(tc.expect_contains.as_deref(), Some("expected"));
674        assert_eq!(tc.expect_tool_call.as_deref(), Some("bash"));
675        assert_eq!(tc.max_tokens, Some(1000));
676    }
677
678    #[test]
679    fn test_case_minimal_from_toml() {
680        let toml_content = r#"
681[[test]]
682name = "min"
683input = "hello"
684expect_contains = "world"
685"#;
686        let test_file: TestFile = toml::from_str(toml_content).unwrap();
687        let tc = &test_file.test[0];
688        assert!(tc.expect_tool_call.is_none());
689        assert!(tc.max_tokens.is_none());
690    }
691
692    #[test]
693    fn test_file_multiple_cases() {
694        let toml_content = r#"
695[[test]]
696name = "case1"
697input = "a"
698expect_contains = "b"
699
700[[test]]
701name = "case2"
702input = "c"
703expect_tool_call = "read_file"
704
705[[test]]
706name = "case3"
707input = "d"
708expect_contains = "e"
709expect_tool_call = "bash"
710max_tokens = 500
711"#;
712        let test_file: TestFile = toml::from_str(toml_content).unwrap();
713        assert_eq!(test_file.test.len(), 3);
714    }
715
716    // ─── validate_test_case edge cases ────────────────────────────────────
717
718    #[test]
719    fn validate_test_case_whitespace_name_passes() {
720        // A whitespace-only name is technically non-empty
721        let tc = TestCase {
722            name: " ".to_string(),
723            input: "hello".to_string(),
724            expect_contains: Some("world".to_string()),
725            expect_tool_call: None,
726            max_tokens: None,
727        };
728        assert!(validate_test_case(&tc));
729    }
730
731    // ─── truncate_str edge cases ──────────────────────────────────────────
732
733    #[test]
734    fn truncate_str_one_char_max() {
735        assert_eq!(truncate_str("hello", 1), "h...");
736    }
737
738    #[test]
739    fn truncate_str_unicode() {
740        assert_eq!(truncate_str("abcde", 3), "abc...");
741        // Issue #115: the cut lands inside a multi-byte character. This used to
742        // panic ("byte index N is not a char boundary") on the assertion-failure
743        // path, which prints raw model output. '🎉' occupies bytes 3..7.
744        assert_eq!(truncate_str("abc🎉def", 4), "abc...");
745        assert_eq!(truncate_str("abc🎉def", 6), "abc...");
746        // A boundary-aligned cut is unaffected.
747        assert_eq!(truncate_str("abc🎉def", 7), "abc🎉...");
748        // Every character straddles the cut - the preview degrades to the marker
749        // rather than panicking.
750        assert_eq!(truncate_str("🎉🎉", 2), "...");
751    }
752
753    // ─── dry_run with filter ──────────────────────────────────────────────
754
755    #[tokio::test]
756    async fn dry_run_with_filter_matches() {
757        let dir = tempfile::tempdir().unwrap();
758        let project = dir.path();
759        let manifest = r#"
760[agent]
761name = "test-agent"
762version = "0.1.0"
763description = "test"
764
765[stages.main]
766model = { provider = "anthropic", model = "claude-sonnet-4-6" }
767"#;
768        write_test_agent(project, manifest);
769        let tests_dir = project.join("tests");
770        std::fs::create_dir_all(&tests_dir).unwrap();
771        let test_toml = r#"
772[[test]]
773name = "alpha_test"
774input = "hello"
775expect_contains = "world"
776
777[[test]]
778name = "beta_test"
779input = "hello"
780expect_contains = "world"
781"#;
782        std::fs::write(tests_dir.join("basic.toml"), test_toml).unwrap();
783
784        let args = TestArgs {
785            path: Some(project.to_str().unwrap().to_string()),
786            filter: Some("alpha".to_string()),
787            dry_run: true,
788        };
789        let result = execute(args).await;
790        assert!(result.is_ok());
791    }
792
793    #[tokio::test]
794    async fn dry_run_failing_test_case() {
795        let dir = tempfile::tempdir().unwrap();
796        let project = dir.path();
797        let manifest = r#"
798[agent]
799name = "test-agent"
800version = "0.1.0"
801description = "test"
802
803[stages.main]
804model = { provider = "anthropic", model = "claude-sonnet-4-6" }
805"#;
806        write_test_agent(project, manifest);
807        let tests_dir = project.join("tests");
808        std::fs::create_dir_all(&tests_dir).unwrap();
809        // No assertions = fails validation
810        let test_toml = r#"
811[[test]]
812name = "bad_test"
813input = "hello"
814"#;
815        std::fs::write(tests_dir.join("fail.toml"), test_toml).unwrap();
816
817        let args = TestArgs {
818            path: Some(project.to_str().unwrap().to_string()),
819            filter: None,
820            dry_run: true,
821        };
822        let result = execute(args).await;
823        assert!(result.is_err()); // Should report failures
824    }
825
826    // ─── validate_test_case more cases ───────────────────────────────────
827
828    #[test]
829    fn validate_test_case_with_max_tokens_only_and_no_assertion_fails() {
830        let tc = TestCase {
831            name: "has-max-tokens".to_string(),
832            input: "test".to_string(),
833            expect_contains: None,
834            expect_tool_call: None,
835            max_tokens: Some(500),
836        };
837        assert!(!validate_test_case(&tc));
838    }
839
840    #[test]
841    fn validate_test_case_with_only_tool_call_assertion() {
842        let tc = TestCase {
843            name: "tool-only".to_string(),
844            input: "do it".to_string(),
845            expect_contains: None,
846            expect_tool_call: Some("write_file".to_string()),
847            max_tokens: None,
848        };
849        assert!(validate_test_case(&tc));
850    }
851
852    // ─── truncate_str additional ─────────────────────────────────────────
853
854    #[test]
855    fn truncate_str_zero_max() {
856        assert_eq!(truncate_str("hello", 0), "...");
857    }
858
859    #[test]
860    fn truncate_str_large_max() {
861        let s = "short";
862        assert_eq!(truncate_str(s, 1000), "short");
863    }
864
865    // ─── TestFile TOML parsing edge cases ────────────────────────────────
866
867    #[test]
868    fn parse_test_file_empty_tests_array() {
869        let toml_content = r#"
870test = []
871"#;
872        let test_file: TestFile = toml::from_str(toml_content).unwrap();
873        assert!(test_file.test.is_empty());
874    }
875
876    #[test]
877    fn parse_test_file_missing_test_key_errors() {
878        let result: Result<TestFile, _> = toml::from_str("something_else = 42");
879        assert!(result.is_err());
880    }
881
882    // ─── dry_run with no matching filter ─────────────────────────────────
883
884    #[tokio::test]
885    async fn dry_run_with_filter_no_match() {
886        let dir = tempfile::tempdir().unwrap();
887        let project = dir.path();
888        let manifest = r#"
889[agent]
890name = "test-agent"
891version = "0.1.0"
892description = "test"
893
894[stages.main]
895model = { provider = "anthropic", model = "claude-sonnet-4-6" }
896"#;
897        write_test_agent(project, manifest);
898        let tests_dir = project.join("tests");
899        std::fs::create_dir_all(&tests_dir).unwrap();
900        let test_toml = r#"
901[[test]]
902name = "alpha_test"
903input = "hello"
904expect_contains = "world"
905"#;
906        std::fs::write(tests_dir.join("basic.toml"), test_toml).unwrap();
907
908        let args = TestArgs {
909            path: Some(project.to_str().unwrap().to_string()),
910            filter: Some("nonexistent_filter".to_string()),
911            dry_run: true,
912        };
913        // All tests filtered out = 0 total, no failures
914        let result = execute(args).await;
915        assert!(result.is_ok());
916    }
917
918    // ─── Rhai script tests ────────────────────────────────────────────────
919
920    #[tokio::test]
921    async fn dry_run_with_rhai_script_passing() {
922        let dir = tempfile::tempdir().unwrap();
923        let project = dir.path();
924        let manifest = r#"
925[agent]
926name = "test-agent"
927version = "0.1.0"
928description = "test"
929
930[stages.main]
931model = { provider = "anthropic", model = "claude-sonnet-4-6" }
932"#;
933        write_test_agent(project, manifest);
934        let tests_dir = project.join("tests");
935        std::fs::create_dir_all(&tests_dir).unwrap();
936
937        // Write a Rhai script that returns true (passes)
938        std::fs::write(tests_dir.join("pass_test.rhai"), "true").unwrap();
939
940        let args = TestArgs {
941            path: Some(project.to_str().unwrap().to_string()),
942            filter: None,
943            dry_run: true,
944        };
945        let result = execute(args).await;
946        assert!(result.is_ok());
947    }
948
949    #[tokio::test]
950    async fn dry_run_with_rhai_script_returning_false() {
951        let dir = tempfile::tempdir().unwrap();
952        let project = dir.path();
953        let manifest = r#"
954[agent]
955name = "test-agent"
956version = "0.1.0"
957description = "test"
958
959[stages.main]
960model = { provider = "anthropic", model = "claude-sonnet-4-6" }
961"#;
962        write_test_agent(project, manifest);
963        let tests_dir = project.join("tests");
964        std::fs::create_dir_all(&tests_dir).unwrap();
965
966        // Write a Rhai script that returns false (fails)
967        std::fs::write(tests_dir.join("fail_test.rhai"), "false").unwrap();
968
969        let args = TestArgs {
970            path: Some(project.to_str().unwrap().to_string()),
971            filter: None,
972            dry_run: true,
973        };
974        let result = execute(args).await;
975        assert!(result.is_err()); // Should report test failure
976    }
977
978    #[tokio::test]
979    async fn dry_run_with_rhai_script_error() {
980        let dir = tempfile::tempdir().unwrap();
981        let project = dir.path();
982        let manifest = r#"
983[agent]
984name = "test-agent"
985version = "0.1.0"
986description = "test"
987
988[stages.main]
989model = { provider = "anthropic", model = "claude-sonnet-4-6" }
990"#;
991        write_test_agent(project, manifest);
992        let tests_dir = project.join("tests");
993        std::fs::create_dir_all(&tests_dir).unwrap();
994
995        // Write a Rhai script that throws an error
996        std::fs::write(
997            tests_dir.join("error_test.rhai"),
998            "throw \"intentional error\"",
999        )
1000        .unwrap();
1001
1002        let args = TestArgs {
1003            path: Some(project.to_str().unwrap().to_string()),
1004            filter: None,
1005            dry_run: true,
1006        };
1007        let result = execute(args).await;
1008        assert!(result.is_err()); // Should report script error as failure
1009    }
1010
1011    #[tokio::test]
1012    async fn dry_run_with_rhai_non_bool_result_passes() {
1013        let dir = tempfile::tempdir().unwrap();
1014        let project = dir.path();
1015        let manifest = r#"
1016[agent]
1017name = "test-agent"
1018version = "0.1.0"
1019description = "test"
1020
1021[stages.main]
1022model = { provider = "anthropic", model = "claude-sonnet-4-6" }
1023"#;
1024        write_test_agent(project, manifest);
1025        let tests_dir = project.join("tests");
1026        std::fs::create_dir_all(&tests_dir).unwrap();
1027
1028        // Write a Rhai script that returns a non-bool (treated as pass)
1029        std::fs::write(tests_dir.join("nonbool_test.rhai"), "42").unwrap();
1030
1031        let args = TestArgs {
1032            path: Some(project.to_str().unwrap().to_string()),
1033            filter: None,
1034            dry_run: true,
1035        };
1036        let result = execute(args).await;
1037        assert!(result.is_ok()); // Non-bool return treated as pass
1038    }
1039
1040    #[tokio::test]
1041    async fn dry_run_with_rhai_filter_matches() {
1042        let dir = tempfile::tempdir().unwrap();
1043        let project = dir.path();
1044        let manifest = r#"
1045[agent]
1046name = "test-agent"
1047version = "0.1.0"
1048description = "test"
1049
1050[stages.main]
1051model = { provider = "anthropic", model = "claude-sonnet-4-6" }
1052"#;
1053        write_test_agent(project, manifest);
1054        let tests_dir = project.join("tests");
1055        std::fs::create_dir_all(&tests_dir).unwrap();
1056
1057        // A rhai script whose name won't match the filter
1058        std::fs::write(tests_dir.join("fail_test.rhai"), "false").unwrap();
1059        // A rhai script that passes and matches the filter
1060        std::fs::write(tests_dir.join("good_test.rhai"), "true").unwrap();
1061
1062        let args = TestArgs {
1063            path: Some(project.to_str().unwrap().to_string()),
1064            filter: Some("good".to_string()),
1065            dry_run: true,
1066        };
1067        let result = execute(args).await;
1068        assert!(result.is_ok()); // Only "good_test.rhai" runs, which passes
1069    }
1070
1071    // ─── dry_run with multiple test files ────────────────────────────────
1072
1073    #[tokio::test]
1074    async fn dry_run_with_multiple_test_files() {
1075        let dir = tempfile::tempdir().unwrap();
1076        let project = dir.path();
1077        let manifest = r#"
1078[agent]
1079name = "test-agent"
1080version = "0.1.0"
1081description = "test"
1082
1083[stages.main]
1084model = { provider = "anthropic", model = "claude-sonnet-4-6" }
1085"#;
1086        write_test_agent(project, manifest);
1087        let tests_dir = project.join("tests");
1088        std::fs::create_dir_all(&tests_dir).unwrap();
1089
1090        let test1 = r#"
1091[[test]]
1092name = "test_a"
1093input = "hello"
1094expect_contains = "world"
1095"#;
1096        let test2 = r#"
1097[[test]]
1098name = "test_b"
1099input = "foo"
1100expect_tool_call = "bar"
1101"#;
1102        std::fs::write(tests_dir.join("file1.toml"), test1).unwrap();
1103        std::fs::write(tests_dir.join("file2.toml"), test2).unwrap();
1104
1105        let args = TestArgs {
1106            path: Some(project.to_str().unwrap().to_string()),
1107            filter: None,
1108            dry_run: true,
1109        };
1110        let result = execute(args).await;
1111        assert!(result.is_ok());
1112    }
1113
1114    // ─── dry_run with invalid TOML file ──────────────────────────────────
1115
1116    #[tokio::test]
1117    async fn dry_run_with_invalid_toml_file() {
1118        let dir = tempfile::tempdir().unwrap();
1119        let project = dir.path();
1120        let manifest = r#"
1121[agent]
1122name = "test-agent"
1123version = "0.1.0"
1124description = "test"
1125
1126[stages.main]
1127model = { provider = "anthropic", model = "claude-sonnet-4-6" }
1128"#;
1129        write_test_agent(project, manifest);
1130        let tests_dir = project.join("tests");
1131        std::fs::create_dir_all(&tests_dir).unwrap();
1132        std::fs::write(tests_dir.join("bad.toml"), "not valid {{{ toml").unwrap();
1133
1134        let args = TestArgs {
1135            path: Some(project.to_str().unwrap().to_string()),
1136            filter: None,
1137            dry_run: true,
1138        };
1139        let result = execute(args).await;
1140        assert!(result.is_err());
1141    }
1142
1143    // ─── run_test_case: mock provider (no real network calls) ────────────
1144    //
1145    // `execute()`'s non-dry-run path calls `Config::load()`, which reads the
1146    // developer's real `~/.leviath/config.toml` (and env var fallbacks) --
1147    // there's no path-injection seam for it from this file, and adding one
1148    // would require touching `config.rs`, which is out of scope. Driving
1149    // `execute(dry_run: false)` in a test would risk registering a real
1150    // provider with a real API key and making a live network call, which is
1151    // exactly the kind of flakiness/cost we must not introduce. Instead, we
1152    // exercise `run_test_case` directly with an in-memory mock `Provider`,
1153    // which covers the same assertion/response-handling logic without any
1154    // I/O.
1155
1156    use leviath_providers::{
1157        FinishReason, InferenceRequest, InferenceResponse, Provider, TokenUsage, ToolCall,
1158    };
1159
1160    /// A mock provider that returns a fixed canned response, entirely in
1161    /// memory - no network calls, no subprocess spawning.
1162    struct MockProvider {
1163        content: String,
1164        tool_calls: Vec<ToolCall>,
1165    }
1166
1167    #[async_trait::async_trait]
1168    impl Provider for MockProvider {
1169        async fn infer(
1170            &self,
1171            _request: InferenceRequest,
1172        ) -> leviath_providers::Result<InferenceResponse> {
1173            Ok(InferenceResponse {
1174                content: self.content.clone(),
1175                tool_calls: self.tool_calls.clone(),
1176                tokens_used: TokenUsage {
1177                    prompt_tokens: 1,
1178                    completion_tokens: 1,
1179                    total_tokens: 2,
1180                    cached_tokens: 0,
1181                    cache_write_tokens: 0,
1182                },
1183                finish_reason: FinishReason::Complete,
1184            })
1185        }
1186
1187        async fn count_tokens(&self, text: &str, _model: &str) -> usize {
1188            text.len()
1189        }
1190
1191        fn max_context_tokens(&self, _model: &str) -> usize {
1192            8192
1193        }
1194
1195        fn name(&self) -> &str {
1196            "mock"
1197        }
1198
1199        fn capabilities(&self, _model: &str) -> leviath_providers::ModelCapabilities {
1200            leviath_providers::ModelCapabilities::default()
1201        }
1202    }
1203
1204    /// A mock provider that does NOT support temperature (the default caps have
1205    /// it `true`), so the `else { 0.0 }` branch of the temperature choice runs.
1206    struct NoTemperatureProvider;
1207
1208    #[async_trait::async_trait]
1209    impl Provider for NoTemperatureProvider {
1210        async fn infer(
1211            &self,
1212            _request: InferenceRequest,
1213        ) -> leviath_providers::Result<InferenceResponse> {
1214            Ok(InferenceResponse {
1215                content: "cold hello".to_string(),
1216                tool_calls: vec![],
1217                tokens_used: TokenUsage {
1218                    prompt_tokens: 1,
1219                    completion_tokens: 1,
1220                    total_tokens: 2,
1221                    cached_tokens: 0,
1222                    cache_write_tokens: 0,
1223                },
1224                finish_reason: FinishReason::Complete,
1225            })
1226        }
1227
1228        async fn count_tokens(&self, text: &str, _model: &str) -> usize {
1229            text.len()
1230        }
1231
1232        fn max_context_tokens(&self, _model: &str) -> usize {
1233            8192
1234        }
1235
1236        fn name(&self) -> &str {
1237            "no-temperature"
1238        }
1239
1240        fn capabilities(&self, _model: &str) -> leviath_providers::ModelCapabilities {
1241            leviath_providers::ModelCapabilities {
1242                supports_temperature: false,
1243                ..Default::default()
1244            }
1245        }
1246    }
1247
1248    /// A mock provider that always returns an error.
1249    struct ErrorProvider;
1250
1251    #[async_trait::async_trait]
1252    impl Provider for ErrorProvider {
1253        async fn infer(
1254            &self,
1255            _request: InferenceRequest,
1256        ) -> leviath_providers::Result<InferenceResponse> {
1257            Err(leviath_providers::ProviderError::ApiError(
1258                "simulated inference error".to_string(),
1259            ))
1260        }
1261
1262        async fn count_tokens(&self, text: &str, _model: &str) -> usize {
1263            text.len()
1264        }
1265
1266        fn max_context_tokens(&self, _model: &str) -> usize {
1267            8192
1268        }
1269
1270        fn name(&self) -> &str {
1271            "error-provider"
1272        }
1273
1274        fn capabilities(&self, _model: &str) -> leviath_providers::ModelCapabilities {
1275            leviath_providers::ModelCapabilities::default()
1276        }
1277    }
1278
1279    fn basic_blueprint() -> leviath_core::Blueprint {
1280        let manifest = r#"
1281[agent]
1282name = "test-agent"
1283version = "0.1.0"
1284description = "test"
1285
1286[stages.main]
1287model = { provider = "anthropic", model = "claude-sonnet-4-6" }
1288"#;
1289        parse_manifest(manifest).unwrap()
1290    }
1291
1292    /// Blueprint with an explicit `tool_results` region, so the
1293    /// `if window.get_region("tool_results").is_none()` branch is NOT taken.
1294    fn blueprint_with_tool_results_region() -> leviath_core::Blueprint {
1295        let manifest = r#"
1296[agent]
1297name = "test-agent"
1298version = "0.1.0"
1299description = "test"
1300
1301[stages.main]
1302model = { provider = "anthropic", model = "claude-sonnet-4-6" }
1303
1304[context.regions.tool_results]
1305kind = "temporary"
1306max_tokens = 5000
1307"#;
1308        parse_manifest(manifest).unwrap()
1309    }
1310
1311    /// A provider that records the request it receives, so a test can assert
1312    /// what `lev test` actually assembled (e.g. a custom region's rendered
1313    /// output).
1314    struct RecordingProvider {
1315        seen: std::sync::Arc<std::sync::Mutex<Option<InferenceRequest>>>,
1316    }
1317
1318    #[async_trait::async_trait]
1319    impl Provider for RecordingProvider {
1320        async fn infer(
1321            &self,
1322            request: InferenceRequest,
1323        ) -> leviath_providers::Result<InferenceResponse> {
1324            *self.seen.lock().unwrap() = Some(request);
1325            Ok(InferenceResponse {
1326                content: "recorded".to_string(),
1327                tool_calls: vec![],
1328                tokens_used: TokenUsage {
1329                    prompt_tokens: 1,
1330                    completion_tokens: 1,
1331                    total_tokens: 2,
1332                    cached_tokens: 0,
1333                    cache_write_tokens: 0,
1334                },
1335                finish_reason: FinishReason::Complete,
1336            })
1337        }
1338
1339        async fn count_tokens(&self, text: &str, _model: &str) -> usize {
1340            text.len()
1341        }
1342
1343        fn max_context_tokens(&self, _model: &str) -> usize {
1344            8192
1345        }
1346
1347        fn name(&self) -> &str {
1348            "recording"
1349        }
1350
1351        fn capabilities(&self, _model: &str) -> leviath_providers::ModelCapabilities {
1352            leviath_providers::ModelCapabilities::default()
1353        }
1354    }
1355
1356    /// `lev test` runs custom-region render hooks with the entry stage's real
1357    /// metadata - the preview a hook author iterates against.
1358    #[tokio::test]
1359    async fn run_test_case_renders_custom_region_through_its_script() {
1360        let manifest = r#"
1361[agent]
1362name = "custom-test-agent"
1363version = "0.1.0"
1364description = "test"
1365
1366[stages.main]
1367model = { provider = "anthropic", model = "claude-sonnet-4-6" }
1368
1369[context.regions.task]
1370kind = "pinned"
1371max_tokens = 4000
1372
1373[context.regions.brain]
1374kind = "custom"
1375script = "hooks/brain.rhai"
1376max_tokens = 4000
1377"#;
1378        let blueprint = parse_manifest(manifest).unwrap();
1379        let scripts = std::collections::HashMap::from([(
1380            "hooks/brain.rhai".to_string(),
1381            std::sync::Arc::new(
1382                leviath_scripting::region_hook::compile(
1383                    "hooks/brain.rhai",
1384                    "fn render(ctx) { `<brain stage=${ctx.stage_name} model=${ctx.model}>` }",
1385                )
1386                .unwrap(),
1387            ),
1388        )]);
1389        let seen = std::sync::Arc::new(std::sync::Mutex::new(None));
1390        let mut registry = ProviderRegistry::new();
1391        registry.register(
1392            "anthropic".to_string(),
1393            Arc::new(RecordingProvider { seen: seen.clone() }),
1394        );
1395        let tc = TestCase {
1396            name: "custom_render".to_string(),
1397            input: "hi".to_string(),
1398            expect_contains: Some("recorded".to_string()),
1399            expect_tool_call: None,
1400            max_tokens: None,
1401        };
1402        let passed = run_test_case(&blueprint, &registry, &tc, &scripts)
1403            .await
1404            .unwrap();
1405        assert!(passed);
1406        let request = seen.lock().unwrap().take().expect("provider saw a request");
1407        // Precompute the texts so the assert message costs no extra branch.
1408        let system_texts: Vec<&String> = request.system.iter().map(|b| &b.text).collect();
1409        let rendered = system_texts
1410            .iter()
1411            .any(|t| t.as_str() == "<brain stage=main model=claude-sonnet-4-6>");
1412        assert!(
1413            rendered,
1414            "custom region rendered with stage metadata; system blocks: {system_texts:?}"
1415        );
1416
1417        // Exercise the recording provider's remaining trait surface directly.
1418        let provider = registry.get("anthropic").unwrap();
1419        assert_eq!(provider.count_tokens("abcd", "m").await, 4);
1420        assert_eq!(provider.max_context_tokens("m"), 8192);
1421        assert_eq!(provider.name(), "recording");
1422    }
1423
1424    /// The custom-region resolve error path in `execute` (a declared script
1425    /// that doesn't exist fails before any provider setup, dry-run or not).
1426    #[tokio::test]
1427    async fn execute_fails_fast_on_a_broken_custom_region_script() {
1428        let dir = tempfile::tempdir().unwrap();
1429        let project = dir.path();
1430        std::fs::write(
1431            project.join("agent.leviath"),
1432            r#"
1433[agent]
1434name = "broken-custom"
1435version = "0.1.0"
1436description = "d"
1437
1438[stages.main]
1439model = { provider = "anthropic", model = "m" }
1440
1441[context.regions.brain]
1442kind = "custom"
1443script = "hooks/missing.rhai"
1444max_tokens = 4000
1445"#,
1446        )
1447        .unwrap();
1448        std::fs::create_dir(project.join("tests")).unwrap();
1449        std::fs::write(
1450            project.join("tests/basic.toml"),
1451            "[[test]]\nname = \"t\"\ninput = \"hi\"\n",
1452        )
1453        .unwrap();
1454        let args = TestArgs {
1455            path: Some(project.to_str().unwrap().to_string()),
1456            filter: None,
1457            dry_run: true,
1458        };
1459        let err = execute(args).await.unwrap_err().to_string();
1460        assert!(err.contains("region 'brain'"), "{err}");
1461        assert!(err.contains("hooks/missing.rhai"), "{err}");
1462    }
1463
1464    // ── new coverage tests ────────────────────────────────────────────────────
1465
1466    /// Covers the `map_err(|e| anyhow!("Inference failed: {}", e))` closure
1467    /// path at the `provider.infer(...)` call-site.
1468    #[tokio::test]
1469    async fn run_test_case_inference_error_propagates() {
1470        let blueprint = basic_blueprint();
1471        let mut registry = ProviderRegistry::new();
1472        registry.register("anthropic".to_string(), Arc::new(ErrorProvider));
1473        let tc = TestCase {
1474            name: "inference_error".to_string(),
1475            input: "hi".to_string(),
1476            expect_contains: Some("x".to_string()),
1477            expect_tool_call: None,
1478            max_tokens: None,
1479        };
1480        let result = run_test_case(&blueprint, &registry, &tc, &Default::default()).await;
1481        let err = result.unwrap_err().to_string();
1482        assert!(err.contains("Inference failed"));
1483    }
1484
1485    /// Covers the `ok_or(anyhow!("Blueprint has no stages"))` path.
1486    #[tokio::test]
1487    async fn run_test_case_blueprint_with_no_stages_errors() {
1488        use leviath_core::{Blueprint, layout::ContextLayout};
1489        let blueprint = Blueprint::new(
1490            "no-stages".to_string(),
1491            "test".to_string(),
1492            vec![],
1493            ContextLayout::new(vec![], 4096),
1494        );
1495        let registry = ProviderRegistry::new();
1496        let tc = TestCase {
1497            name: "no_stages".to_string(),
1498            input: "hi".to_string(),
1499            expect_contains: Some("x".to_string()),
1500            expect_tool_call: None,
1501            max_tokens: None,
1502        };
1503        let result = run_test_case(&blueprint, &registry, &tc, &Default::default()).await;
1504        let err = result.unwrap_err().to_string();
1505        assert!(err.contains("Blueprint has no stages"));
1506    }
1507
1508    /// A blueprint that already declares a `tool_results` region runs fine (the
1509    /// window builder leaves the existing region in place).
1510    #[tokio::test]
1511    async fn run_test_case_with_preexisting_tool_results_region() {
1512        let blueprint = blueprint_with_tool_results_region();
1513        let mut registry = ProviderRegistry::new();
1514        registry.register(
1515            "anthropic".to_string(),
1516            Arc::new(MockProvider {
1517                content: "hello world".to_string(),
1518                tool_calls: vec![],
1519            }),
1520        );
1521        let tc = TestCase {
1522            name: "has_tool_results_region".to_string(),
1523            input: "hi".to_string(),
1524            expect_contains: Some("world".to_string()),
1525            expect_tool_call: None,
1526            max_tokens: None,
1527        };
1528        let result = run_test_case(&blueprint, &registry, &tc, &Default::default()).await;
1529        assert!(result.unwrap());
1530    }
1531
1532    /// Covers `fs::read_to_string(&manifest_path)?` failing by making
1533    /// `agent.leviath` a *directory*: `exists()` passes the guard but the read
1534    /// fails on every platform.
1535    #[tokio::test]
1536    async fn execute_with_registry_manifest_unreadable_errors() {
1537        let dir = tempfile::tempdir().unwrap();
1538        let project = dir.path();
1539        std::fs::create_dir_all(project.join("agent.leviath")).unwrap();
1540        let tests_dir = project.join("tests");
1541        std::fs::create_dir_all(&tests_dir).unwrap();
1542        let args = TestArgs {
1543            path: Some(project.to_str().unwrap().to_string()),
1544            filter: None,
1545            dry_run: true,
1546        };
1547        let result = execute_with_registry(args, Box::new(build_registry_from_config)).await;
1548        assert!(result.is_err());
1549    }
1550
1551    /// Covers `Config::load()?` (line 139) failing when the config file exists
1552    /// but contains invalid TOML.  Uses `isolate_config_path_for_test` so that
1553    /// we redirect `LEVIATH_CONFIG_PATH` to a temp file we control, avoiding
1554    /// any mutation of the user's real `~/.leviath/config.toml`.
1555    #[tokio::test]
1556    async fn execute_with_registry_config_load_fails_errors() {
1557        let dir = tempfile::tempdir().unwrap();
1558        let project = dir.path();
1559        let manifest = r#"
1560[agent]
1561name = "test-agent"
1562version = "0.1.0"
1563description = "test"
1564
1565[stages.main]
1566model = { provider = "anthropic", model = "claude-sonnet-4-6" }
1567"#;
1568        write_test_agent(project, manifest);
1569        let tests_dir = project.join("tests");
1570        std::fs::create_dir_all(&tests_dir).unwrap();
1571
1572        // Redirect Config::load() to a file with invalid TOML.
1573        crate::config::with_isolated_config_path_async(
1574            "test-cmd-config-fail",
1575            |fake_dir| async move {
1576                let bad_config = fake_dir.join("config.toml");
1577                std::fs::write(&bad_config, "not valid toml {{{").unwrap();
1578
1579                let args = TestArgs {
1580                    path: Some(project.to_str().unwrap().to_string()),
1581                    filter: None,
1582                    dry_run: false, // triggers Config::load()
1583                };
1584                let result =
1585                    execute_with_registry(args, Box::new(build_registry_from_config)).await;
1586                assert!(result.is_err());
1587            },
1588        )
1589        .await;
1590    }
1591
1592    /// Covers the `parse_manifest(&manifest_content)?` error path
1593    /// in `execute_with_registry` (invalid TOML in agent.leviath).
1594    #[tokio::test]
1595    async fn execute_with_registry_manifest_invalid_toml_errors() {
1596        let dir = tempfile::tempdir().unwrap();
1597        let project = dir.path();
1598        std::fs::write(project.join("agent.leviath"), "not valid toml {{{").unwrap();
1599        let tests_dir = project.join("tests");
1600        std::fs::create_dir_all(&tests_dir).unwrap();
1601        let args = TestArgs {
1602            path: Some(project.to_str().unwrap().to_string()),
1603            filter: None,
1604            dry_run: false,
1605        };
1606        let result =
1607            execute_with_registry(args, Box::new(mock_registry_builder("irrelevant", vec![])))
1608                .await;
1609        assert!(result.is_err());
1610    }
1611
1612    /// Covers `fs::read_dir(&tests_dir)?` failing by making `tests` a *file*:
1613    /// `exists()` passes the guard but `read_dir` fails on every platform.
1614    #[tokio::test]
1615    async fn execute_with_registry_tests_dir_unreadable_errors() {
1616        let dir = tempfile::tempdir().unwrap();
1617        let project = dir.path();
1618        let manifest = r#"
1619[agent]
1620name = "test-agent"
1621version = "0.1.0"
1622description = "test"
1623
1624[stages.main]
1625model = { provider = "anthropic", model = "claude-sonnet-4-6" }
1626"#;
1627        write_test_agent(project, manifest);
1628        // `tests` is a file, not a directory.
1629        std::fs::write(project.join("tests"), "not a dir").unwrap();
1630        let args = TestArgs {
1631            path: Some(project.to_str().unwrap().to_string()),
1632            filter: None,
1633            dry_run: true,
1634        };
1635        let result = execute_with_registry(args, Box::new(build_registry_from_config)).await;
1636        assert!(result.is_err());
1637    }
1638
1639    /// Covers `fs::read_to_string(&test_path)?` for a `.toml` entry by making
1640    /// it a *directory* (extension is still `toml`): `read_dir` yields it but
1641    /// the read fails on every platform.
1642    #[tokio::test]
1643    async fn execute_with_registry_toml_unreadable_errors() {
1644        let dir = tempfile::tempdir().unwrap();
1645        let project = dir.path();
1646        let manifest = r#"
1647[agent]
1648name = "test-agent"
1649version = "0.1.0"
1650description = "test"
1651
1652[stages.main]
1653model = { provider = "anthropic", model = "claude-sonnet-4-6" }
1654"#;
1655        write_test_agent(project, manifest);
1656        let tests_dir = project.join("tests");
1657        std::fs::create_dir_all(&tests_dir).unwrap();
1658        std::fs::create_dir_all(tests_dir.join("unreadable.toml")).unwrap();
1659        let args = TestArgs {
1660            path: Some(project.to_str().unwrap().to_string()),
1661            filter: None,
1662            dry_run: true,
1663        };
1664        let result = execute_with_registry(args, Box::new(build_registry_from_config)).await;
1665        assert!(result.is_err());
1666    }
1667
1668    /// Covers `fs::read_to_string(&test_path)?` for a `.rhai` entry by making
1669    /// it a *directory* (extension is still `rhai`): `read_dir` yields it but
1670    /// the read fails on every platform.
1671    #[tokio::test]
1672    async fn execute_with_registry_rhai_unreadable_errors() {
1673        let dir = tempfile::tempdir().unwrap();
1674        let project = dir.path();
1675        let manifest = r#"
1676[agent]
1677name = "test-agent"
1678version = "0.1.0"
1679description = "test"
1680
1681[stages.main]
1682model = { provider = "anthropic", model = "claude-sonnet-4-6" }
1683"#;
1684        write_test_agent(project, manifest);
1685        let tests_dir = project.join("tests");
1686        std::fs::create_dir_all(&tests_dir).unwrap();
1687        std::fs::create_dir_all(tests_dir.join("unreadable.rhai")).unwrap();
1688        let args = TestArgs {
1689            path: Some(project.to_str().unwrap().to_string()),
1690            filter: None,
1691            dry_run: true,
1692        };
1693        let result = execute_with_registry(args, Box::new(build_registry_from_config)).await;
1694        assert!(result.is_err());
1695    }
1696
1697    /// A blueprint with no Pinned region still runs: `init_window` simply skips
1698    /// seeding the task, and the inference proceeds.
1699    #[tokio::test]
1700    async fn run_test_case_with_no_pinned_region_still_runs() {
1701        use leviath_core::Blueprint;
1702        use leviath_core::layout::ContextLayout;
1703        let blueprint = Blueprint::new(
1704            "no-regions".to_string(),
1705            "test".to_string(),
1706            vec![leviath_core::Stage::new(
1707                "main".to_string(),
1708                leviath_core::blueprint::ModelConfig::new(
1709                    "anthropic".to_string(),
1710                    "claude-sonnet-4-6".to_string(),
1711                ),
1712            )],
1713            ContextLayout::new(vec![], 4096),
1714        );
1715        let mut registry = ProviderRegistry::new();
1716        registry.register(
1717            "anthropic".to_string(),
1718            Arc::new(MockProvider {
1719                content: "hello world".to_string(),
1720                tool_calls: vec![],
1721            }),
1722        );
1723        let tc = TestCase {
1724            name: "no_pinned".to_string(),
1725            input: "hi".to_string(),
1726            expect_contains: Some("world".to_string()),
1727            expect_tool_call: None,
1728            max_tokens: None,
1729        };
1730        assert!(
1731            run_test_case(&blueprint, &registry, &tc, &Default::default())
1732                .await
1733                .unwrap()
1734        );
1735    }
1736
1737    #[tokio::test]
1738    async fn run_test_case_passes_with_expect_contains() {
1739        let blueprint = basic_blueprint();
1740        let mut registry = ProviderRegistry::new();
1741        registry.register(
1742            "anthropic".to_string(),
1743            Arc::new(MockProvider {
1744                content: "Hello, world!".to_string(),
1745                tool_calls: vec![],
1746            }),
1747        );
1748
1749        let tc = TestCase {
1750            name: "greeting".to_string(),
1751            input: "say hello".to_string(),
1752            expect_contains: Some("world".to_string()),
1753            expect_tool_call: None,
1754            max_tokens: None,
1755        };
1756
1757        let result = run_test_case(&blueprint, &registry, &tc, &Default::default()).await;
1758        assert!(result.unwrap());
1759    }
1760
1761    #[tokio::test]
1762    async fn run_test_case_fails_expect_contains_mismatch() {
1763        let blueprint = basic_blueprint();
1764        let mut registry = ProviderRegistry::new();
1765        registry.register(
1766            "anthropic".to_string(),
1767            Arc::new(MockProvider {
1768                content: "Goodbye".to_string(),
1769                tool_calls: vec![],
1770            }),
1771        );
1772
1773        let tc = TestCase {
1774            name: "greeting".to_string(),
1775            input: "say hello".to_string(),
1776            expect_contains: Some("world".to_string()),
1777            expect_tool_call: None,
1778            max_tokens: None,
1779        };
1780
1781        let result = run_test_case(&blueprint, &registry, &tc, &Default::default()).await;
1782        assert!(!result.unwrap());
1783    }
1784
1785    #[tokio::test]
1786    async fn run_test_case_passes_with_expect_tool_call() {
1787        let blueprint = basic_blueprint();
1788        let mut registry = ProviderRegistry::new();
1789        registry.register(
1790            "anthropic".to_string(),
1791            Arc::new(MockProvider {
1792                content: String::new(),
1793                tool_calls: vec![ToolCall {
1794                    id: "call_1".to_string(),
1795                    name: "bash".to_string(),
1796                    arguments: serde_json::json!({}),
1797                    thought_signature: None,
1798                }],
1799            }),
1800        );
1801
1802        let tc = TestCase {
1803            name: "tool_test".to_string(),
1804            input: "run a command".to_string(),
1805            expect_contains: None,
1806            expect_tool_call: Some("bash".to_string()),
1807            max_tokens: None,
1808        };
1809
1810        let result = run_test_case(&blueprint, &registry, &tc, &Default::default()).await;
1811        assert!(result.unwrap());
1812    }
1813
1814    #[tokio::test]
1815    async fn run_test_case_fails_expect_tool_call_missing() {
1816        let blueprint = basic_blueprint();
1817        let mut registry = ProviderRegistry::new();
1818        registry.register(
1819            "anthropic".to_string(),
1820            Arc::new(MockProvider {
1821                content: "no tools here".to_string(),
1822                // A non-matching (rather than empty) tool call list still
1823                // fails the "has_tool" check but also exercises the
1824                // subsequent `tool_names` diagnostic's `.map()` closure,
1825                // which an empty Vec's `.iter().map(...)` never invokes at
1826                // all.
1827                tool_calls: vec![ToolCall {
1828                    id: "call_1".to_string(),
1829                    name: "write_file".to_string(),
1830                    arguments: serde_json::json!({}),
1831                    thought_signature: None,
1832                }],
1833            }),
1834        );
1835
1836        let tc = TestCase {
1837            name: "tool_test".to_string(),
1838            input: "run a command".to_string(),
1839            expect_contains: None,
1840            expect_tool_call: Some("bash".to_string()),
1841            max_tokens: None,
1842        };
1843
1844        let result = run_test_case(&blueprint, &registry, &tc, &Default::default()).await;
1845        assert!(!result.unwrap());
1846    }
1847
1848    #[tokio::test]
1849    async fn run_test_case_fails_both_assertions() {
1850        let blueprint = basic_blueprint();
1851        let mut registry = ProviderRegistry::new();
1852        registry.register(
1853            "anthropic".to_string(),
1854            Arc::new(MockProvider {
1855                content: "unrelated content".to_string(),
1856                tool_calls: vec![],
1857            }),
1858        );
1859
1860        let tc = TestCase {
1861            name: "both".to_string(),
1862            input: "do stuff".to_string(),
1863            expect_contains: Some("expected".to_string()),
1864            expect_tool_call: Some("write_file".to_string()),
1865            max_tokens: None,
1866        };
1867
1868        let result = run_test_case(&blueprint, &registry, &tc, &Default::default()).await;
1869        assert!(!result.unwrap());
1870    }
1871
1872    #[tokio::test]
1873    async fn run_test_case_no_assertions_always_passes() {
1874        let blueprint = basic_blueprint();
1875        let mut registry = ProviderRegistry::new();
1876        registry.register(
1877            "anthropic".to_string(),
1878            Arc::new(MockProvider {
1879                content: "anything".to_string(),
1880                tool_calls: vec![],
1881            }),
1882        );
1883
1884        let tc = TestCase {
1885            name: "no_assertions".to_string(),
1886            input: "hi".to_string(),
1887            expect_contains: None,
1888            expect_tool_call: None,
1889            max_tokens: None,
1890        };
1891
1892        let result = run_test_case(&blueprint, &registry, &tc, &Default::default()).await;
1893        assert!(result.unwrap());
1894    }
1895
1896    #[tokio::test]
1897    async fn run_test_case_provider_not_registered_errors() {
1898        let blueprint = basic_blueprint();
1899        let registry = ProviderRegistry::new(); // empty -- "anthropic" not registered
1900
1901        let tc = TestCase {
1902            name: "no_provider".to_string(),
1903            input: "hi".to_string(),
1904            expect_contains: Some("x".to_string()),
1905            expect_tool_call: None,
1906            max_tokens: None,
1907        };
1908
1909        let result = run_test_case(&blueprint, &registry, &tc, &Default::default()).await;
1910        let err = result.unwrap_err().to_string();
1911        assert!(err.contains("not configured"));
1912    }
1913
1914    #[tokio::test]
1915    async fn no_temperature_provider_metadata_is_exercised() {
1916        let p = NoTemperatureProvider;
1917        assert_eq!(p.name(), "no-temperature");
1918        assert_eq!(p.count_tokens("abcd", "m").await, 4);
1919        assert_eq!(p.max_context_tokens("m"), 8192);
1920    }
1921
1922    #[tokio::test]
1923    async fn run_test_case_omits_temperature_when_provider_lacks_it() {
1924        let blueprint = basic_blueprint();
1925        let mut registry = ProviderRegistry::new();
1926        registry.register("anthropic".to_string(), Arc::new(NoTemperatureProvider));
1927        let tc = TestCase {
1928            name: "no_temp".to_string(),
1929            input: "hi".to_string(),
1930            expect_contains: Some("cold".to_string()),
1931            expect_tool_call: None,
1932            max_tokens: None,
1933        };
1934        assert!(
1935            run_test_case(&blueprint, &registry, &tc, &Default::default())
1936                .await
1937                .unwrap()
1938        );
1939    }
1940
1941    #[tokio::test]
1942    async fn run_test_case_long_input_runs() {
1943        // A long input still seeds cleanly into the pinned task region.
1944        let blueprint = basic_blueprint();
1945        let mut registry = ProviderRegistry::new();
1946        registry.register(
1947            "anthropic".to_string(),
1948            Arc::new(MockProvider {
1949                content: "response text mentioning keyword".to_string(),
1950                tool_calls: vec![],
1951            }),
1952        );
1953
1954        let tc = TestCase {
1955            name: "long_input".to_string(),
1956            input: "x".repeat(500),
1957            expect_contains: Some("keyword".to_string()),
1958            expect_tool_call: None,
1959            max_tokens: None,
1960        };
1961
1962        let result = run_test_case(&blueprint, &registry, &tc, &Default::default()).await;
1963        assert!(result.unwrap());
1964    }
1965
1966    // ─── execute_with_registry: non-dry-run path (mock provider) ────────────
1967    //
1968    // `execute()`'s non-dry-run path still calls the real `Config::load()`
1969    // (no path-injection seam for that without touching config.rs, out of
1970    // scope here). `execute_with_registry` takes the registry-building step
1971    // as a parameter, so we can hand it a registry built entirely from an
1972    // in-memory `MockProvider` and don't care what the config *contains* --
1973    // no network calls, no real API keys read. But `Config::load()?` still
1974    // propagates a hard error via `?` if it fails, which is *not* irrelevant:
1975    // every test below that reaches this line uses
1976    // `isolate_config_path_for_test` to point `LEVIATH_CONFIG_PATH` at a
1977    // guaranteed-absent path, so `Config::load()` deterministically falls
1978    // back to defaults instead of racing some *other*, concurrently-running
1979    // test's temporarily-malformed config file at the same process-global
1980    // env var (see `models.rs`'s own `isolate_config_path_for_test` users
1981    // for the other side of that race - without this, this whole group was
1982    // observed to fail intermittently, with a config-parse error instead of
1983    // the expected test-run outcome, when run alongside `commands::models`'s
1984    // test suite).
1985
1986    fn mock_registry_builder(
1987        content: &'static str,
1988        tool_calls: Vec<ToolCall>,
1989    ) -> impl FnOnce(&Config) -> ProviderRegistry {
1990        move |_config: &Config| {
1991            let mut reg = ProviderRegistry::new();
1992            reg.register(
1993                "anthropic".to_string(),
1994                Arc::new(MockProvider {
1995                    content: content.to_string(),
1996                    tool_calls,
1997                }),
1998            );
1999            reg
2000        }
2001    }
2002
2003    fn write_project_with_test_file(project: &std::path::Path, test_toml: &str) {
2004        let manifest = r#"
2005[agent]
2006name = "test-agent"
2007version = "0.1.0"
2008description = "test"
2009
2010[stages.main]
2011model = { provider = "anthropic", model = "claude-sonnet-4-6" }
2012"#;
2013        write_test_agent(project, manifest);
2014        let tests_dir = project.join("tests");
2015        std::fs::create_dir_all(&tests_dir).unwrap();
2016        std::fs::write(tests_dir.join("basic.toml"), test_toml).unwrap();
2017    }
2018
2019    #[tokio::test]
2020    async fn execute_with_registry_non_dry_run_all_pass() {
2021        crate::config::with_isolated_config_path_async(
2022            "test-rs-non-dry-run-all-pass",
2023            |_fake_dir| async move {
2024                let dir = tempfile::tempdir().unwrap();
2025                let project = dir.path();
2026                write_project_with_test_file(
2027                    project,
2028                    r#"
2029[[test]]
2030name = "greeting"
2031input = "say hello"
2032expect_contains = "world"
2033"#,
2034                );
2035
2036                let args = TestArgs {
2037                    path: Some(project.to_str().unwrap().to_string()),
2038                    filter: None,
2039                    dry_run: false,
2040                };
2041
2042                let result = with_tracing(|| {
2043                    execute_with_registry(
2044                        args,
2045                        Box::new(mock_registry_builder("Hello, world!", vec![])),
2046                    )
2047                })
2048                .await;
2049                assert!(result.is_ok());
2050            },
2051        )
2052        .await;
2053    }
2054
2055    #[tokio::test]
2056    async fn execute_with_registry_none_path_defaults_to_current_dir() {
2057        // Covers the `unwrap_or_else(|| ".".to_string())` closure, never
2058        // invoked by any other test (all of which pass an explicit `path`).
2059        // `cargo test`'s cwd is this crate's own source directory, which
2060        // has no `agent.leviath`, so this deterministically hits the
2061        // "No agent.leviath found" bail - proving the closure ran without
2062        // depending on (or mutating) any real project directory.
2063        let args = TestArgs {
2064            path: None,
2065            filter: None,
2066            dry_run: true,
2067        };
2068        let result = execute_with_registry(args, Box::new(build_registry_from_config)).await;
2069        assert!(result.is_err());
2070        assert!(
2071            result
2072                .unwrap_err()
2073                .to_string()
2074                .contains("No agent.leviath found")
2075        );
2076    }
2077
2078    #[tokio::test]
2079    async fn execute_with_registry_non_dry_run_failure_bails_with_count() {
2080        crate::config::with_isolated_config_path_async(
2081            "test-rs-non-dry-run-failure-bails-with-count",
2082            |_fake_dir| async move {
2083                let dir = tempfile::tempdir().unwrap();
2084                let project = dir.path();
2085                write_project_with_test_file(
2086                    project,
2087                    r#"
2088[[test]]
2089name = "greeting"
2090input = "say hello"
2091expect_contains = "world"
2092"#,
2093                );
2094
2095                let args = TestArgs {
2096                    path: Some(project.to_str().unwrap().to_string()),
2097                    filter: None,
2098                    dry_run: false,
2099                };
2100
2101                let result =
2102                    execute_with_registry(args, Box::new(mock_registry_builder("goodbye", vec![])))
2103                        .await;
2104                let err = result.unwrap_err().to_string();
2105                assert!(err.contains("1 test(s) failed"));
2106            },
2107        )
2108        .await;
2109    }
2110
2111    #[tokio::test]
2112    async fn execute_with_registry_non_dry_run_applies_filter() {
2113        crate::config::with_isolated_config_path_async(
2114            "test-rs-non-dry-run-applies-filter",
2115            |_fake_dir| async move {
2116                let dir = tempfile::tempdir().unwrap();
2117                let project = dir.path();
2118                write_project_with_test_file(
2119                    project,
2120                    r#"
2121[[test]]
2122name = "keep_me"
2123input = "say hello"
2124expect_contains = "world"
2125
2126[[test]]
2127name = "skip_me"
2128input = "say hello"
2129expect_contains = "unmatchable content"
2130"#,
2131                );
2132
2133                let args = TestArgs {
2134                    path: Some(project.to_str().unwrap().to_string()),
2135                    filter: Some("keep".to_string()),
2136                    dry_run: false,
2137                };
2138
2139                // "skip_me" would fail (its expectation never matches the mock
2140                // response), but the filter excludes it - only "keep_me" runs, and
2141                // it passes, so the whole run succeeds.
2142                let result = execute_with_registry(
2143                    args,
2144                    Box::new(mock_registry_builder("Hello, world!", vec![])),
2145                )
2146                .await;
2147                assert!(result.is_ok());
2148            },
2149        )
2150        .await;
2151    }
2152
2153    #[tokio::test]
2154    async fn execute_with_registry_non_dry_run_tool_call_assertion() {
2155        crate::config::with_isolated_config_path_async(
2156            "test-rs-non-dry-run-tool-call-assertion",
2157            |_fake_dir| async move {
2158                let dir = tempfile::tempdir().unwrap();
2159                let project = dir.path();
2160                write_project_with_test_file(
2161                    project,
2162                    r#"
2163[[test]]
2164name = "tool_test"
2165input = "run a command"
2166expect_tool_call = "bash"
2167"#,
2168                );
2169
2170                let args = TestArgs {
2171                    path: Some(project.to_str().unwrap().to_string()),
2172                    filter: None,
2173                    dry_run: false,
2174                };
2175
2176                let tool_calls = vec![ToolCall {
2177                    id: "call_1".to_string(),
2178                    name: "bash".to_string(),
2179                    arguments: serde_json::json!({}),
2180                    thought_signature: None,
2181                }];
2182                let result =
2183                    execute_with_registry(args, Box::new(mock_registry_builder("", tool_calls)))
2184                        .await;
2185                assert!(result.is_ok());
2186            },
2187        )
2188        .await;
2189    }
2190
2191    #[tokio::test]
2192    async fn execute_with_registry_non_dry_run_provider_error_counts_as_failure() {
2193        crate::config::with_isolated_config_path_async(
2194            "test-rs-non-dry-run-provider-error-counts-as-failure",
2195            |_fake_dir| async move {
2196                let dir = tempfile::tempdir().unwrap();
2197                let project = dir.path();
2198                write_project_with_test_file(
2199                    project,
2200                    r#"
2201[[test]]
2202name = "no_such_provider"
2203input = "hi"
2204expect_contains = "x"
2205"#,
2206                );
2207                // Overwrite the manifest with a provider name the mock registry never
2208                // registers, so `run_test_case`'s "not configured" error path fires
2209                // (the `Err(e)` arm of `execute`'s match, not `Ok(false)`).
2210                std::fs::write(
2211                    project.join("agent.leviath"),
2212                    r#"
2213[agent]
2214name = "test-agent"
2215version = "0.1.0"
2216description = "test"
2217
2218[stages.main]
2219model = { provider = "nonexistent-provider", model = "x" }
2220"#,
2221                )
2222                .unwrap();
2223
2224                let args = TestArgs {
2225                    path: Some(project.to_str().unwrap().to_string()),
2226                    filter: None,
2227                    dry_run: false,
2228                };
2229
2230                let result = execute_with_registry(
2231                    args,
2232                    Box::new(mock_registry_builder("irrelevant", vec![])),
2233                )
2234                .await;
2235                let err = result.unwrap_err().to_string();
2236                assert!(err.contains("1 test(s) failed"));
2237            },
2238        )
2239        .await;
2240    }
2241
2242    #[tokio::test]
2243    async fn execute_with_registry_non_dry_run_toml_malformed_errors() {
2244        crate::config::with_isolated_config_path_async(
2245            "test-rs-non-dry-run-toml-malformed-errors",
2246            |_fake_dir| async move {
2247                let dir = tempfile::tempdir().unwrap();
2248                let project = dir.path();
2249                write_project_with_test_file(project, "not valid {{{ toml");
2250
2251                let args = TestArgs {
2252                    path: Some(project.to_str().unwrap().to_string()),
2253                    filter: None,
2254                    dry_run: false,
2255                };
2256
2257                let result = execute_with_registry(
2258                    args,
2259                    Box::new(mock_registry_builder("irrelevant", vec![])),
2260                )
2261                .await;
2262                assert!(result.is_err());
2263                assert!(result.unwrap_err().to_string().contains("Failed to parse"));
2264            },
2265        )
2266        .await;
2267    }
2268
2269    // ─── rhai script execution path ──────────────────────────────────────────
2270
2271    #[tokio::test]
2272    async fn execute_with_registry_rhai_script_passes() {
2273        crate::config::with_isolated_config_path_async(
2274            "test-rs-rhai-script-passes",
2275            |_fake_dir| async move {
2276                let dir = tempfile::tempdir().unwrap();
2277                let project = dir.path();
2278                let manifest = r#"
2279[agent]
2280name = "test-agent"
2281version = "0.1.0"
2282description = "test"
2283
2284[stages.main]
2285model = { provider = "anthropic", model = "claude-sonnet-4-6" }
2286"#;
2287                write_test_agent(project, manifest);
2288                let tests_dir = project.join("tests");
2289                std::fs::create_dir_all(&tests_dir).unwrap();
2290                std::fs::write(tests_dir.join("script.rhai"), "true").unwrap();
2291
2292                let args = TestArgs {
2293                    path: Some(project.to_str().unwrap().to_string()),
2294                    filter: None,
2295                    dry_run: false,
2296                };
2297
2298                let result =
2299                    execute_with_registry(args, Box::new(mock_registry_builder("unused", vec![])))
2300                        .await;
2301                assert!(result.is_ok());
2302            },
2303        )
2304        .await;
2305    }
2306
2307    #[tokio::test]
2308    async fn execute_with_registry_rhai_script_returns_false_fails() {
2309        crate::config::with_isolated_config_path_async(
2310            "test-rs-rhai-script-returns-false-fails",
2311            |_fake_dir| async move {
2312                let dir = tempfile::tempdir().unwrap();
2313                let project = dir.path();
2314                let manifest = r#"
2315[agent]
2316name = "test-agent"
2317version = "0.1.0"
2318description = "test"
2319
2320[stages.main]
2321model = { provider = "anthropic", model = "claude-sonnet-4-6" }
2322"#;
2323                write_test_agent(project, manifest);
2324                let tests_dir = project.join("tests");
2325                std::fs::create_dir_all(&tests_dir).unwrap();
2326                std::fs::write(tests_dir.join("script.rhai"), "false").unwrap();
2327
2328                let args = TestArgs {
2329                    path: Some(project.to_str().unwrap().to_string()),
2330                    filter: None,
2331                    dry_run: false,
2332                };
2333
2334                let result =
2335                    execute_with_registry(args, Box::new(mock_registry_builder("unused", vec![])))
2336                        .await;
2337                let err = result.unwrap_err().to_string();
2338                assert!(err.contains("1 test(s) failed"));
2339            },
2340        )
2341        .await;
2342    }
2343
2344    #[tokio::test]
2345    async fn execute_with_registry_rhai_script_error_fails() {
2346        crate::config::with_isolated_config_path_async(
2347            "test-rs-rhai-script-error-fails",
2348            |_fake_dir| async move {
2349                let dir = tempfile::tempdir().unwrap();
2350                let project = dir.path();
2351                let manifest = r#"
2352[agent]
2353name = "test-agent"
2354version = "0.1.0"
2355description = "test"
2356
2357[stages.main]
2358model = { provider = "anthropic", model = "claude-sonnet-4-6" }
2359"#;
2360                write_test_agent(project, manifest);
2361                let tests_dir = project.join("tests");
2362                std::fs::create_dir_all(&tests_dir).unwrap();
2363                std::fs::write(tests_dir.join("script.rhai"), "this is not valid rhai (((")
2364                    .unwrap();
2365
2366                let args = TestArgs {
2367                    path: Some(project.to_str().unwrap().to_string()),
2368                    filter: None,
2369                    dry_run: false,
2370                };
2371
2372                let result =
2373                    execute_with_registry(args, Box::new(mock_registry_builder("unused", vec![])))
2374                        .await;
2375                assert!(result.is_err());
2376            },
2377        )
2378        .await;
2379    }
2380
2381    #[tokio::test]
2382    async fn execute_with_registry_rhai_script_non_bool_return_passes() {
2383        crate::config::with_isolated_config_path_async(
2384            "test-rs-rhai-script-non-bool-return-passes",
2385            |_fake_dir| async move {
2386                let dir = tempfile::tempdir().unwrap();
2387                let project = dir.path();
2388                let manifest = r#"
2389[agent]
2390name = "test-agent"
2391version = "0.1.0"
2392description = "test"
2393
2394[stages.main]
2395model = { provider = "anthropic", model = "claude-sonnet-4-6" }
2396"#;
2397                write_test_agent(project, manifest);
2398                let tests_dir = project.join("tests");
2399                std::fs::create_dir_all(&tests_dir).unwrap();
2400                // Returns an integer, not a bool - exercises the `else` arm of the
2401                // `result.as_bool()` match (treated as an automatic pass).
2402                std::fs::write(tests_dir.join("script.rhai"), "42").unwrap();
2403
2404                let args = TestArgs {
2405                    path: Some(project.to_str().unwrap().to_string()),
2406                    filter: None,
2407                    dry_run: false,
2408                };
2409
2410                let result =
2411                    execute_with_registry(args, Box::new(mock_registry_builder("unused", vec![])))
2412                        .await;
2413                assert!(result.is_ok());
2414            },
2415        )
2416        .await;
2417    }
2418
2419    #[tokio::test]
2420    async fn execute_with_registry_rhai_script_filter_excludes_all() {
2421        crate::config::with_isolated_config_path_async(
2422            "test-rs-rhai-script-filter-excludes-all",
2423            |_fake_dir| async move {
2424                let dir = tempfile::tempdir().unwrap();
2425                let project = dir.path();
2426                let manifest = r#"
2427[agent]
2428name = "test-agent"
2429version = "0.1.0"
2430description = "test"
2431
2432[stages.main]
2433model = { provider = "anthropic", model = "claude-sonnet-4-6" }
2434"#;
2435                write_test_agent(project, manifest);
2436                let tests_dir = project.join("tests");
2437                std::fs::create_dir_all(&tests_dir).unwrap();
2438                std::fs::write(tests_dir.join("script.rhai"), "false").unwrap();
2439
2440                let args = TestArgs {
2441                    path: Some(project.to_str().unwrap().to_string()),
2442                    filter: Some("no-such-script".to_string()),
2443                    dry_run: false,
2444                };
2445
2446                // Filter excludes the only script - 0 total, reports "no test files
2447                // found" and succeeds (rather than failing on the script's `false`).
2448                let result =
2449                    execute_with_registry(args, Box::new(mock_registry_builder("unused", vec![])))
2450                        .await;
2451                assert!(result.is_ok());
2452            },
2453        )
2454        .await;
2455    }
2456
2457    // ─── build_registry_from_config ──────────────────────────────────────────
2458    //
2459    // The production registry builder passed to `execute_with_registry` by
2460    // `execute()`. `Provider::new`/`with_base_url` constructors just store
2461    // config - they don't make network calls - so this is safe to exercise
2462    // directly with fake keys, registering every provider branch.
2463
2464    #[test]
2465    fn build_registry_from_config_registers_all_providers() {
2466        let config = Config {
2467            default_provider: "anthropic".to_string(),
2468            providers: crate::config::ProviderConfig {
2469                anthropic_api_key: Some("fake-anthropic-key".to_string()),
2470                openai_api_key: Some("fake-openai-key".to_string()),
2471                google_api_key: Some("fake-google-key".to_string()),
2472                claude_code_enabled: false,
2473                claude_code_binary: None,
2474                claude_code_effort: None,
2475            },
2476            openrouter_api_key: Some("fake-openrouter-key".to_string()),
2477            ollama_base_url: Some("http://localhost:12345".to_string()),
2478            ..Config::default()
2479        };
2480
2481        let registry = build_registry_from_config(&config);
2482        assert!(registry.has("anthropic"));
2483        assert!(registry.has("openai"));
2484        assert!(registry.has("google"));
2485        assert!(registry.has("openrouter"));
2486        assert!(registry.has("ollama"));
2487    }
2488
2489    #[test]
2490    fn build_registry_from_config_no_keys_still_registers_ollama_with_default_url() {
2491        let config = Config::default();
2492        let registry = build_registry_from_config(&config);
2493        assert!(!registry.has("anthropic"));
2494        assert!(!registry.has("openai"));
2495        assert!(!registry.has("google"));
2496        assert!(!registry.has("openrouter"));
2497        // ollama has no key gate - always registered, with the default URL
2498        // when `ollama_base_url` is unset.
2499        assert!(registry.has("ollama"));
2500    }
2501
2502    /// Covers the implicit `else` branch in the `if .toml / else if .rhai`
2503    /// check: a file in tests/ whose extension is neither is silently skipped.
2504    #[tokio::test]
2505    async fn execute_with_registry_ignores_non_test_files_in_tests_dir() {
2506        let dir = tempfile::tempdir().unwrap();
2507        let project = dir.path();
2508        let manifest = r#"
2509[agent]
2510name = "test-agent"
2511version = "0.1.0"
2512description = "test"
2513
2514[stages.main]
2515model = { provider = "anthropic", model = "claude-sonnet-4-6" }
2516"#;
2517        write_test_agent(project, manifest);
2518        let tests_dir = project.join("tests");
2519        std::fs::create_dir_all(&tests_dir).unwrap();
2520        // A .txt file - neither .toml nor .rhai - exercises the implicit else
2521        // path that simply skips unrecognized files.
2522        std::fs::write(tests_dir.join("readme.txt"), "this file should be ignored").unwrap();
2523        let args = TestArgs {
2524            path: Some(project.to_str().unwrap().to_string()),
2525            filter: None,
2526            dry_run: true,
2527        };
2528        let result = execute_with_registry(args, Box::new(build_registry_from_config)).await;
2529        assert!(result.is_ok());
2530    }
2531
2532    // ─── ErrorProvider trivial trait methods ─────────────────────────────────
2533
2534    #[tokio::test]
2535    async fn error_provider_trivial_trait_methods() {
2536        let provider = ErrorProvider;
2537        assert_eq!(provider.count_tokens("hello", "any-model").await, 5);
2538        assert_eq!(provider.max_context_tokens("any-model"), 8192);
2539        assert_eq!(provider.name(), "error-provider");
2540        let caps = provider.capabilities("any-model");
2541        let _ = caps; // just verify it doesn't panic
2542    }
2543
2544    // ─── MockProvider trivial trait methods ──────────────────────────────────
2545
2546    #[tokio::test]
2547    async fn mock_provider_trivial_trait_methods() {
2548        let provider = MockProvider {
2549            content: "x".to_string(),
2550            tool_calls: vec![],
2551        };
2552        assert_eq!(provider.count_tokens("hello", "any-model").await, 5);
2553        assert_eq!(provider.max_context_tokens("any-model"), 8192);
2554        assert_eq!(provider.name(), "mock");
2555    }
2556}