use super::support::*;
use super::*;
#[test]
fn cached_thinking_levels_for_model_uses_cache_before_name_fallback() {
let temp = tempfile::TempDir::new().unwrap();
let paths = test_paths(temp.path());
crate::config::write_settings(
&paths,
&crate::config::Settings {
custom_providers: std::collections::BTreeMap::from([(
"local-ai".to_string(),
crate::config::CustomProviderConfig {
label: "Local AI".to_string(),
base_url: "http://localhost:11434/v1".to_string(),
fast_mode: None,
api_key_env_var: None,
models_dev_provider: None,
use_responses_endpoint: false,
supports_text_verbosity: false,
reasoning_protocol: crate::config::CustomReasoningProtocol::default(),
extra_models: Vec::new(),
request_headers: Default::default(),
},
)]),
..Default::default()
},
)
.unwrap();
let mut custom_supported =
crate::model_catalog::ModelCatalogEntry::new("local-ai", "plain-model");
custom_supported.reasoning_efforts =
Some(crate::thinking::ThinkingLevel::EFFORT_GENERIC.to_vec());
let mut codex_disabled = crate::model_catalog::ModelCatalogEntry::new_codex("gpt-5");
codex_disabled.reasoning_efforts = Some(vec![crate::thinking::ThinkingLevel::Default]);
crate::model_catalog::write_catalog_cache_for_configured_provider(
&paths,
"local-ai",
&[custom_supported],
)
.unwrap();
crate::model_catalog::write_catalog_cache(
&paths,
crate::providers::OPENAI_CODEX_PROVIDER,
&[codex_disabled],
)
.unwrap();
let custom_gpt_like = crate::thinking::ThinkingCapabilityScope::Custom(
crate::config::CustomReasoningProtocol::GptLike,
);
let builtin = crate::thinking::ThinkingCapabilityScope::BuiltIn;
assert_eq!(
cached_thinking_levels_for_model(&paths, "local-ai", "plain-model", custom_gpt_like),
crate::thinking::ThinkingLevel::EFFORT_GENERIC.to_vec()
);
assert_eq!(
cached_thinking_levels_for_model(
&paths,
crate::providers::OPENAI_CODEX_PROVIDER,
"gpt-5",
builtin,
),
vec![crate::thinking::ThinkingLevel::Default]
);
assert_eq!(
cached_thinking_levels_for_model(
&paths,
crate::providers::OPENAI_CODEX_PROVIDER,
"o3",
builtin,
),
crate::thinking::ThinkingLevel::EFFORT_GENERIC.to_vec()
);
assert_eq!(
cached_thinking_levels_for_model(&paths, "local-ai", "gpt-5", custom_gpt_like),
vec![crate::thinking::ThinkingLevel::Default]
);
for model in ["claude-haiku-4-5-20251001", "claude-fable-5-1"] {
assert_eq!(
cached_thinking_levels_for_model(
&paths,
crate::providers::ANTHROPIC_PROVIDER,
model,
builtin,
),
crate::thinking::ThinkingLevel::HIGH_MAX.to_vec(),
"{model}"
);
}
}
#[test]
fn primary_agent_selection_persistence_writes_id_and_none() {
let temp = tempfile::TempDir::new().unwrap();
let (sender, _receiver) = bounded::<TuiEvent>(1);
let mut app = test_app(&temp, sender);
let mut state = state::MissionControlState::default();
app.persist_primary_agent_selection(Some("tars"), &mut state);
reap_owned_worker(
&mut app,
&mut state,
MissionControlApp::reap_settings_persistence_worker,
);
assert_eq!(state.status, "primary agent selected: tars");
assert_eq!(
crate::config::selected_primary_agent(&app.config.paths)
.unwrap()
.as_deref(),
Some("tars")
);
app.persist_primary_agent_selection(None, &mut state);
reap_owned_worker(
&mut app,
&mut state,
MissionControlApp::reap_settings_persistence_worker,
);
assert_eq!(state.status, "primary agent selection cleared");
assert_eq!(
crate::config::selected_primary_agent(&app.config.paths).unwrap(),
None
);
let raw: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&app.config.paths.settings_file).unwrap())
.unwrap();
assert_eq!(raw["selected_primary_agent"], serde_json::Value::Null);
}
#[test]
fn primary_agent_selection_persistence_reports_invalid_id_without_write() {
let temp = tempfile::TempDir::new().unwrap();
let (sender, _receiver) = bounded::<TuiEvent>(1);
let mut app = test_app(&temp, sender);
let mut state = state::MissionControlState::default();
app.persist_primary_agent_selection(Some("bad/id"), &mut state);
reap_owned_worker(
&mut app,
&mut state,
MissionControlApp::reap_settings_persistence_worker,
);
assert!(
state.status.contains("failed to save setting"),
"{}",
state.status
);
assert!(!app.config.paths.settings_file.exists());
}
#[test]
fn submit_model_picker_rejects_fresh_codex_cache_when_active_provider_is_openai() {
let temp = tempfile::TempDir::new().unwrap();
let (sender, receiver) = bounded::<TuiEvent>(4);
let mut app = test_app(&temp, sender);
crate::model_catalog::write_catalog_cache(
&app.config.paths,
crate::providers::OPENAI_CODEX_PROVIDER,
&[crate::model_catalog::ModelCatalogEntry::new_codex(
"gpt-next",
)],
)
.unwrap();
let mut ui_state = state::MissionControlState {
provider: "openai".to_string(),
model: "test-model".to_string(),
..Default::default()
};
app.submit(
"/setmodel".to_string(),
&mut ui_state,
&receiver,
test_area(),
);
assert!(ui_state.status.contains("loading model catalogs"));
assert!(ui_state.modals.model_picker.is_none());
assert_eq!(ui_state.model, "test-model");
assert!(receiver.try_recv().is_err());
assert!(app.worker.is_none());
assert!(!app.active_run);
}
#[test]
fn tui_direct_model_switch_preserves_provider_when_active_provider_is_codex() {
let temp = tempfile::TempDir::new().unwrap();
let (sender, receiver) = bounded::<TuiEvent>(4);
let mut app = test_app(&temp, sender);
app.state.auth_state = crate::config::AuthState::Missing {
provider: crate::providers::OPENAI_CODEX_PROVIDER.to_string(),
};
crate::config::write_settings(
&app.config.paths,
&crate::config::Settings {
selected_model: crate::config::SelectedModelSettings {
provider: Some(crate::providers::OPENAI_CODEX_PROVIDER.to_string()),
model: Some("gpt-old".to_string()),
..Default::default()
},
..Default::default()
},
)
.unwrap();
let mut entry = crate::model_catalog::ModelCatalogEntry::new_codex("gpt-next");
entry.context_window = Some(196_000);
app.state.model = "gpt-old".to_string();
crate::model_catalog::write_catalog_cache(
&app.config.paths,
crate::providers::OPENAI_CODEX_PROVIDER,
&[entry],
)
.unwrap();
let mut ui_state = state::MissionControlState {
provider: crate::providers::OPENAI_CODEX_PROVIDER.to_string(),
model: "gpt-old".to_string(),
context_usage: Some(state::ContextUsageState {
current_tokens: 12_345,
max_tokens: 128_000,
reasoning_tokens: None,
..state::ContextUsageState::default()
}),
..Default::default()
};
app.select_model("openai-codex/gpt-next", &mut ui_state);
reap_model_selection_worker(&mut app, &mut ui_state, &receiver);
assert_eq!(app.state.model, "gpt-next");
assert_eq!(ui_state.provider, crate::providers::OPENAI_CODEX_PROVIDER);
assert_eq!(ui_state.model, "gpt-next");
assert_eq!(ui_state.context_usage, None);
let saved = crate::config::read_settings(&app.config.paths).unwrap();
assert_eq!(
saved.selected_model.provider.as_deref(),
Some(crate::providers::OPENAI_CODEX_PROVIDER)
);
assert_eq!(saved.selected_model.model.as_deref(), Some("gpt-next"));
}
#[test]
fn tui_direct_model_switch_reconciles_authoritative_result_after_runtime_state_changes() {
let temp = tempfile::TempDir::new().unwrap();
let (sender, receiver) = bounded::<TuiEvent>(4);
let mut app = test_app(&temp, sender);
let provider = crate::providers::OPENAI_CODEX_PROVIDER;
crate::config::write_settings(
&app.config.paths,
&crate::config::Settings {
selected_model: crate::config::SelectedModelSettings {
provider: Some(provider.to_string()),
model: Some("gpt-old".to_string()),
..Default::default()
},
..Default::default()
},
)
.unwrap();
crate::model_catalog::write_catalog_cache(
&app.config.paths,
provider,
&[crate::model_catalog::ModelCatalogEntry::new_codex(
"gpt-next",
)],
)
.unwrap();
app.state.auth_state = crate::config::AuthState::Missing {
provider: provider.to_string(),
};
app.state.model = "gpt-old".to_string();
let mut ui_state = state::MissionControlState {
provider: provider.to_string(),
model: "gpt-old".to_string(),
..Default::default()
};
app.select_model("openai-codex/gpt-next", &mut ui_state);
app.state.model = "runtime-changed".to_string();
reap_model_selection_worker(&mut app, &mut ui_state, &receiver);
assert_eq!(app.state.model, "gpt-next");
assert_eq!(ui_state.model, "gpt-next");
assert!(ui_state.status.starts_with("model switched to "));
assert!(receiver.try_recv().is_err());
}
#[test]
fn tui_direct_setmodel_rejects_disabled_model_without_changing_selection() {
let temp = tempfile::TempDir::new().unwrap();
let (sender, receiver) = bounded::<TuiEvent>(4);
let mut app = test_app(&temp, sender);
crate::model_catalog::write_catalog_cache(
&app.config.paths,
crate::providers::OPENAI_CODEX_PROVIDER,
&[crate::model_catalog::ModelCatalogEntry::new_codex(
"gpt-next",
)],
)
.unwrap();
crate::config::set_model_disabled_for_scope(
&app.config.paths,
crate::config::SettingsScope::Global,
"openai-codex/gpt-next",
true,
)
.unwrap();
app.state.auth_state = crate::config::AuthState::Missing {
provider: crate::providers::OPENAI_CODEX_PROVIDER.to_string(),
};
app.state.model = "gpt-old".to_string();
let mut ui_state = state::MissionControlState {
provider: crate::providers::OPENAI_CODEX_PROVIDER.to_string(),
model: "gpt-old".to_string(),
..Default::default()
};
app.submit(
"/setmodel openai-codex/gpt-next".to_string(),
&mut ui_state,
&receiver,
test_area(),
);
reap_model_selection_worker(&mut app, &mut ui_state, &receiver);
assert_eq!(
ui_state.status,
"cannot select disabled model: openai-codex/gpt-next"
);
assert_eq!(ui_state.model, "gpt-old");
assert_ne!(app.state.model, "gpt-next");
}
#[test]
fn model_picker_excludes_disabled_models() {
let mut ui_state = state::MissionControlState {
provider: crate::providers::OPENAI_CODEX_PROVIDER.to_string(),
model: "gpt-old".to_string(),
..Default::default()
};
ui_state.open_model_picker(
vec![
crate::model_catalog::ModelCatalogEntry::new_codex("gpt-disabled"),
crate::model_catalog::ModelCatalogEntry::new_codex("gpt-enabled"),
],
std::collections::BTreeSet::from(["openai-codex/gpt-disabled".to_string()]),
None,
5,
);
let picker = ui_state.modals.model_picker.as_ref().unwrap();
let ids = picker
.entries
.iter()
.map(|entry| entry.id.as_str())
.collect::<Vec<_>>();
assert_eq!(ids, vec!["openai-codex/gpt-enabled"]);
assert_eq!(
ui_state.selected_model_picker_id().as_deref(),
Some("openai-codex/gpt-enabled")
);
}
#[test]
fn tui_direct_model_switch_uses_cached_custom_reasoning_metadata() {
let temp = tempfile::TempDir::new().unwrap();
let (sender, receiver) = bounded::<TuiEvent>(4);
let mut app = test_app(&temp, sender);
let mut custom_providers = std::collections::BTreeMap::new();
custom_providers.insert(
"local-ai".to_string(),
crate::config::CustomProviderConfig {
label: "Local AI".to_string(),
base_url: "http://localhost:11434/v1".to_string(),
fast_mode: None,
api_key_env_var: None,
models_dev_provider: None,
reasoning_protocol: crate::config::CustomReasoningProtocol::default(),
use_responses_endpoint: false,
supports_text_verbosity: false,
extra_models: Vec::new(),
request_headers: Default::default(),
},
);
crate::config::write_settings(
&app.config.paths,
&crate::config::Settings {
selected_model: crate::config::SelectedModelSettings {
provider: Some("local-ai".to_string()),
model: Some("old-model".to_string()),
thinking_level: Some(crate::thinking::ThinkingLevel::High),
..Default::default()
},
custom_providers: custom_providers.clone(),
..Default::default()
},
)
.unwrap();
app.config.custom_providers = custom_providers;
app.config.thinking_level = crate::thinking::ThinkingLevel::High;
app.state.auth_state = crate::config::AuthState::Ready {
provider: "local-ai".to_string(),
credential: crate::config::ProviderCredential::NoAuth,
};
app.state.model = "old-model".to_string();
let mut supported = crate::model_catalog::ModelCatalogEntry::new("local-ai", "gpt-next");
supported.reasoning_efforts = Some(crate::thinking::ThinkingLevel::EFFORT_GENERIC.to_vec());
let mut unsupported = crate::model_catalog::ModelCatalogEntry::new("local-ai", "plain-next");
unsupported.reasoning_efforts = Some(vec![crate::thinking::ThinkingLevel::Default]);
crate::model_catalog::write_catalog_cache_for_configured_provider(
&app.config.paths,
"local-ai",
&[supported, unsupported],
)
.unwrap();
let mut ui_state = state::MissionControlState {
provider: "local-ai".to_string(),
model: "old-model".to_string(),
thinking_levels: vec![crate::thinking::ThinkingLevel::Default],
..Default::default()
};
app.select_model("local-ai/gpt-next", &mut ui_state);
reap_model_selection_worker(&mut app, &mut ui_state, &receiver);
assert_eq!(ui_state.provider, "local-ai");
assert_eq!(ui_state.model, "gpt-next");
assert_eq!(
ui_state.thinking_levels,
crate::thinking::ThinkingLevel::EFFORT_GENERIC.to_vec()
);
assert_eq!(
ui_state.effective_thinking_level,
crate::thinking::ThinkingLevel::High
);
app.select_model("local-ai/plain-next", &mut ui_state);
reap_model_selection_worker(&mut app, &mut ui_state, &receiver);
assert_eq!(
ui_state.thinking_levels,
vec![crate::thinking::ThinkingLevel::Default]
);
assert_eq!(
ui_state.effective_thinking_level,
crate::thinking::ThinkingLevel::Default
);
}
#[test]
fn tui_direct_model_switch_hides_unknown_context_usage() {
let temp = tempfile::TempDir::new().unwrap();
let (sender, receiver) = bounded::<TuiEvent>(4);
let mut app = test_app(&temp, sender);
app.state.auth_state = crate::config::AuthState::Missing {
provider: crate::providers::OPENAI_CODEX_PROVIDER.to_string(),
};
crate::config::write_settings(
&app.config.paths,
&crate::config::Settings {
selected_model: crate::config::SelectedModelSettings {
provider: Some(crate::providers::OPENAI_CODEX_PROVIDER.to_string()),
model: Some("gpt-old".to_string()),
..Default::default()
},
context: Some(crate::context::ContextBudget {
max_tokens: 64_000,
..Default::default()
}),
..Default::default()
},
)
.unwrap();
crate::model_catalog::write_catalog_cache(
&app.config.paths,
crate::providers::OPENAI_CODEX_PROVIDER,
&[crate::model_catalog::ModelCatalogEntry::new_codex(
"gpt-next",
)],
)
.unwrap();
app.state.model = "gpt-old".to_string();
let mut ui_state = state::MissionControlState {
provider: crate::providers::OPENAI_CODEX_PROVIDER.to_string(),
model: "gpt-old".to_string(),
context_usage: Some(state::ContextUsageState {
current_tokens: 12_345,
max_tokens: 128_000,
reasoning_tokens: None,
..state::ContextUsageState::default()
}),
..Default::default()
};
app.select_model("openai-codex/gpt-next", &mut ui_state);
reap_model_selection_worker(&mut app, &mut ui_state, &receiver);
assert_eq!(ui_state.context_usage, None);
}