1use leviath_core::Blueprint;
11use leviath_core::blueprint::{ModelConfig, ModelEntry};
12
13use super::ResolvedStage;
14use crate::providers::ProviderRegistry;
15use leviath_providers::Tool;
16
17#[derive(Debug, Clone, Default, PartialEq, Eq)]
22pub struct ModelDefaults {
23 pub provider: String,
25 pub model: Option<String>,
27 pub fallback_order: Vec<ModelEntry>,
35}
36
37pub fn resolve_stage_model(
44 model_cfg: &ModelConfig,
45 model_override: Option<&str>,
46 defaults: &ModelDefaults,
47 registry: &ProviderRegistry,
48) -> (String, String) {
49 let first = resolve_stage_candidates(model_cfg, model_override, defaults, registry)
50 .into_iter()
51 .next()
52 .expect("resolve_stage_candidates always yields at least one entry");
53 (first.provider, first.model)
54}
55
56pub fn resolve_stage_candidates(
70 model_cfg: &ModelConfig,
71 model_override: Option<&str>,
72 defaults: &ModelDefaults,
73 registry: &ProviderRegistry,
74) -> Vec<ModelEntry> {
75 let (override_provider, override_model) = match model_override {
76 Some(ov) if ov.contains('/') => {
77 let (p, m) = ov
78 .split_once('/')
79 .expect("the `contains('/')` guard splits");
80 (Some(p.to_string()), Some(m.to_string()))
81 }
82 Some(ov) => (None, Some(ov.to_string())),
83 None => (None, None),
84 };
85
86 if let Some(provider) = override_provider {
89 return vec![ModelEntry::new(
90 provider,
91 override_model.unwrap_or_default(),
92 )];
93 }
94
95 let mut candidates: Vec<ModelEntry> = Vec::new();
96 let mut push = |provider: String, model: String| {
97 let entry = ModelEntry::new(provider, model);
98 if !candidates
99 .iter()
100 .any(|c| c.provider == entry.provider && c.model == entry.model)
101 {
102 candidates.push(entry);
103 }
104 };
105
106 for entry in &model_cfg.models {
109 if registry.has(&entry.provider) {
110 let model = override_model
111 .clone()
112 .unwrap_or_else(|| entry.model.clone());
113 push(entry.provider.clone(), model);
114 }
115 }
116
117 if let Some((provider, model)) =
118 user_default_model(model_cfg, override_model.as_deref(), defaults, registry)
119 {
120 push(provider, model);
121 }
122
123 for entry in &defaults.fallback_order {
126 if registry.has(&entry.provider) {
127 push(entry.provider.clone(), entry.model.clone());
128 }
129 }
130
131 if model_cfg.allow_user_default && registry.has(&defaults.provider) {
143 let (preferred, rest): (Vec<ModelEntry>, Vec<ModelEntry>) = candidates
144 .into_iter()
145 .partition(|c| c.provider == defaults.provider);
146 candidates = preferred.into_iter().chain(rest).collect();
147 }
148
149 if candidates.is_empty() {
150 candidates.push(ModelEntry::new(
154 model_cfg.provider().to_string(),
155 model_cfg.model().to_string(),
156 ));
157 }
158
159 let tail: Vec<ModelEntry> = candidates
167 .split_off(1)
168 .into_iter()
169 .filter(|e| registry.has(&e.provider))
170 .collect();
171 candidates.extend(tail);
172 candidates
173}
174
175fn user_default_model(
178 model_cfg: &ModelConfig,
179 override_model: Option<&str>,
180 defaults: &ModelDefaults,
181 registry: &ProviderRegistry,
182) -> Option<(String, String)> {
183 if !model_cfg.allow_user_default {
184 return None;
185 }
186 if let Some(model) = override_model {
187 return Some((defaults.provider.clone(), model.to_string()));
188 }
189 if let Some(default_model) = &defaults.model
190 && registry.has(&defaults.provider)
191 {
192 return Some((defaults.provider.clone(), default_model.clone()));
193 }
194 None
195}
196
197pub fn filter_tools_by_available(all: &[Tool], available: &[String]) -> Vec<Tool> {
201 if available.is_empty() {
202 return Vec::new();
203 }
204 all.iter()
205 .filter(|t| {
206 available
207 .iter()
208 .any(|n| leviath_tools::canonical_tool_name(n) == t.name)
209 })
210 .cloned()
211 .collect()
212}
213
214pub fn filter_tools_for_stage(
227 all: &[Tool],
228 available: &[String],
229 required: &[String],
230 unattended: bool,
231) -> Vec<Tool> {
232 let mut tools = filter_tools_by_available(all, available);
233 if unattended {
234 tools.retain(|t| {
235 !crate::dynamic_interaction::BLOCKING_INTERACTION_TOOLS.contains(&t.name.as_str())
236 || required
237 .iter()
238 .any(|n| leviath_tools::canonical_tool_name(n) == t.name)
239 });
240 }
241 tools
242}
243
244pub fn providers_tried(
255 model_cfg: &ModelConfig,
256 model_override: Option<&str>,
257 defaults: &ModelDefaults,
258) -> String {
259 let mut names: Vec<String> = match model_override {
260 Some(ov) if ov.contains('/') => vec![
261 ov.split_once('/')
262 .map(|(p, _)| p.to_string())
263 .expect("the `contains('/')` guard guarantees a split"),
264 ],
265 _ => {
266 let mut listed: Vec<String> = model_cfg
267 .models
268 .iter()
269 .map(|e| e.provider.clone())
270 .collect();
271 if model_cfg.allow_user_default && !defaults.provider.is_empty() {
272 listed.push(defaults.provider.clone());
273 }
274 listed
275 }
276 };
277 names.dedup();
278 names.join(", ")
279}
280
281pub fn resolve_stages(
296 blueprint: &Blueprint,
297 model_override: Option<&str>,
298 defaults: &ModelDefaults,
299 registry: &ProviderRegistry,
300 all_tool_defs: &[Tool],
301 unattended: bool,
302) -> Result<Vec<ResolvedStage>, String> {
303 blueprint
304 .stages
305 .iter()
306 .map(|stage| {
307 let mut candidates =
308 resolve_stage_candidates(&stage.model, model_override, defaults, registry);
309 let head = candidates.remove(0);
310 if !registry.has(&head.provider) {
314 return Err(format!(
315 "stage '{}' has no usable provider (tried: {}). Configure one \
316 with `lev setup`, or add it to config.toml and restart the daemon.",
317 stage.name,
318 providers_tried(&stage.model, model_override, defaults)
319 ));
320 }
321 let tools = filter_tools_for_stage(
326 all_tool_defs,
327 &stage.available_tools,
328 &stage.required_tools,
329 unattended,
330 );
331 Ok(ResolvedStage {
332 provider_name: head.provider,
333 model: head.model,
334 tools,
335 fallbacks: candidates,
336 })
337 })
338 .collect()
339}
340
341#[cfg(test)]
342mod tests {
343 use super::*;
344 use leviath_core::blueprint::ModelEntry;
345 use std::collections::HashMap;
346 use std::sync::Arc;
347
348 fn model_cfg(models: Vec<(&str, &str)>) -> ModelConfig {
349 ModelConfig {
350 models: models
351 .into_iter()
352 .map(|(p, m)| ModelEntry {
353 provider: p.to_string(),
354 model: m.to_string(),
355 })
356 .collect(),
357 allow_user_default: true,
358 parameters: HashMap::new(),
359 request_timeout_secs: None,
360 }
361 }
362
363 fn registry_with(providers: &[&str]) -> ProviderRegistry {
364 let mut r = ProviderRegistry::new();
365 for p in providers {
366 r.register(p.to_string(), Arc::new(FakeProvider));
367 }
368 r
369 }
370
371 struct FakeProvider;
372 #[async_trait::async_trait]
373 impl leviath_providers::Provider for FakeProvider {
374 async fn infer(
375 &self,
376 _r: leviath_providers::InferenceRequest,
377 ) -> leviath_providers::Result<leviath_providers::InferenceResponse> {
378 Err(leviath_providers::ProviderError::Other(
379 "test provider".to_string(),
380 ))
381 }
382 async fn count_tokens(&self, _t: &str, _m: &str) -> usize {
383 1
384 }
385 fn max_context_tokens(&self, _m: &str) -> usize {
386 1000
387 }
388 fn name(&self) -> &str {
389 "fake"
390 }
391 fn capabilities(&self, _m: &str) -> leviath_providers::ModelCapabilities {
392 leviath_providers::ModelCapabilities::default()
393 }
394 }
395
396 #[tokio::test]
397 async fn fake_provider_is_a_minimal_registry_stub() {
398 use leviath_providers::Provider as _;
401 let p = FakeProvider;
402 let request: leviath_providers::InferenceRequest =
403 serde_json::from_value(serde_json::json!({
404 "messages": [],
405 "model": "m",
406 "max_tokens": 1,
407 "temperature": 0.0,
408 "tools": [],
409 "extra": null,
410 }))
411 .unwrap();
412 assert!(p.infer(request).await.is_err());
413 assert_eq!(p.count_tokens("x", "m").await, 1);
414 assert_eq!(p.max_context_tokens("m"), 1000);
415 assert_eq!(p.name(), "fake");
416 let _ = p.capabilities("m");
417 }
418
419 #[test]
420 fn resolve_full_override_wins() {
421 let (p, m) = resolve_stage_model(
422 &model_cfg(vec![("anthropic", "x")]),
423 Some("openai/gpt-5"),
424 &ModelDefaults::default(),
425 ®istry_with(&[]),
426 );
427 assert_eq!((p.as_str(), m.as_str()), ("openai", "gpt-5"));
428 }
429
430 #[test]
431 fn resolve_first_available_model() {
432 let (p, m) = resolve_stage_model(
434 &model_cfg(vec![("anthropic", "a"), ("openai", "o")]),
435 None,
436 &ModelDefaults::default(),
437 ®istry_with(&["openai"]),
438 );
439 assert_eq!((p.as_str(), m.as_str()), ("openai", "o"));
440 }
441
442 #[test]
443 fn resolve_model_only_override_keeps_available_provider() {
444 let (p, m) = resolve_stage_model(
445 &model_cfg(vec![("openai", "o")]),
446 Some("gpt-override"),
447 &ModelDefaults::default(),
448 ®istry_with(&["openai"]),
449 );
450 assert_eq!((p.as_str(), m.as_str()), ("openai", "gpt-override"));
451 }
452
453 #[test]
454 fn resolve_user_default_when_nothing_listed_available() {
455 let defaults = ModelDefaults {
457 provider: "anthropic".to_string(),
458 model: Some("claude-default".to_string()),
459 fallback_order: Vec::new(),
460 };
461 let (p, m) = resolve_stage_model(
462 &model_cfg(vec![("ghost", "g")]),
463 None,
464 &defaults,
465 ®istry_with(&["anthropic"]),
466 );
467 assert_eq!((p.as_str(), m.as_str()), ("anthropic", "claude-default"));
468 }
469
470 #[test]
471 fn resolve_user_default_with_model_override() {
472 let defaults = ModelDefaults {
473 provider: "anthropic".to_string(),
474 model: None,
475 fallback_order: Vec::new(),
476 };
477 let (p, m) = resolve_stage_model(
478 &model_cfg(vec![("ghost", "g")]),
479 Some("just-a-model"),
480 &defaults,
481 ®istry_with(&[]),
482 );
483 assert_eq!((p.as_str(), m.as_str()), ("anthropic", "just-a-model"));
484 }
485
486 #[test]
487 fn resolve_user_default_provider_unavailable_falls_through() {
488 let defaults = ModelDefaults {
491 provider: "ghost-default".to_string(),
492 model: Some("dm".to_string()),
493 fallback_order: Vec::new(),
494 };
495 let (p, m) = resolve_stage_model(
496 &model_cfg(vec![("ghost", "g")]),
497 None,
498 &defaults,
499 ®istry_with(&[]),
500 );
501 assert_eq!((p.as_str(), m.as_str()), ("ghost", "g"));
502 }
503
504 #[test]
505 fn resolve_last_resort_first_listed() {
506 let (p, m) = resolve_stage_model(
508 &model_cfg(vec![("ghost", "g")]),
509 None,
510 &ModelDefaults::default(),
511 ®istry_with(&[]),
512 );
513 assert_eq!((p.as_str(), m.as_str()), ("ghost", "g"));
514 }
515
516 #[test]
517 fn resolve_no_user_default_uses_last_resort() {
518 let mut cfg = model_cfg(vec![("ghost", "g")]);
519 cfg.allow_user_default = false; let defaults = ModelDefaults {
521 provider: "anthropic".to_string(),
522 model: Some("would-be-default".to_string()),
523 fallback_order: Vec::new(),
524 };
525 let (p, m) = resolve_stage_model(&cfg, None, &defaults, ®istry_with(&["anthropic"]));
526 assert_eq!((p.as_str(), m.as_str()), ("ghost", "g"));
527 }
528
529 #[test]
530 fn resolve_stages_empty_available_tools_gets_none() {
531 let mut stage =
532 leviath_core::Stage::new("s".to_string(), model_cfg(vec![("anthropic", "m")]));
533 stage.available_tools = vec![]; let layout = leviath_core::layout::ContextLayout::new(vec![], 1000);
535 let bp = Blueprint::new("t".to_string(), "d".to_string(), vec![stage], layout);
536 let tools = vec![Tool {
537 name: "read_file".to_string(),
538 description: String::new(),
539 parameters: serde_json::Value::Null,
540 }];
541 let resolved = resolve_stages(
542 &bp,
543 None,
544 &ModelDefaults::default(),
545 ®istry_with(&["anthropic"]),
546 &tools,
547 false,
548 )
549 .expect("anthropic is registered");
550 assert!(resolved[0].tools.is_empty());
551 }
552
553 #[test]
554 fn resolve_stages_refuses_a_stage_with_no_usable_provider() {
555 let stage = leviath_core::Stage::new("plan".to_string(), model_cfg(vec![("ghost", "m")]));
559 let layout = leviath_core::layout::ContextLayout::new(vec![], 1000);
560 let bp = Blueprint::new("t".to_string(), "d".to_string(), vec![stage], layout);
561
562 let err = resolve_stages(
563 &bp,
564 None,
565 &ModelDefaults::default(),
566 ®istry_with(&[]),
567 &[],
568 false,
569 )
570 .expect_err("no provider is configured");
571
572 assert!(err.contains("plan"), "names the stage: {err}");
573 assert!(err.contains("ghost"), "names what it tried: {err}");
574 assert!(err.contains("lev setup"), "says what to do: {err}");
575 }
576
577 #[test]
578 fn resolve_stages_refuses_an_override_naming_an_unregistered_provider() {
579 let stage =
582 leviath_core::Stage::new("plan".to_string(), model_cfg(vec![("anthropic", "m")]));
583 let layout = leviath_core::layout::ContextLayout::new(vec![], 1000);
584 let bp = Blueprint::new("t".to_string(), "d".to_string(), vec![stage], layout);
585
586 let err = resolve_stages(
587 &bp,
588 Some("ghost/x"),
589 &ModelDefaults::default(),
590 ®istry_with(&["anthropic"]),
591 &[],
592 false,
593 )
594 .expect_err("the override names a provider that isn't registered");
595
596 assert!(err.contains("tried: ghost"), "got: {err}");
597 assert!(
598 !err.contains("anthropic"),
599 "the override skipped the blueprint's list entirely: {err}"
600 );
601 }
602
603 #[test]
604 fn providers_tried_lists_the_blueprint_entries_and_the_user_default() {
605 let defaults = ModelDefaults {
606 provider: "fallback".to_string(),
607 model: None,
608 fallback_order: Vec::new(),
609 };
610 let cfg = model_cfg(vec![("one", "m"), ("two", "m")]);
611 assert_eq!(providers_tried(&cfg, None, &defaults), "one, two, fallback");
612
613 let mut no_default = cfg.clone();
615 no_default.allow_user_default = false;
616 assert_eq!(providers_tried(&no_default, None, &defaults), "one, two");
617
618 assert_eq!(
620 providers_tried(&cfg, None, &ModelDefaults::default()),
621 "one, two"
622 );
623
624 assert_eq!(
626 providers_tried(&cfg, Some("m"), &defaults),
627 "one, two, fallback"
628 );
629 }
630
631 #[test]
632 fn resolve_stages_matches_by_alias_and_skips_unknown_names() {
633 let mut stage =
637 leviath_core::Stage::new("s".to_string(), model_cfg(vec![("anthropic", "m")]));
638 stage.available_tools = vec!["bash".to_string(), "acme__uninstalled".to_string()];
639 let layout = leviath_core::layout::ContextLayout::new(vec![], 1000);
640 let bp = Blueprint::new("t".to_string(), "d".to_string(), vec![stage], layout);
641 let tools = vec![
642 Tool {
643 name: "shell".to_string(),
644 description: String::new(),
645 parameters: serde_json::Value::Null,
646 },
647 Tool {
648 name: "read_file".to_string(),
649 description: String::new(),
650 parameters: serde_json::Value::Null,
651 },
652 ];
653 let resolved = resolve_stages(
654 &bp,
655 None,
656 &ModelDefaults::default(),
657 ®istry_with(&["anthropic"]),
658 &tools,
659 false,
660 )
661 .expect("anthropic is registered");
662 let selected: Vec<&str> = resolved[0].tools.iter().map(|t| t.name.as_str()).collect();
663 assert_eq!(selected, vec!["shell"]);
666 }
667
668 fn pairs(entries: &[ModelEntry]) -> Vec<(&str, &str)> {
672 entries
673 .iter()
674 .map(|e| (e.provider.as_str(), e.model.as_str()))
675 .collect()
676 }
677
678 #[test]
679 fn candidates_keep_every_registered_entry_in_blueprint_order() {
680 let cfg = model_cfg(vec![
681 ("openrouter", "deepseek"),
682 ("anthropic", "sonnet"),
683 ("openai", "gpt"),
684 ]);
685 let registry = registry_with(&["openrouter", "anthropic", "openai"]);
686 let got = resolve_stage_candidates(&cfg, None, &ModelDefaults::default(), ®istry);
687 assert_eq!(
690 pairs(&got),
691 vec![
692 ("openrouter", "deepseek"),
693 ("anthropic", "sonnet"),
694 ("openai", "gpt"),
695 ]
696 );
697 }
698
699 fn ask_and_read_defs() -> Vec<Tool> {
703 ["read_file", "ask_user_text", "ask_user_choice"]
704 .iter()
705 .map(|n| Tool {
706 name: n.to_string(),
707 description: String::new(),
708 parameters: serde_json::Value::Null,
709 })
710 .collect()
711 }
712
713 fn names(tools: &[Tool]) -> Vec<&str> {
714 tools.iter().map(|t| t.name.as_str()).collect()
715 }
716
717 #[test]
718 fn an_attended_run_keeps_every_tool_the_stage_lists() {
719 let available = vec![
720 "read_file".to_string(),
721 "ask_user_text".to_string(),
722 "ask_user_choice".to_string(),
723 ];
724 let tools = filter_tools_for_stage(&ask_and_read_defs(), &available, &[], false);
725 assert_eq!(
726 names(&tools),
727 vec!["read_file", "ask_user_text", "ask_user_choice"]
728 );
729 }
730
731 #[test]
732 fn candidates_skip_providers_that_are_not_registered() {
733 let cfg = model_cfg(vec![("ghost", "nope"), ("anthropic", "sonnet")]);
734 let registry = registry_with(&["anthropic"]);
735 let got = resolve_stage_candidates(&cfg, None, &ModelDefaults::default(), ®istry);
736 assert_eq!(pairs(&got), vec![("anthropic", "sonnet")]);
737 }
738
739 #[test]
740 fn the_global_chain_rescues_a_single_model_stage() {
741 let cfg = ModelConfig {
744 allow_user_default: false,
745 ..model_cfg(vec![("openrouter", "deepseek")])
746 };
747 let defaults = ModelDefaults {
748 fallback_order: vec![
749 ModelEntry::new("anthropic".to_string(), "sonnet".to_string()),
750 ModelEntry::new("ghost".to_string(), "nope".to_string()),
751 ],
752 ..Default::default()
753 };
754 let registry = registry_with(&["openrouter", "anthropic"]);
755 let got = resolve_stage_candidates(&cfg, None, &defaults, ®istry);
756 assert_eq!(
757 pairs(&got),
758 vec![("openrouter", "deepseek"), ("anthropic", "sonnet")],
759 "the unregistered global entry is skipped"
760 );
761 }
762
763 #[test]
764 fn the_global_chain_comes_after_the_user_default() {
765 let cfg = model_cfg(vec![("openrouter", "deepseek")]);
766 let defaults = ModelDefaults {
767 provider: "anthropic".to_string(),
768 model: Some("sonnet".to_string()),
769 fallback_order: vec![ModelEntry::new("openai".to_string(), "gpt".to_string())],
770 };
771 let registry = registry_with(&["openrouter", "anthropic", "openai"]);
772 let got = resolve_stage_candidates(&cfg, None, &defaults, ®istry);
773 assert_eq!(
777 pairs(&got),
778 vec![
779 ("anthropic", "sonnet"),
780 ("openrouter", "deepseek"),
781 ("openai", "gpt"),
782 ]
783 );
784 }
785
786 #[test]
787 fn the_default_provider_outranks_the_stages_own_list() {
788 let cfg = model_cfg(vec![
793 ("anthropic", "claude-sonnet-5"),
794 ("ollama", "qwen3.5:9b"),
795 ]);
796 let defaults = ModelDefaults {
797 provider: "openrouter".to_string(),
798 model: Some("openai/gpt-4o-mini".to_string()),
799 ..Default::default()
800 };
801 let registry = registry_with(&["openrouter", "ollama"]);
802 let got = resolve_stage_candidates(&cfg, None, &defaults, ®istry);
803 assert_eq!(
804 pairs(&got),
805 vec![
806 ("openrouter", "openai/gpt-4o-mini"),
807 ("ollama", "qwen3.5:9b"),
808 ],
809 "the user's default provider heads the list; the registered stage \
810 entry stays behind it as a fallback"
811 );
812 }
813
814 #[test]
815 fn a_stage_that_forbids_the_user_default_keeps_its_own_order() {
816 let cfg = ModelConfig {
820 allow_user_default: false,
821 ..model_cfg(vec![("anthropic", "sonnet"), ("openrouter", "deepseek")])
822 };
823 let defaults = ModelDefaults {
824 provider: "openrouter".to_string(),
825 model: Some("deepseek".to_string()),
826 ..Default::default()
827 };
828 let registry = registry_with(&["openrouter", "anthropic"]);
829 let got = resolve_stage_candidates(&cfg, None, &defaults, ®istry);
830 assert_eq!(
831 pairs(&got),
832 vec![("anthropic", "sonnet"), ("openrouter", "deepseek")]
833 );
834 }
835
836 #[test]
837 fn an_unregistered_default_provider_changes_nothing() {
838 let cfg = model_cfg(vec![("anthropic", "sonnet"), ("ollama", "qwen")]);
843 let defaults = ModelDefaults {
844 provider: "openrouter".to_string(),
845 model: Some("deepseek".to_string()),
846 ..Default::default()
847 };
848 let registry = registry_with(&["anthropic", "ollama"]);
849 let got = resolve_stage_candidates(&cfg, None, &defaults, ®istry);
850 assert_eq!(
851 pairs(&got),
852 vec![("anthropic", "sonnet"), ("ollama", "qwen")]
853 );
854 }
855
856 #[test]
857 fn candidates_are_deduplicated() {
858 let cfg = model_cfg(vec![("anthropic", "sonnet"), ("anthropic", "sonnet")]);
861 let defaults = ModelDefaults {
862 provider: "anthropic".to_string(),
863 model: Some("sonnet".to_string()),
864 fallback_order: vec![ModelEntry::new(
865 "anthropic".to_string(),
866 "sonnet".to_string(),
867 )],
868 };
869 let registry = registry_with(&["anthropic"]);
870 let got = resolve_stage_candidates(&cfg, None, &defaults, ®istry);
871 assert_eq!(pairs(&got), vec![("anthropic", "sonnet")]);
872 }
873
874 #[test]
875 fn a_full_override_names_exactly_one_candidate() {
876 let cfg = model_cfg(vec![("anthropic", "sonnet"), ("openai", "gpt")]);
878 let defaults = ModelDefaults {
879 fallback_order: vec![ModelEntry::new("openai".to_string(), "gpt".to_string())],
880 ..Default::default()
881 };
882 let registry = registry_with(&["anthropic", "openai", "ollama"]);
883 let got = resolve_stage_candidates(&cfg, Some("ollama/llama"), &defaults, ®istry);
884 assert_eq!(pairs(&got), vec![("ollama", "llama")]);
885 }
886
887 #[test]
888 fn a_bare_override_renames_the_model_on_every_candidate() {
889 let cfg = model_cfg(vec![("anthropic", "sonnet"), ("openai", "gpt")]);
890 let registry = registry_with(&["anthropic", "openai"]);
891 let got =
892 resolve_stage_candidates(&cfg, Some("haiku"), &ModelDefaults::default(), ®istry);
893 assert_eq!(
894 pairs(&got),
895 vec![("anthropic", "haiku"), ("openai", "haiku")]
896 );
897 }
898
899 #[test]
900 fn candidates_are_never_empty_even_with_nothing_registered() {
901 let cfg = ModelConfig {
903 allow_user_default: false,
904 ..model_cfg(vec![("ghost", "nope")])
905 };
906 let got =
907 resolve_stage_candidates(&cfg, None, &ModelDefaults::default(), ®istry_with(&[]));
908 assert_eq!(pairs(&got), vec![("ghost", "nope")]);
909 }
910
911 #[test]
912 fn resolve_stages_carries_the_tail_onto_the_resolved_stage() {
913 let mut stage = leviath_core::Stage::new(
914 "work".to_string(),
915 model_cfg(vec![("openrouter", "deepseek"), ("anthropic", "sonnet")]),
916 );
917 stage.available_tools = vec![];
918 let layout = leviath_core::layout::ContextLayout::new(vec![], 1000);
919 let bp = Blueprint::new("t".to_string(), "d".to_string(), vec![stage], layout);
920 let registry = registry_with(&["openrouter", "anthropic"]);
921 let resolved = resolve_stages(&bp, None, &ModelDefaults::default(), ®istry, &[], false)
922 .expect("both providers are registered");
923 assert_eq!(resolved[0].provider_name, "openrouter");
924 assert_eq!(pairs(&resolved[0].fallbacks), vec![("anthropic", "sonnet")]);
925 }
926
927 #[test]
928 fn an_unattended_run_loses_the_tools_that_wait_on_a_person() {
929 let available = vec![
932 "read_file".to_string(),
933 "ask_user_text".to_string(),
934 "ask_user_choice".to_string(),
935 ];
936 let tools = filter_tools_for_stage(&ask_and_read_defs(), &available, &[], true);
937 assert_eq!(names(&tools), vec!["read_file"]);
938 }
939
940 #[test]
941 fn required_tools_survive_an_unattended_run() {
942 let available = vec![
945 "read_file".to_string(),
946 "ask_user_text".to_string(),
947 "ask_user_choice".to_string(),
948 ];
949 let required = vec!["ask_user_text".to_string()];
950 let tools = filter_tools_for_stage(&ask_and_read_defs(), &available, &required, true);
951 assert_eq!(names(&tools), vec!["read_file", "ask_user_text"]);
952 }
953
954 #[test]
955 fn a_required_tool_the_stage_never_offered_adds_nothing() {
956 let available = vec!["read_file".to_string()];
960 let required = vec!["ask_user_text".to_string()];
961 let tools = filter_tools_for_stage(&ask_and_read_defs(), &available, &required, true);
962 assert_eq!(names(&tools), vec!["read_file"]);
963 }
964
965 #[test]
966 fn resolve_stages_applies_the_unattended_cut_per_stage() {
967 let mut plan =
970 leviath_core::Stage::new("plan".to_string(), model_cfg(vec![("anthropic", "m")]));
971 plan.available_tools = vec!["read_file".to_string(), "ask_user_text".to_string()];
972 plan.required_tools = vec!["ask_user_text".to_string()];
973 let mut build =
974 leviath_core::Stage::new("build".to_string(), model_cfg(vec![("anthropic", "m")]));
975 build.available_tools = vec!["read_file".to_string(), "ask_user_text".to_string()];
976 let layout = leviath_core::layout::ContextLayout::new(vec![], 1000);
977 let bp = Blueprint::new("t".to_string(), "d".to_string(), vec![plan, build], layout);
978
979 let resolved = resolve_stages(
980 &bp,
981 None,
982 &ModelDefaults::default(),
983 ®istry_with(&["anthropic"]),
984 &ask_and_read_defs(),
985 true,
986 )
987 .expect("anthropic is registered");
988
989 assert_eq!(
990 names(&resolved[0].tools),
991 vec!["read_file", "ask_user_text"]
992 );
993 assert_eq!(names(&resolved[1].tools), vec!["read_file"]);
994 }
995
996 #[test]
997 fn the_unattended_cut_resolves_aliases_on_both_sides() {
998 let defs = vec![Tool {
1004 name: "shell".to_string(),
1005 description: String::new(),
1006 parameters: serde_json::Value::Null,
1007 }];
1008 let available = vec!["bash".to_string()];
1009 let tools = filter_tools_for_stage(&defs, &available, &[], true);
1010 assert_eq!(names(&tools), vec!["shell"]);
1011 }
1012}