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