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
244fn apply_output_shape(tools: &mut [Tool], spec: Option<&leviath_core::output::OutputSpec>) {
255 let Some(spec) = spec else { return };
256 let described = leviath_core::describe_spec(spec);
257 if described.is_empty() {
258 return;
259 }
260 for tool in tools
261 .iter_mut()
262 .filter(|t| t.name == leviath_tools::SUBMIT_OUTPUT_TOOL)
263 {
264 tool.description = leviath_tools::submit_output_description(&described);
265 }
266}
267
268pub fn providers_tried(
279 model_cfg: &ModelConfig,
280 model_override: Option<&str>,
281 defaults: &ModelDefaults,
282) -> String {
283 let mut names: Vec<String> = match model_override {
284 Some(ov) if ov.contains('/') => vec![
285 ov.split_once('/')
286 .map(|(p, _)| p.to_string())
287 .expect("the `contains('/')` guard guarantees a split"),
288 ],
289 _ => {
290 let mut listed: Vec<String> = model_cfg
291 .models
292 .iter()
293 .map(|e| e.provider.clone())
294 .collect();
295 if model_cfg.allow_user_default && !defaults.provider.is_empty() {
296 listed.push(defaults.provider.clone());
297 }
298 listed
299 }
300 };
301 names.dedup();
302 names.join(", ")
303}
304
305pub fn resolve_stages(
325 blueprint: &Blueprint,
326 model_override: Option<&str>,
327 defaults: &ModelDefaults,
328 registry: &ProviderRegistry,
329 all_tool_defs: &[Tool],
330 unattended: bool,
331 output_request: Option<&leviath_core::output::OutputSpec>,
332) -> Result<Vec<ResolvedStage>, String> {
333 blueprint
334 .stages
335 .iter()
336 .map(|stage| {
337 let mut candidates =
338 resolve_stage_candidates(&stage.model, model_override, defaults, registry);
339 let head = candidates.remove(0);
340 if !registry.has(&head.provider) {
344 return Err(format!(
345 "stage '{}' has no usable provider (tried: {}). Configure one \
346 with `lev setup`, or add it to config.toml and restart the daemon.",
347 stage.name,
348 providers_tried(&stage.model, model_override, defaults)
349 ));
350 }
351 let mut tools = filter_tools_for_stage(
356 all_tool_defs,
357 &stage.available_tools,
358 &stage.required_tools,
359 unattended,
360 );
361 let output = leviath_core::resolve_output_spec(
362 blueprint.output.as_ref(),
363 stage.output.as_ref(),
364 output_request,
365 );
366 apply_output_shape(&mut tools, output.as_ref());
367 Ok(ResolvedStage {
368 provider_name: head.provider,
369 model: head.model,
370 tools,
371 fallbacks: candidates,
372 output,
373 })
374 })
375 .collect()
376}
377
378#[cfg(test)]
379mod tests {
380 use super::*;
381 use leviath_core::blueprint::ModelEntry;
382 use std::collections::HashMap;
383 use std::sync::Arc;
384
385 fn model_cfg(models: Vec<(&str, &str)>) -> ModelConfig {
386 ModelConfig {
387 models: models
388 .into_iter()
389 .map(|(p, m)| ModelEntry {
390 provider: p.to_string(),
391 model: m.to_string(),
392 })
393 .collect(),
394 allow_user_default: true,
395 parameters: HashMap::new(),
396 request_timeout_secs: None,
397 }
398 }
399
400 fn registry_with(providers: &[&str]) -> ProviderRegistry {
401 let mut r = ProviderRegistry::new();
402 for p in providers {
403 r.register(p.to_string(), Arc::new(FakeProvider));
404 }
405 r
406 }
407
408 struct FakeProvider;
409 #[async_trait::async_trait]
410 impl leviath_providers::Provider for FakeProvider {
411 async fn infer(
412 &self,
413 _r: &leviath_providers::InferenceRequest,
414 ) -> leviath_providers::Result<leviath_providers::InferenceResponse> {
415 Err(leviath_providers::ProviderError::Other(
416 "test provider".to_string(),
417 ))
418 }
419 async fn count_tokens(&self, _t: &str, _m: &str) -> usize {
420 1
421 }
422 fn max_context_tokens(&self, _m: &str) -> usize {
423 1000
424 }
425 fn name(&self) -> &str {
426 "fake"
427 }
428 fn capabilities(&self, _m: &str) -> leviath_providers::ModelCapabilities {
429 leviath_providers::ModelCapabilities::default()
430 }
431 }
432
433 #[tokio::test]
434 async fn fake_provider_is_a_minimal_registry_stub() {
435 use leviath_providers::Provider as _;
438 let p = FakeProvider;
439 let request: leviath_providers::InferenceRequest =
440 serde_json::from_value(serde_json::json!({
441 "messages": [],
442 "model": "m",
443 "max_tokens": 1,
444 "temperature": 0.0,
445 "tools": [],
446 "extra": null,
447 }))
448 .unwrap();
449 assert!(p.infer(&request).await.is_err());
450 assert_eq!(p.count_tokens("x", "m").await, 1);
451 assert_eq!(p.max_context_tokens("m"), 1000);
452 assert_eq!(p.name(), "fake");
453 let _ = p.capabilities("m");
454 }
455
456 #[test]
457 fn resolve_full_override_wins() {
458 let (p, m) = resolve_stage_model(
459 &model_cfg(vec![("anthropic", "x")]),
460 Some("openai/gpt-5"),
461 &ModelDefaults::default(),
462 ®istry_with(&[]),
463 );
464 assert_eq!((p.as_str(), m.as_str()), ("openai", "gpt-5"));
465 }
466
467 #[test]
468 fn resolve_first_available_model() {
469 let (p, m) = resolve_stage_model(
471 &model_cfg(vec![("anthropic", "a"), ("openai", "o")]),
472 None,
473 &ModelDefaults::default(),
474 ®istry_with(&["openai"]),
475 );
476 assert_eq!((p.as_str(), m.as_str()), ("openai", "o"));
477 }
478
479 #[test]
480 fn resolve_model_only_override_keeps_available_provider() {
481 let (p, m) = resolve_stage_model(
482 &model_cfg(vec![("openai", "o")]),
483 Some("gpt-override"),
484 &ModelDefaults::default(),
485 ®istry_with(&["openai"]),
486 );
487 assert_eq!((p.as_str(), m.as_str()), ("openai", "gpt-override"));
488 }
489
490 #[test]
491 fn resolve_user_default_when_nothing_listed_available() {
492 let defaults = ModelDefaults {
494 provider: "anthropic".to_string(),
495 model: Some("claude-default".to_string()),
496 fallback_order: Vec::new(),
497 };
498 let (p, m) = resolve_stage_model(
499 &model_cfg(vec![("ghost", "g")]),
500 None,
501 &defaults,
502 ®istry_with(&["anthropic"]),
503 );
504 assert_eq!((p.as_str(), m.as_str()), ("anthropic", "claude-default"));
505 }
506
507 #[test]
508 fn resolve_user_default_with_model_override() {
509 let defaults = ModelDefaults {
510 provider: "anthropic".to_string(),
511 model: None,
512 fallback_order: Vec::new(),
513 };
514 let (p, m) = resolve_stage_model(
515 &model_cfg(vec![("ghost", "g")]),
516 Some("just-a-model"),
517 &defaults,
518 ®istry_with(&[]),
519 );
520 assert_eq!((p.as_str(), m.as_str()), ("anthropic", "just-a-model"));
521 }
522
523 #[test]
524 fn resolve_user_default_provider_unavailable_falls_through() {
525 let defaults = ModelDefaults {
528 provider: "ghost-default".to_string(),
529 model: Some("dm".to_string()),
530 fallback_order: Vec::new(),
531 };
532 let (p, m) = resolve_stage_model(
533 &model_cfg(vec![("ghost", "g")]),
534 None,
535 &defaults,
536 ®istry_with(&[]),
537 );
538 assert_eq!((p.as_str(), m.as_str()), ("ghost", "g"));
539 }
540
541 #[test]
542 fn resolve_last_resort_first_listed() {
543 let (p, m) = resolve_stage_model(
545 &model_cfg(vec![("ghost", "g")]),
546 None,
547 &ModelDefaults::default(),
548 ®istry_with(&[]),
549 );
550 assert_eq!((p.as_str(), m.as_str()), ("ghost", "g"));
551 }
552
553 #[test]
554 fn resolve_no_user_default_uses_last_resort() {
555 let mut cfg = model_cfg(vec![("ghost", "g")]);
556 cfg.allow_user_default = false; let defaults = ModelDefaults {
558 provider: "anthropic".to_string(),
559 model: Some("would-be-default".to_string()),
560 fallback_order: Vec::new(),
561 };
562 let (p, m) = resolve_stage_model(&cfg, None, &defaults, ®istry_with(&["anthropic"]));
563 assert_eq!((p.as_str(), m.as_str()), ("ghost", "g"));
564 }
565
566 #[test]
567 fn resolve_stages_empty_available_tools_gets_none() {
568 let mut stage =
569 leviath_core::Stage::new("s".to_string(), model_cfg(vec![("anthropic", "m")]));
570 stage.available_tools = vec![]; let layout = leviath_core::layout::ContextLayout::new(vec![], 1000);
572 let bp = Blueprint::new("t".to_string(), "d".to_string(), vec![stage], layout);
573 let tools = vec![Tool {
574 name: "read_file".to_string(),
575 description: String::new(),
576 parameters: serde_json::Value::Null,
577 }];
578 let resolved = resolve_stages(
579 &bp,
580 None,
581 &ModelDefaults::default(),
582 ®istry_with(&["anthropic"]),
583 &tools,
584 false,
585 None,
586 )
587 .expect("anthropic is registered");
588 assert!(resolved[0].tools.is_empty());
589 }
590
591 #[test]
592 fn resolve_stages_refuses_a_stage_with_no_usable_provider() {
593 let stage = leviath_core::Stage::new("plan".to_string(), model_cfg(vec![("ghost", "m")]));
597 let layout = leviath_core::layout::ContextLayout::new(vec![], 1000);
598 let bp = Blueprint::new("t".to_string(), "d".to_string(), vec![stage], layout);
599
600 let err = resolve_stages(
601 &bp,
602 None,
603 &ModelDefaults::default(),
604 ®istry_with(&[]),
605 &[],
606 false,
607 None,
608 )
609 .expect_err("no provider is configured");
610
611 assert!(err.contains("plan"), "names the stage: {err}");
612 assert!(err.contains("ghost"), "names what it tried: {err}");
613 assert!(err.contains("lev setup"), "says what to do: {err}");
614 }
615
616 #[test]
617 fn resolve_stages_refuses_an_override_naming_an_unregistered_provider() {
618 let stage =
621 leviath_core::Stage::new("plan".to_string(), model_cfg(vec![("anthropic", "m")]));
622 let layout = leviath_core::layout::ContextLayout::new(vec![], 1000);
623 let bp = Blueprint::new("t".to_string(), "d".to_string(), vec![stage], layout);
624
625 let err = resolve_stages(
626 &bp,
627 Some("ghost/x"),
628 &ModelDefaults::default(),
629 ®istry_with(&["anthropic"]),
630 &[],
631 false,
632 None,
633 )
634 .expect_err("the override names a provider that isn't registered");
635
636 assert!(err.contains("tried: ghost"), "got: {err}");
637 assert!(
638 !err.contains("anthropic"),
639 "the override skipped the blueprint's list entirely: {err}"
640 );
641 }
642
643 #[test]
644 fn providers_tried_lists_the_blueprint_entries_and_the_user_default() {
645 let defaults = ModelDefaults {
646 provider: "fallback".to_string(),
647 model: None,
648 fallback_order: Vec::new(),
649 };
650 let cfg = model_cfg(vec![("one", "m"), ("two", "m")]);
651 assert_eq!(providers_tried(&cfg, None, &defaults), "one, two, fallback");
652
653 let mut no_default = cfg.clone();
655 no_default.allow_user_default = false;
656 assert_eq!(providers_tried(&no_default, None, &defaults), "one, two");
657
658 assert_eq!(
660 providers_tried(&cfg, None, &ModelDefaults::default()),
661 "one, two"
662 );
663
664 assert_eq!(
666 providers_tried(&cfg, Some("m"), &defaults),
667 "one, two, fallback"
668 );
669 }
670
671 #[test]
672 fn resolve_stages_matches_by_alias_and_skips_unknown_names() {
673 let mut stage =
677 leviath_core::Stage::new("s".to_string(), model_cfg(vec![("anthropic", "m")]));
678 stage.available_tools = vec!["bash".to_string(), "acme__uninstalled".to_string()];
679 let layout = leviath_core::layout::ContextLayout::new(vec![], 1000);
680 let bp = Blueprint::new("t".to_string(), "d".to_string(), vec![stage], layout);
681 let tools = vec![
682 Tool {
683 name: "shell".to_string(),
684 description: String::new(),
685 parameters: serde_json::Value::Null,
686 },
687 Tool {
688 name: "read_file".to_string(),
689 description: String::new(),
690 parameters: serde_json::Value::Null,
691 },
692 ];
693 let resolved = resolve_stages(
694 &bp,
695 None,
696 &ModelDefaults::default(),
697 ®istry_with(&["anthropic"]),
698 &tools,
699 false,
700 None,
701 )
702 .expect("anthropic is registered");
703 let selected: Vec<&str> = resolved[0].tools.iter().map(|t| t.name.as_str()).collect();
704 assert_eq!(selected, vec!["shell"]);
707 }
708
709 fn pairs(entries: &[ModelEntry]) -> Vec<(&str, &str)> {
713 entries
714 .iter()
715 .map(|e| (e.provider.as_str(), e.model.as_str()))
716 .collect()
717 }
718
719 #[test]
720 fn candidates_keep_every_registered_entry_in_blueprint_order() {
721 let cfg = model_cfg(vec![
722 ("openrouter", "deepseek"),
723 ("anthropic", "sonnet"),
724 ("openai", "gpt"),
725 ]);
726 let registry = registry_with(&["openrouter", "anthropic", "openai"]);
727 let got = resolve_stage_candidates(&cfg, None, &ModelDefaults::default(), ®istry);
728 assert_eq!(
731 pairs(&got),
732 vec![
733 ("openrouter", "deepseek"),
734 ("anthropic", "sonnet"),
735 ("openai", "gpt"),
736 ]
737 );
738 }
739
740 fn ask_and_read_defs() -> Vec<Tool> {
744 ["read_file", "ask_user_text", "ask_user_choice"]
745 .iter()
746 .map(|n| Tool {
747 name: n.to_string(),
748 description: String::new(),
749 parameters: serde_json::Value::Null,
750 })
751 .collect()
752 }
753
754 fn names(tools: &[Tool]) -> Vec<&str> {
755 tools.iter().map(|t| t.name.as_str()).collect()
756 }
757
758 #[test]
759 fn an_attended_run_keeps_every_tool_the_stage_lists() {
760 let available = vec![
761 "read_file".to_string(),
762 "ask_user_text".to_string(),
763 "ask_user_choice".to_string(),
764 ];
765 let tools = filter_tools_for_stage(&ask_and_read_defs(), &available, &[], false);
766 assert_eq!(
767 names(&tools),
768 vec!["read_file", "ask_user_text", "ask_user_choice"]
769 );
770 }
771
772 #[test]
773 fn candidates_skip_providers_that_are_not_registered() {
774 let cfg = model_cfg(vec![("ghost", "nope"), ("anthropic", "sonnet")]);
775 let registry = registry_with(&["anthropic"]);
776 let got = resolve_stage_candidates(&cfg, None, &ModelDefaults::default(), ®istry);
777 assert_eq!(pairs(&got), vec![("anthropic", "sonnet")]);
778 }
779
780 #[test]
781 fn the_global_chain_rescues_a_single_model_stage() {
782 let cfg = ModelConfig {
785 allow_user_default: false,
786 ..model_cfg(vec![("openrouter", "deepseek")])
787 };
788 let defaults = ModelDefaults {
789 fallback_order: vec![
790 ModelEntry::new("anthropic".to_string(), "sonnet".to_string()),
791 ModelEntry::new("ghost".to_string(), "nope".to_string()),
792 ],
793 ..Default::default()
794 };
795 let registry = registry_with(&["openrouter", "anthropic"]);
796 let got = resolve_stage_candidates(&cfg, None, &defaults, ®istry);
797 assert_eq!(
798 pairs(&got),
799 vec![("openrouter", "deepseek"), ("anthropic", "sonnet")],
800 "the unregistered global entry is skipped"
801 );
802 }
803
804 #[test]
805 fn the_global_chain_comes_after_the_user_default() {
806 let cfg = model_cfg(vec![("openrouter", "deepseek")]);
807 let defaults = ModelDefaults {
808 provider: "anthropic".to_string(),
809 model: Some("sonnet".to_string()),
810 fallback_order: vec![ModelEntry::new("openai".to_string(), "gpt".to_string())],
811 };
812 let registry = registry_with(&["openrouter", "anthropic", "openai"]);
813 let got = resolve_stage_candidates(&cfg, None, &defaults, ®istry);
814 assert_eq!(
818 pairs(&got),
819 vec![
820 ("anthropic", "sonnet"),
821 ("openrouter", "deepseek"),
822 ("openai", "gpt"),
823 ]
824 );
825 }
826
827 #[test]
828 fn the_default_provider_outranks_the_stages_own_list() {
829 let cfg = model_cfg(vec![
834 ("anthropic", "claude-sonnet-5"),
835 ("ollama", "qwen3.5:9b"),
836 ]);
837 let defaults = ModelDefaults {
838 provider: "openrouter".to_string(),
839 model: Some("openai/gpt-4o-mini".to_string()),
840 ..Default::default()
841 };
842 let registry = registry_with(&["openrouter", "ollama"]);
843 let got = resolve_stage_candidates(&cfg, None, &defaults, ®istry);
844 assert_eq!(
845 pairs(&got),
846 vec![
847 ("openrouter", "openai/gpt-4o-mini"),
848 ("ollama", "qwen3.5:9b"),
849 ],
850 "the user's default provider heads the list; the registered stage \
851 entry stays behind it as a fallback"
852 );
853 }
854
855 #[test]
856 fn a_stage_that_forbids_the_user_default_keeps_its_own_order() {
857 let cfg = ModelConfig {
861 allow_user_default: false,
862 ..model_cfg(vec![("anthropic", "sonnet"), ("openrouter", "deepseek")])
863 };
864 let defaults = ModelDefaults {
865 provider: "openrouter".to_string(),
866 model: Some("deepseek".to_string()),
867 ..Default::default()
868 };
869 let registry = registry_with(&["openrouter", "anthropic"]);
870 let got = resolve_stage_candidates(&cfg, None, &defaults, ®istry);
871 assert_eq!(
872 pairs(&got),
873 vec![("anthropic", "sonnet"), ("openrouter", "deepseek")]
874 );
875 }
876
877 #[test]
878 fn an_unregistered_default_provider_changes_nothing() {
879 let cfg = model_cfg(vec![("anthropic", "sonnet"), ("ollama", "qwen")]);
884 let defaults = ModelDefaults {
885 provider: "openrouter".to_string(),
886 model: Some("deepseek".to_string()),
887 ..Default::default()
888 };
889 let registry = registry_with(&["anthropic", "ollama"]);
890 let got = resolve_stage_candidates(&cfg, None, &defaults, ®istry);
891 assert_eq!(
892 pairs(&got),
893 vec![("anthropic", "sonnet"), ("ollama", "qwen")]
894 );
895 }
896
897 #[test]
898 fn candidates_are_deduplicated() {
899 let cfg = model_cfg(vec![("anthropic", "sonnet"), ("anthropic", "sonnet")]);
902 let defaults = ModelDefaults {
903 provider: "anthropic".to_string(),
904 model: Some("sonnet".to_string()),
905 fallback_order: vec![ModelEntry::new(
906 "anthropic".to_string(),
907 "sonnet".to_string(),
908 )],
909 };
910 let registry = registry_with(&["anthropic"]);
911 let got = resolve_stage_candidates(&cfg, None, &defaults, ®istry);
912 assert_eq!(pairs(&got), vec![("anthropic", "sonnet")]);
913 }
914
915 #[test]
916 fn a_full_override_names_exactly_one_candidate() {
917 let cfg = model_cfg(vec![("anthropic", "sonnet"), ("openai", "gpt")]);
919 let defaults = ModelDefaults {
920 fallback_order: vec![ModelEntry::new("openai".to_string(), "gpt".to_string())],
921 ..Default::default()
922 };
923 let registry = registry_with(&["anthropic", "openai", "ollama"]);
924 let got = resolve_stage_candidates(&cfg, Some("ollama/llama"), &defaults, ®istry);
925 assert_eq!(pairs(&got), vec![("ollama", "llama")]);
926 }
927
928 #[test]
929 fn a_bare_override_renames_the_model_on_every_candidate() {
930 let cfg = model_cfg(vec![("anthropic", "sonnet"), ("openai", "gpt")]);
931 let registry = registry_with(&["anthropic", "openai"]);
932 let got =
933 resolve_stage_candidates(&cfg, Some("haiku"), &ModelDefaults::default(), ®istry);
934 assert_eq!(
935 pairs(&got),
936 vec![("anthropic", "haiku"), ("openai", "haiku")]
937 );
938 }
939
940 #[test]
941 fn candidates_are_never_empty_even_with_nothing_registered() {
942 let cfg = ModelConfig {
944 allow_user_default: false,
945 ..model_cfg(vec![("ghost", "nope")])
946 };
947 let got =
948 resolve_stage_candidates(&cfg, None, &ModelDefaults::default(), ®istry_with(&[]));
949 assert_eq!(pairs(&got), vec![("ghost", "nope")]);
950 }
951
952 #[test]
953 fn resolve_stages_carries_the_tail_onto_the_resolved_stage() {
954 let mut stage = leviath_core::Stage::new(
955 "work".to_string(),
956 model_cfg(vec![("openrouter", "deepseek"), ("anthropic", "sonnet")]),
957 );
958 stage.available_tools = vec![];
959 let layout = leviath_core::layout::ContextLayout::new(vec![], 1000);
960 let bp = Blueprint::new("t".to_string(), "d".to_string(), vec![stage], layout);
961 let registry = registry_with(&["openrouter", "anthropic"]);
962 let resolved = resolve_stages(
963 &bp,
964 None,
965 &ModelDefaults::default(),
966 ®istry,
967 &[],
968 false,
969 None,
970 )
971 .expect("both providers are registered");
972 assert_eq!(resolved[0].provider_name, "openrouter");
973 assert_eq!(pairs(&resolved[0].fallbacks), vec![("anthropic", "sonnet")]);
974 }
975
976 #[test]
977 fn an_unattended_run_loses_the_tools_that_wait_on_a_person() {
978 let available = vec![
981 "read_file".to_string(),
982 "ask_user_text".to_string(),
983 "ask_user_choice".to_string(),
984 ];
985 let tools = filter_tools_for_stage(&ask_and_read_defs(), &available, &[], true);
986 assert_eq!(names(&tools), vec!["read_file"]);
987 }
988
989 #[test]
990 fn required_tools_survive_an_unattended_run() {
991 let available = vec![
994 "read_file".to_string(),
995 "ask_user_text".to_string(),
996 "ask_user_choice".to_string(),
997 ];
998 let required = vec!["ask_user_text".to_string()];
999 let tools = filter_tools_for_stage(&ask_and_read_defs(), &available, &required, true);
1000 assert_eq!(names(&tools), vec!["read_file", "ask_user_text"]);
1001 }
1002
1003 #[test]
1004 fn a_required_tool_the_stage_never_offered_adds_nothing() {
1005 let available = vec!["read_file".to_string()];
1009 let required = vec!["ask_user_text".to_string()];
1010 let tools = filter_tools_for_stage(&ask_and_read_defs(), &available, &required, true);
1011 assert_eq!(names(&tools), vec!["read_file"]);
1012 }
1013
1014 #[test]
1015 fn resolve_stages_applies_the_unattended_cut_per_stage() {
1016 let mut plan =
1019 leviath_core::Stage::new("plan".to_string(), model_cfg(vec![("anthropic", "m")]));
1020 plan.available_tools = vec!["read_file".to_string(), "ask_user_text".to_string()];
1021 plan.required_tools = vec!["ask_user_text".to_string()];
1022 let mut build =
1023 leviath_core::Stage::new("build".to_string(), model_cfg(vec![("anthropic", "m")]));
1024 build.available_tools = vec!["read_file".to_string(), "ask_user_text".to_string()];
1025 let layout = leviath_core::layout::ContextLayout::new(vec![], 1000);
1026 let bp = Blueprint::new("t".to_string(), "d".to_string(), vec![plan, build], layout);
1027
1028 let resolved = resolve_stages(
1029 &bp,
1030 None,
1031 &ModelDefaults::default(),
1032 ®istry_with(&["anthropic"]),
1033 &ask_and_read_defs(),
1034 true,
1035 None,
1036 )
1037 .expect("anthropic is registered");
1038
1039 assert_eq!(
1040 names(&resolved[0].tools),
1041 vec!["read_file", "ask_user_text"]
1042 );
1043 assert_eq!(names(&resolved[1].tools), vec!["read_file"]);
1044 }
1045
1046 #[test]
1047 fn the_unattended_cut_resolves_aliases_on_both_sides() {
1048 let defs = vec![Tool {
1054 name: "shell".to_string(),
1055 description: String::new(),
1056 parameters: serde_json::Value::Null,
1057 }];
1058 let available = vec!["bash".to_string()];
1059 let tools = filter_tools_for_stage(&defs, &available, &[], true);
1060 assert_eq!(names(&tools), vec!["shell"]);
1061 }
1062
1063 fn output_stage_blueprint(
1067 agent: Option<leviath_core::output::OutputSpec>,
1068 stage_spec: Option<leviath_core::output::OutputSpec>,
1069 ) -> Blueprint {
1070 let mut stage =
1071 leviath_core::Stage::new("summary".to_string(), model_cfg(vec![("anthropic", "m")]));
1072 stage.available_tools = vec![leviath_tools::SUBMIT_OUTPUT_TOOL.to_string()];
1073 stage.output = stage_spec;
1074 let layout = leviath_core::layout::ContextLayout::new(vec![], 1000);
1075 let mut bp = Blueprint::new("t".to_string(), "d".to_string(), vec![stage], layout);
1076 bp.output = agent;
1077 bp
1078 }
1079
1080 fn submit_tool_defs() -> Vec<Tool> {
1081 vec![Tool {
1082 name: leviath_tools::SUBMIT_OUTPUT_TOOL.to_string(),
1083 description: leviath_tools::submit_output_description(""),
1084 parameters: serde_json::Value::Null,
1085 }]
1086 }
1087
1088 fn resolve_one(
1089 bp: &Blueprint,
1090 request: Option<&leviath_core::output::OutputSpec>,
1091 ) -> ResolvedStage {
1092 resolve_stages(
1093 bp,
1094 None,
1095 &ModelDefaults::default(),
1096 ®istry_with(&["anthropic"]),
1097 &submit_tool_defs(),
1098 false,
1099 request,
1100 )
1101 .expect("anthropic is registered")
1102 .remove(0)
1103 }
1104
1105 #[test]
1110 fn a_spec_that_says_nothing_leaves_the_description_generic() {
1111 let generic = leviath_tools::submit_output_description("");
1112 let bp = output_stage_blueprint(None, Some(leviath_core::output::OutputSpec::default()));
1113
1114 let resolved = resolve_one(&bp, None);
1115
1116 assert_eq!(
1117 resolved
1118 .tools
1119 .iter()
1120 .find(|t| t.name == leviath_tools::SUBMIT_OUTPUT_TOOL)
1121 .expect("the stage offers the tool")
1122 .description,
1123 generic
1124 );
1125 }
1126
1127 #[test]
1131 fn an_unrecognized_format_reaches_the_submit_tool_description() {
1132 let bp = output_stage_blueprint(
1133 None,
1134 Some(leviath_core::output::OutputSpec {
1135 format: Some("a2ui".to_string()),
1136 instructions: Some("One card per finding.".to_string()),
1137 example: Some("{\"root\": {}}".to_string()),
1138 schema: None,
1139 validator: None,
1140 }),
1141 );
1142 let resolved = resolve_one(&bp, None);
1143 let description = &resolved
1144 .tools
1145 .iter()
1146 .find(|t| t.name == leviath_tools::SUBMIT_OUTPUT_TOOL)
1147 .expect("the stage offers the tool")
1148 .description;
1149 assert!(description.contains("a2ui"), "{description}");
1150 assert!(
1151 description.contains("One card per finding."),
1152 "{description}"
1153 );
1154 assert!(description.contains("{\"root\": {}}"), "{description}");
1155 assert_eq!(
1156 resolved.output.and_then(|s| s.format).as_deref(),
1157 Some("a2ui")
1158 );
1159 }
1160
1161 #[test]
1164 fn a_stage_declaring_no_shape_keeps_the_generic_description() {
1165 let bp = output_stage_blueprint(None, None);
1166 let resolved = resolve_one(&bp, None);
1167 assert_eq!(
1168 resolved.tools[0].description,
1169 leviath_tools::submit_output_description("")
1170 );
1171 assert!(resolved.output.is_none());
1172 }
1173
1174 #[test]
1178 fn a_callers_request_overrides_the_blueprint_and_drops_its_schema() {
1179 let bp = output_stage_blueprint(
1180 Some(leviath_core::output::OutputSpec {
1181 format: Some("json".to_string()),
1182 schema: Some(serde_json::json!({"type": "object"})),
1183 ..Default::default()
1184 }),
1185 None,
1186 );
1187 let request = leviath_core::output::OutputSpec {
1188 format: Some("xml".to_string()),
1189 ..Default::default()
1190 };
1191 let resolved = resolve_one(&bp, Some(&request));
1192 let spec = resolved.output.expect("a shape was asked for");
1193 assert_eq!(spec.format.as_deref(), Some("xml"));
1194 assert_eq!(spec.schema, None);
1195 assert!(resolved.tools[0].description.contains("xml"));
1196 }
1197
1198 #[test]
1200 fn a_stage_without_the_submit_tool_is_left_alone() {
1201 let mut stage =
1202 leviath_core::Stage::new("plan".to_string(), model_cfg(vec![("anthropic", "m")]));
1203 stage.available_tools = vec!["read_file".to_string()];
1204 let layout = leviath_core::layout::ContextLayout::new(vec![], 1000);
1205 let mut bp = Blueprint::new("t".to_string(), "d".to_string(), vec![stage], layout);
1206 bp.output = Some(leviath_core::output::OutputSpec {
1207 format: Some("a2ui".to_string()),
1208 ..Default::default()
1209 });
1210 let resolved = resolve_stages(
1211 &bp,
1212 None,
1213 &ModelDefaults::default(),
1214 ®istry_with(&["anthropic"]),
1215 &[Tool {
1216 name: "read_file".to_string(),
1217 description: "read a file".to_string(),
1218 parameters: serde_json::Value::Null,
1219 }],
1220 false,
1221 None,
1222 )
1223 .expect("anthropic is registered");
1224 assert_eq!(resolved[0].tools[0].description, "read a file");
1225 }
1226}