use super::{
dispatcher::{RequestIdGuard, ServiceOutbound},
protocol::{
EmptyParams, RequestIdentity, ResponsePayload, ServiceErrorCode, ServiceMessage,
ServiceRequest, ServiceResponse,
},
};
use crate::{
config::{self, McPaths, Settings, SettingsScope},
model_catalog,
thinking::{self, ThinkingLevel},
};
use serde::Deserialize;
use serde_json::{Value, json};
use std::collections::BTreeSet;
pub(crate) const MAX_CATALOG_PAGE_ITEMS: usize = 32;
const MAX_ID_BYTES: usize = 256;
const MAX_LABEL_BYTES: usize = 128;
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct PageParams {
#[serde(default)]
offset: usize,
#[serde(default = "page_limit")]
limit: usize,
}
fn page_limit() -> usize {
MAX_CATALOG_PAGE_ITEMS
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct ModelParams {
provider_id: String,
#[serde(default)]
offset: usize,
#[serde(default = "page_limit")]
limit: usize,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct RefreshParams {
provider_id: String,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Selection {
provider_id: String,
model_id: String,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Mutation {
scope: String,
selection: Option<Selection>,
thinking_level: Option<ThinkingLevel>,
fast: Option<bool>,
}
fn display_label(label: Option<&str>, fallback: &str) -> String {
label
.map(crate::output::sanitize_display_text)
.filter(|label| !label.is_empty() && label.len() <= MAX_LABEL_BYTES)
.unwrap_or_else(|| fallback.chars().take(MAX_LABEL_BYTES).collect())
}
fn safe_identity(value: &str) -> bool {
!value.is_empty()
&& value.len() <= MAX_ID_BYTES
&& value
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b"-._/:".contains(&b))
&& crate::output::sanitize_display_text(value) == value
&& !config::looks_like_secret_value(value)
}
fn provider_exists(settings: &Settings, provider: &str) -> bool {
safe_identity(provider)
&& (matches!(provider, "openai-codex" | "anthropic")
|| settings.custom_providers.contains_key(provider))
}
fn levels(paths: &McPaths, settings: &Settings, provider: &str, model: &str) -> Vec<ThinkingLevel> {
thinking::available_thinking_levels(
provider,
model,
model_catalog::cached_model_thinking_metadata(paths, provider, model).as_ref(),
thinking::capability_scope_for_provider(&settings.custom_providers, provider),
)
}
fn effective_selection(settings: &Settings) -> Result<Selection, ServiceErrorCode> {
let (provider, model) = config::EffectiveConfig::selected_provider_model(settings);
let provider = provider.unwrap_or_else(|| crate::providers::OPENAI_CODEX_PROVIDER.to_string());
let model = model
.unwrap_or_else(|| crate::providers::default_model_for_provider(&provider).to_string());
if !safe_identity(&provider) || !safe_identity(&model) {
return Err(ServiceErrorCode::ConfigurationUnavailable);
}
Ok(Selection {
provider_id: provider,
model_id: model,
})
}
fn effective(paths: &McPaths, settings: &Settings) -> Result<Value, ServiceErrorCode> {
let Selection {
provider_id: provider,
model_id: model,
} = effective_selection(settings)?;
let available = levels(paths, settings, &provider, &model);
let configured = settings.selected_model.thinking_level.unwrap_or_default();
Ok(
json!({"selection": {"provider_id": provider, "model_id": model},
"thinking_level": thinking::resolve_thinking_level(&available, configured),
"configured_thinking_level": configured, "available_thinking_levels": available,
"fast": settings.fast.enabled,
"precedence": ["environment", "project", "global"],
"writable_scopes": ["global", "project"], "global_only": ["fast"]}),
)
}
pub(crate) fn dispatch(
paths: &McPaths,
request: ServiceRequest,
guard: RequestIdGuard,
initialized: bool,
) -> ServiceOutbound {
let result = if !initialized {
Err(ServiceErrorCode::NotInitialized)
} else if request.session_id.is_some() {
Err(ServiceErrorCode::InvalidPayload)
} else {
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| operation(paths, &request)))
.unwrap_or(Err(ServiceErrorCode::InternalError))
};
let response = match result {
Ok(value) => ServiceResponse::success(&request, ResponsePayload::Configuration(value)),
Err(code) => ServiceResponse::error(RequestIdentity::from_request(&request), code),
};
ServiceOutbound::guarded(vec![ServiceMessage::response(response)], guard)
}
fn page_valid(offset: usize, limit: usize) -> Result<(), ServiceErrorCode> {
if limit == 0 || limit > MAX_CATALOG_PAGE_ITEMS || offset > 100_000 {
Err(ServiceErrorCode::LimitExceeded)
} else {
Ok(())
}
}
fn operation(paths: &McPaths, request: &ServiceRequest) -> Result<Value, ServiceErrorCode> {
let invalid = |_| ServiceErrorCode::InvalidPayload;
match request.method.as_str() {
"config.get" => {
let _: EmptyParams =
serde_json::from_value(request.payload.clone()).map_err(invalid)?;
let settings = config::read_settings(paths)
.map_err(|_| ServiceErrorCode::ConfigurationUnavailable)?;
effective(paths, &settings)
}
"config.set" => {
if request
.payload
.as_object()
.is_some_and(|fields| fields.values().any(Value::is_null))
{
return Err(ServiceErrorCode::InvalidPayload);
}
let params: Mutation =
serde_json::from_value(request.payload.clone()).map_err(invalid)?;
mutate(paths, params)
}
"catalog.providers" => {
let params: PageParams =
serde_json::from_value(request.payload.clone()).map_err(invalid)?;
page_valid(params.offset, params.limit)?;
let settings = config::read_settings(paths)
.map_err(|_| ServiceErrorCode::ConfigurationUnavailable)?;
let providers: BTreeSet<&str> = ["anthropic", "openai-codex"]
.into_iter()
.chain(settings.custom_providers.keys().map(String::as_str))
.filter(|id| safe_identity(id))
.collect();
let entries: Vec<Value> = providers
.iter()
.skip(params.offset)
.take(params.limit)
.map(|id| json!({"provider_id": id, "label": display_label(settings.custom_providers.get(*id).map(|custom| custom.label.as_str()), id)}))
.collect();
let next = (params.offset.saturating_add(entries.len()) < providers.len())
.then_some(params.offset + entries.len());
Ok(json!({"entries": entries, "next_offset": next, "refresh": "cache_only"}))
}
"catalog.refresh" => {
let params: RefreshParams =
serde_json::from_value(request.payload.clone()).map_err(invalid)?;
let settings = config::read_settings(paths)
.map_err(|_| ServiceErrorCode::ConfigurationUnavailable)?;
if !provider_exists(&settings, ¶ms.provider_id) {
return Err(ServiceErrorCode::InvalidSelection);
}
model_catalog::refresh_catalog_for_provider(paths, ¶ms.provider_id, "")
.map_err(|_| ServiceErrorCode::CatalogRefreshFailed)?;
let catalog = model_catalog::cached_catalog_for_service(paths, ¶ms.provider_id);
let state = match catalog {
Some(c) if !c.stale => "fresh_cache",
Some(_) => "stale",
None => "unavailable",
};
Ok(json!({"provider_id": params.provider_id, "state": state}))
}
"catalog.models" => {
let params: ModelParams =
serde_json::from_value(request.payload.clone()).map_err(invalid)?;
page_valid(params.offset, params.limit)?;
let settings = config::read_settings(paths)
.map_err(|_| ServiceErrorCode::ConfigurationUnavailable)?;
if !provider_exists(&settings, ¶ms.provider_id) {
return Err(ServiceErrorCode::InvalidSelection);
}
let catalog = model_catalog::cached_catalog_for_service(paths, ¶ms.provider_id);
let state = match &catalog {
None => "unavailable",
Some(c) if c.stale => "stale",
Some(_) => "fresh_cache",
};
let mut seen = BTreeSet::new();
let disabled = config::disabled_model_ids_from_settings(&settings);
let entries: Vec<Value> = catalog.as_ref().into_iter().flat_map(|c| &c.entries)
.filter(|e| safe_identity(&e.model) && seen.insert(e.model.clone()))
.skip(params.offset).take(params.limit + 1).map(|e| {
let label = display_label(e.display_name.as_deref(), &e.model);
let metadata = (state == "fresh_cache").then(|| thinking::CatalogThinkingMetadata {
reasoning_efforts: e.reasoning_efforts.clone(),
supports_reasoning: e.supports_reasoning.or(e.legacy_supports_reasoning_effort),
});
let available = thinking::available_thinking_levels(&e.provider, &e.model, metadata.as_ref(),
thinking::capability_scope_for_provider(&settings.custom_providers, &e.provider));
json!({"provider_id": e.provider, "model_id": e.model, "label": label,
"available": state == "fresh_cache" && !disabled.contains(&e.id),
"context_window": e.context_window, "max_output_tokens": e.max_output_tokens,
"available_thinking_levels": available})
}).collect();
let next = (entries.len() > params.limit).then_some(params.offset + params.limit);
Ok(
json!({"entries": entries.into_iter().take(params.limit).collect::<Vec<_>>(), "next_offset": next,
"state": state, "refresh": "cache_only"}),
)
}
_ => Err(ServiceErrorCode::UnsupportedOperation),
}
}
fn validate_selection(
paths: &McPaths,
settings: &Settings,
provider: &str,
model: &str,
) -> Result<(), ServiceErrorCode> {
if !provider_exists(settings, provider) || !safe_identity(model) {
return Err(ServiceErrorCode::InvalidSelection);
}
let catalog = model_catalog::cached_catalog_for_service(paths, provider)
.ok_or(ServiceErrorCode::CatalogUnavailable)?;
if catalog.stale {
return Err(ServiceErrorCode::CatalogStale);
}
let id = format!("{provider}/{model}");
if !catalog.entries.iter().any(|e| e.model == model)
|| config::disabled_model_ids_from_settings(settings).contains(&id)
{
return Err(ServiceErrorCode::InvalidSelection);
}
Ok(())
}
fn mutate(paths: &McPaths, params: Mutation) -> Result<Value, ServiceErrorCode> {
let scope = match params.scope.as_str() {
"global" => SettingsScope::Global,
"project" => SettingsScope::Project,
_ => return Err(ServiceErrorCode::InvalidScope),
};
if params.fast.is_some() && scope == SettingsScope::Project {
return Err(ServiceErrorCode::InvalidScope);
}
if params.selection.is_none() && params.thinking_level.is_none() && params.fast.is_none() {
return Err(ServiceErrorCode::InvalidPayload);
}
let mut failure = ServiceErrorCode::SettingsWriteFailed;
let explicit_paths: &[&[&str]] = if params.selection.is_some() {
&[
&["agent", "model", "provider"],
&["agent", "model", "model"],
&["agent", "model", "thinking_level"],
]
} else if params.thinking_level.is_some() {
&[&["agent", "model", "thinking_level"]]
} else {
&[]
};
let result = config::update_settings_checked(
paths,
scope,
explicit_paths,
|target, current| {
let validate = || -> Result<(), ServiceErrorCode> {
if params.selection.is_some() || params.thinking_level.is_some() {
let resolved;
let selection = match ¶ms.selection {
Some(selection) => selection,
None => {
resolved = effective_selection(current)?;
&resolved
}
};
let provider = selection.provider_id.as_str();
let model = selection.model_id.as_str();
validate_selection(paths, current, provider, model)?;
let thinking = params.thinking_level.unwrap_or_default();
if !levels(paths, current, provider, model).contains(&thinking) {
return Err(ServiceErrorCode::UnsupportedReasoning);
}
}
Ok(())
};
if let Err(code) = validate() {
failure = code;
anyhow::bail!("configuration validation failed");
}
if let Some(selection) = ¶ms.selection {
target.selected_model.provider = Some(selection.provider_id.clone());
target.selected_model.model = Some(selection.model_id.clone());
target.selected_model.thinking_level =
Some(params.thinking_level.unwrap_or_default());
} else if let Some(level) = params.thinking_level {
target.selected_model.thinking_level = Some(level);
}
if let Some(fast) = params.fast {
target.fast.enabled = fast;
}
Ok(())
},
|settings| {
effective(paths, settings)
.map_err(|_| anyhow::anyhow!("effective configuration unavailable"))
},
);
result
.map(|value| json!({"persisted": true, "scope": params.scope, "effective": value}))
.map_err(|_| failure)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model_catalog::ModelCatalogEntry;
use std::fs;
fn fixture() -> (tempfile::TempDir, McPaths) {
let temp = tempfile::tempdir().unwrap();
let paths = McPaths::from_root_and_project_dir(
temp.path().join("home"),
temp.path().join("project"),
);
fs::create_dir_all(temp.path().join("project")).unwrap();
paths.ensure_runtime_dirs().unwrap();
let mut model = ModelCatalogEntry::new("anthropic", "claude-test");
model.reasoning_efforts = Some(vec![ThinkingLevel::Default, ThinkingLevel::High]);
model_catalog::write_catalog_cache(&paths, "anthropic", &[model]).unwrap();
(temp, paths)
}
fn call(paths: &McPaths, method: &str, payload: Value) -> Result<Value, ServiceErrorCode> {
operation(paths, &ServiceRequest::new("config-test", method, payload))
}
fn select(scope: &str) -> Value {
json!({"scope": scope, "selection": {"provider_id": "anthropic", "model_id": "claude-test"}, "thinking_level": "high"})
}
#[test]
fn scoped_selection_is_durable_preserves_unknowns_and_returns_effective_precedence() {
let env = crate::test_support::env::env_lock();
let _provider = env.save("MC_PROVIDER");
let _model = env.save("MC_MODEL");
env.remove_var("MC_PROVIDER");
env.remove_var("MC_MODEL");
let (_temp, paths) = fixture();
fs::write(&paths.settings_file, r#"{"schema_version":2,"private_extension":{"keep":true},"agent":{"model":{"provider":"anthropic","model":"global-model","future_field":7}}}"#).unwrap();
let result = call(&paths, "config.set", select("project")).unwrap();
assert_eq!(result["effective"]["selection"]["model_id"], "claude-test");
assert_eq!(result["effective"]["thinking_level"], "high");
assert_eq!(
config::read_settings(&paths)
.unwrap()
.selected_model
.model
.as_deref(),
Some("claude-test")
);
let global: Value =
serde_json::from_slice(&fs::read(&paths.settings_file).unwrap()).unwrap();
assert_eq!(global["private_extension"]["keep"], true);
assert_eq!(global["agent"]["model"]["future_field"], 7);
let result = call(&paths, "config.set", json!({"scope":"global", "fast":true})).unwrap();
assert_eq!(result["effective"]["selection"]["model_id"], "claude-test");
assert_eq!(result["effective"]["fast"], true);
env.set_var("MC_MODEL", "environment-model");
assert_eq!(
call(&paths, "config.get", json!({})).unwrap()["selection"]["model_id"],
"environment-model"
);
}
#[test]
fn reasoning_only_uses_environment_selection_without_changing_stored_selection() {
let env = crate::test_support::env::env_lock();
let _provider = env.save("MC_PROVIDER");
let _model = env.save("MC_MODEL");
env.set_var("MC_PROVIDER", "anthropic");
env.set_var("MC_MODEL", "environment-model");
for scope in ["global", "project"] {
for stored_selection in [false, true] {
let (_temp, paths) = fixture();
let mut stored = ModelCatalogEntry::new("anthropic", "claude-test");
stored.reasoning_efforts = Some(vec![ThinkingLevel::Default, ThinkingLevel::High]);
let mut selected = ModelCatalogEntry::new("anthropic", "environment-model");
selected.reasoning_efforts = Some(vec![ThinkingLevel::Default, ThinkingLevel::Low]);
model_catalog::write_catalog_cache(&paths, "anthropic", &[stored, selected])
.unwrap();
if stored_selection {
call(&paths, "config.set", select(scope)).unwrap();
}
let before = config::read_settings(&paths).unwrap().selected_model;
let advertised = call(&paths, "config.get", json!({})).unwrap();
assert_eq!(advertised["selection"]["model_id"], "environment-model");
assert_eq!(
advertised["available_thinking_levels"],
json!(["default", "low"])
);
let global = fs::read(&paths.settings_file).ok();
let project = fs::read(&paths.project_settings_file).ok();
assert_eq!(
call(
&paths,
"config.set",
json!({"scope":scope,"thinking_level":"high"})
),
Err(ServiceErrorCode::UnsupportedReasoning)
);
assert_eq!(fs::read(&paths.settings_file).ok(), global);
assert_eq!(fs::read(&paths.project_settings_file).ok(), project);
for level in ["default", "low"] {
let result = call(
&paths,
"config.set",
json!({"scope":scope,"thinking_level":level}),
)
.unwrap();
assert_eq!(result["effective"]["thinking_level"], level);
let after = config::read_settings(&paths).unwrap().selected_model;
assert_eq!(after.provider, before.provider);
assert_eq!(after.model, before.model);
assert_eq!(serde_json::to_value(after.thinking_level).unwrap(), level);
if scope == "global" {
assert_eq!(fs::read(&paths.project_settings_file).ok(), project);
} else {
assert_eq!(fs::read(&paths.settings_file).ok(), global);
}
}
}
}
}
#[test]
fn invalid_mutations_leave_both_settings_layers_unchanged() {
let _env = crate::test_support::env::env_lock();
let (_temp, paths) = fixture();
call(&paths, "config.set", select("global")).unwrap();
let original = fs::read(&paths.settings_file).unwrap();
for (payload, expected) in [
(
json!({"scope":"project","fast":true}),
ServiceErrorCode::InvalidScope,
),
(
json!({"scope":"other","fast":true}),
ServiceErrorCode::InvalidScope,
),
(
json!({"scope":"global","thinking_level":"max"}),
ServiceErrorCode::UnsupportedReasoning,
),
(
json!({"scope":"global","selection":{"provider_id":"anthropic","model_id":"missing"}}),
ServiceErrorCode::InvalidSelection,
),
(
json!({"scope":"global","selection":{"provider_id":"unknown","model_id":"test"}}),
ServiceErrorCode::InvalidSelection,
),
(
json!({"scope":"global","api_key":"not-allowed"}),
ServiceErrorCode::InvalidPayload,
),
(
json!({"scope":"global","thinking_level":"unknown"}),
ServiceErrorCode::InvalidPayload,
),
] {
assert_eq!(call(&paths, "config.set", payload), Err(expected));
assert_eq!(fs::read(&paths.settings_file).unwrap(), original);
assert!(!paths.project_settings_file.exists());
}
}
#[test]
fn catalog_pages_filter_invalid_duplicate_and_secret_metadata_and_encode() {
let (_temp, paths) = fixture();
let mut entries: Vec<_> = (0..40)
.map(|i| ModelCatalogEntry::new("anthropic", format!("model-{i}")))
.collect();
entries[0].display_name = Some("Authorization: Bearer sensitive-value".into());
entries[0].description = Some("raw secret provider response".into());
entries[0].context_window = Some(100_000);
entries.push(entries[0].clone());
entries.push(ModelCatalogEntry::new("anthropic", "bad\nmodel"));
entries.push(ModelCatalogEntry::new("anthropic", "x".repeat(257)));
model_catalog::write_catalog_cache(&paths, "anthropic", &entries).unwrap();
let page = call(&paths, "catalog.models", json!({"provider_id":"anthropic"})).unwrap();
assert_eq!(page["entries"].as_array().unwrap().len(), 32);
assert_eq!(page["next_offset"], 32);
assert_eq!(page["entries"][0]["context_window"], 100_000);
assert!(!page.to_string().contains("sensitive-value"));
assert!(!page.to_string().contains("raw secret"));
let response = ServiceResponse::success(
&ServiceRequest::new("id", "catalog.models", json!({})),
ResponsePayload::Configuration(page),
);
super::super::protocol::encode_message(&ServiceMessage::response(response)).unwrap();
let last = call(
&paths,
"catalog.models",
json!({"provider_id":"anthropic","offset":32}),
)
.unwrap();
assert_eq!(last["entries"].as_array().unwrap().len(), 8);
assert!(last["next_offset"].is_null());
assert_eq!(
call(&paths, "catalog.providers", json!({"limit":33})),
Err(ServiceErrorCode::LimitExceeded)
);
}
#[test]
fn missing_corrupt_and_stale_catalogs_are_not_selectable() {
let (_temp, paths) = fixture();
assert_eq!(
call(
&paths,
"catalog.models",
json!({"provider_id":"openai-codex"})
)
.unwrap()["state"],
"unavailable"
);
let cache = model_catalog::catalog_cache_path(&paths, "anthropic").unwrap();
let mut value: Value = serde_json::from_slice(&fs::read(&cache).unwrap()).unwrap();
value["expires_at"] = json!("2000-01-01T00:00:00Z");
fs::write(&cache, serde_json::to_vec(&value).unwrap()).unwrap();
assert_eq!(
call(&paths, "catalog.models", json!({"provider_id":"anthropic"})).unwrap()["state"],
"stale"
);
assert_eq!(
call(&paths, "config.set", select("global")),
Err(ServiceErrorCode::CatalogStale)
);
fs::write(&cache, "invalid secret-bearing provider response").unwrap();
assert_eq!(
call(&paths, "config.set", select("global")),
Err(ServiceErrorCode::CatalogUnavailable)
);
assert!(!paths.settings_file.exists());
}
#[test]
fn concurrent_scoped_writes_preserve_both_updates_and_unknown_fields() {
let _env = crate::test_support::env::env_lock();
let (_temp, paths) = fixture();
fs::write(
&paths.settings_file,
r#"{"schema_version":2,"extension":{"keep":42}}"#,
)
.unwrap();
std::thread::scope(|scope| {
scope.spawn(|| {
call(&paths, "config.set", json!({"scope":"global","fast":true})).unwrap()
});
scope.spawn(|| call(&paths, "config.set", select("project")).unwrap());
});
let settings = config::read_settings(&paths).unwrap();
assert!(settings.fast.enabled);
assert_eq!(
settings.selected_model.model.as_deref(),
Some("claude-test")
);
let raw: Value = serde_json::from_slice(&fs::read(&paths.settings_file).unwrap()).unwrap();
assert_eq!(raw["extension"]["keep"], 42);
}
#[test]
fn corrupt_settings_and_failed_atomic_targets_return_fixed_errors_without_disclosure() {
let (_temp, paths) = fixture();
fs::write(&paths.settings_file, "secret invalid settings content").unwrap();
assert_eq!(
call(&paths, "config.get", json!({})),
Err(ServiceErrorCode::ConfigurationUnavailable)
);
assert_eq!(
call(&paths, "config.set", select("global")),
Err(ServiceErrorCode::SettingsWriteFailed)
);
assert_eq!(
fs::read_to_string(&paths.settings_file).unwrap(),
"secret invalid settings content"
);
fs::remove_file(&paths.settings_file).unwrap();
fs::create_dir(&paths.settings_file).unwrap();
assert_eq!(
call(&paths, "config.set", select("global")),
Err(ServiceErrorCode::SettingsWriteFailed)
);
assert!(paths.settings_file.is_dir());
}
#[test]
fn provider_pages_only_expose_bounded_ids_and_display_labels() {
let (_temp, paths) = fixture();
let mut settings = Settings::default();
for index in 0..40 {
let custom = config::make_custom_provider_config(
&format!("Provider {index}"),
"https://private-provider.invalid/v1",
"SERVICE_SECRET_KEY",
)
.unwrap();
settings
.custom_providers
.insert(format!("provider-{index}"), custom);
}
config::write_settings(&paths, &settings).unwrap();
let page = call(&paths, "catalog.providers", json!({})).unwrap();
assert_eq!(page["entries"].as_array().unwrap().len(), 32);
assert_eq!(page["next_offset"], 32);
assert_eq!(page["entries"][2]["label"], "Provider 0");
assert!(!page.to_string().contains("SERVICE_SECRET_KEY"));
assert!(!page.to_string().contains("private-provider"));
let last = call(&paths, "catalog.providers", json!({"offset":32})).unwrap();
assert_eq!(last["entries"].as_array().unwrap().len(), 10);
assert!(last["next_offset"].is_null());
}
#[test]
fn scoped_mutation_migrates_legacy_settings_only_after_validation() {
let _env = crate::test_support::env::env_lock();
let (_temp, paths) = fixture();
let original = r#"{"selected_model":{"provider":"anthropic","model":"old-model"},"extension":{"keep":true}}"#;
fs::write(&paths.settings_file, original).unwrap();
let backup = paths
.settings_file
.with_file_name("settings.json.pre-v2.bak");
assert_eq!(
call(
&paths,
"config.set",
json!({"scope":"global","selection":{"provider_id":"anthropic","model_id":"missing"}})
),
Err(ServiceErrorCode::InvalidSelection)
);
assert_eq!(fs::read_to_string(&paths.settings_file).unwrap(), original);
assert!(!backup.exists());
call(&paths, "config.set", select("global")).unwrap();
assert_eq!(fs::read_to_string(backup).unwrap(), original);
let updated: Value =
serde_json::from_slice(&fs::read(&paths.settings_file).unwrap()).unwrap();
assert_eq!(updated["schema_version"], 2);
assert_eq!(updated["extension"]["keep"], true);
assert_eq!(updated["agent"]["model"]["model"], "claude-test");
}
#[test]
fn selecting_an_inherited_value_pins_it_in_the_requested_project_scope() {
let _env = crate::test_support::env::env_lock();
let (_temp, paths) = fixture();
call(&paths, "config.set", select("global")).unwrap();
call(&paths, "config.set", select("project")).unwrap();
call(
&paths,
"config.set",
json!({"scope":"global","thinking_level":"default"}),
)
.unwrap();
assert_eq!(
config::read_settings(&paths)
.unwrap()
.selected_model
.thinking_level,
Some(ThinkingLevel::High)
);
let project: Value =
serde_json::from_slice(&fs::read(&paths.project_settings_file).unwrap()).unwrap();
assert_eq!(project["agent"]["model"]["provider"], "anthropic");
assert_eq!(project["agent"]["model"]["model"], "claude-test");
}
}