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