pub mod agent_role_spec;
mod agent_roles;
mod assembly;
mod builder;
pub mod cli;
mod cursor_rules;
mod env_override_apply;
mod env_overrides;
mod final_plan_requirement_mode;
mod finalize;
mod gateway_hints;
mod hot_reload;
mod load;
mod orchestration_profile;
mod scheduled_agent_task;
pub mod skills;
pub mod skills_slash;
mod source;
mod text_util;
mod types;
mod user_config_layers;
mod user_config_xdg;
mod validate;
mod web_cors;
mod workspace_roots;
mod xdg;
pub use web_cors::{DEFAULT_SHELL_CORS_ORIGINS, resolve_web_cors_allowed_origins};
pub use final_plan_requirement_mode::FinalPlanRequirementMode;
pub use gateway_hints::{
default_llm_reasoning_split_for_gateway, fold_system_into_user_for_config,
};
pub use hot_reload::apply_hot_reload_config_subset;
pub use load::{load_config, load_config_for_cli};
pub use orchestration_profile::{OrchestrationProfile, effective_orchestration_path_summary};
pub use user_config_xdg::{
ENV_SKIP_CONFIG_SEED, SYSTEM_CONFIG_DIR, cwd_has_local_user_config,
cwd_in_crabmate_source_tree, ensure_user_config_seeded_from,
ensure_user_config_seeded_from_system, resolve_default_user_config_toml_after_seed,
resolve_interactive_cli_config_path, should_resolve_xdg_user_config, system_config_toml_path,
user_config_dir, user_config_toml_path,
};
pub use xdg::{
ENV_CACHE_DIR, ENV_CONFIG_DIR, ensure_fastembed_cache_dir, ensure_user_cache_subdir,
user_cache_dir,
};
pub use finalize::{
embedded_context_summary_system, embedded_context_summary_user_template,
embedded_session_mode_appendix, embedded_thinking_avoid_echo_appendix,
};
#[allow(unused_imports)] pub use agent_role_spec::AgentRoleSpec;
#[allow(unused_imports)]
pub use types::{
AgentConfig, AgentThinkingTraceConfig, AgentToolStatsConfig, ChatQueuesCacheConfig,
CodebaseSemanticConfig, CommandExecConfig, ContextBootstrapInjectConfig, ContextPipelineConfig,
ConversationPersistenceConfig, CursorRulesConfigSection, ExposeSecret, HttpFetchConfigSection,
LlmConnectionConfig, LlmHttpAuthMode, LlmHttpRetryConfig, LlmSamplingConfig,
LlmVendorFlagsConfig, LongTermMemoryConfig, LongTermMemoryScopeMode,
LongTermMemoryVectorBackend, McpClientConfig, PerPlanPolicyConfig, PlannerExecutorMode,
RolesPromptsConfig, ScheduledAgentTask, SessionUiConfig, SessionWorkspaceChangelistConfig,
SkillsConfigSection, SyncDefaultToolSandboxMode, SyncToolSandboxConfig, ThinkingEchoConfig,
ToolCallExplainConfig, ToolRegistryPolicyConfig, ToolTranscriptConfig, TurnBudgetConfig,
WeatherToolConfig, WebApiConfig, WebSearchConfigSection, WebSearchProvider,
WorkspaceRootsConfig,
};
pub type SharedAgentConfig = std::sync::Arc<tokio::sync::RwLock<AgentConfig>>;
#[cfg(test)]
mod embedded_shard_parse_tests {
use super::assembly;
use super::builder::ConfigBuilder;
#[test]
fn malformed_embedded_toml_returns_err_naming_shard() {
let mut b = ConfigBuilder::default();
let err =
assembly::apply_embedded_agent_shard_for_test(&mut b, "test_shard.toml", "[[[not toml")
.unwrap_err();
assert!(
err.contains("test_shard.toml"),
"expected shard label in error: {err}"
);
assert!(
err.contains("嵌入默认配置"),
"expected Chinese prefix in error: {err}"
);
}
}
#[cfg(test)]
mod web_api_require_bearer_defaults_tests {
use super::load_config;
use super::load_config_test_env::without_cm_planner_executor_mode_env;
use std::fs;
use std::sync::Mutex;
static CM_WEB_API_REQUIRE_BEARER_LOCK: Mutex<()> = Mutex::new(());
fn without_cm_web_api_require_bearer_env<F, R>(f: F) -> R
where
F: FnOnce() -> R,
{
let _g = CM_WEB_API_REQUIRE_BEARER_LOCK
.lock()
.expect("web_api_require_bearer defaults tests must run serialized");
let prev = std::env::var("CM_WEB_API_REQUIRE_BEARER").ok();
unsafe {
std::env::remove_var("CM_WEB_API_REQUIRE_BEARER");
}
let out = f();
unsafe {
match prev.as_ref() {
Some(v) => std::env::set_var("CM_WEB_API_REQUIRE_BEARER", v),
None => std::env::remove_var("CM_WEB_API_REQUIRE_BEARER"),
}
}
out
}
#[test]
fn embedded_default_does_not_require_bearer_without_env_override() {
without_cm_planner_executor_mode_env(|| {
without_cm_web_api_require_bearer_env(|| {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("minimal.toml");
fs::write(
&path,
r#"[agent]
api_base = "https://api.deepseek.com/v1"
model = "deepseek-chat"
"#,
)
.expect("write");
let cfg = load_config(Some(path.to_str().unwrap())).expect("load");
assert!(
!cfg.web_api.web_api_require_bearer,
"embedded default should allow serve without forcing non-empty web_api_bearer_token"
);
assert_eq!(
cfg.web_api.web_cors_allowed_origins,
crate::cm_config::DEFAULT_SHELL_CORS_ORIGINS
.iter()
.map(|s| (*s).to_string())
.collect::<Vec<_>>(),
"embedded default should allow official Client shell Origins"
);
});
});
}
#[test]
fn explicit_true_requires_bearer_secret_at_serve() {
without_cm_planner_executor_mode_env(|| {
without_cm_web_api_require_bearer_env(|| {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("minimal.toml");
fs::write(
&path,
r#"[agent]
api_base = "https://api.deepseek.com/v1"
model = "deepseek-chat"
web_api_require_bearer = true
"#,
)
.expect("write");
let cfg = load_config(Some(path.to_str().unwrap())).expect("load");
assert!(cfg.web_api.web_api_require_bearer);
});
});
}
#[test]
fn toml_web_cors_allowed_origins_are_loaded() {
without_cm_planner_executor_mode_env(|| {
without_cm_web_api_require_bearer_env(|| {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("cors.toml");
fs::write(
&path,
r#"[agent]
api_base = "https://api.deepseek.com/v1"
model = "deepseek-chat"
web_cors_allowed_origins = ["http://127.0.0.1:8081", "https://ui.example.com"]
"#,
)
.expect("write");
let cfg = load_config(Some(path.to_str().unwrap())).expect("load");
assert_eq!(
cfg.web_api.web_cors_allowed_origins,
vec![
"http://127.0.0.1:8081".to_string(),
"https://ui.example.com".to_string(),
"tauri://localhost".to_string(),
"http://tauri.localhost".to_string(),
]
);
});
});
}
}
#[cfg(test)]
pub(crate) mod load_config_test_env {
use std::sync::Mutex;
static CM_PLANNER_EXECUTOR_MODE_LOCK: Mutex<()> = Mutex::new(());
pub(crate) fn without_cm_planner_executor_mode_env<F, R>(f: F) -> R
where
F: FnOnce() -> R,
{
let _g = CM_PLANNER_EXECUTOR_MODE_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let prev = std::env::var("CM_PLANNER_EXECUTOR_MODE").ok();
unsafe {
std::env::remove_var("CM_PLANNER_EXECUTOR_MODE");
}
struct RestoreEnv(Option<String>);
impl Drop for RestoreEnv {
fn drop(&mut self) {
unsafe {
match self.0.take() {
Some(v) => std::env::set_var("CM_PLANNER_EXECUTOR_MODE", v),
None => std::env::remove_var("CM_PLANNER_EXECUTOR_MODE"),
}
}
}
}
let _restore = RestoreEnv(prev);
f()
}
}
#[cfg(test)]
mod planner_executor_mode_load_tests {
use super::load_config;
use super::load_config_test_env::without_cm_planner_executor_mode_env;
use std::fs;
#[test]
fn load_rejects_removed_planner_executor_mode_aliases() {
without_cm_planner_executor_mode_env(|| {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("legacy_mode.toml");
fs::write(
&path,
r#"[agent]
api_base = "https://api.deepseek.com/v1"
model = "deepseek-chat"
planner_executor_mode = "hierarchical"
"#,
)
.expect("write");
let err = load_config(Some(path.to_str().unwrap())).expect_err("legacy mode must fail");
assert!(
err.contains("planner_executor_mode") || err.contains("single_agent"),
"unexpected error: {err}"
);
});
}
#[test]
fn load_accepts_explicit_single_agent() {
without_cm_planner_executor_mode_env(|| {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("ok_mode.toml");
fs::write(
&path,
r#"[agent]
api_base = "https://api.deepseek.com/v1"
model = "deepseek-chat"
planner_executor_mode = "single_agent"
"#,
)
.expect("write");
let cfg = load_config(Some(path.to_str().unwrap())).expect("load");
assert_eq!(
cfg.per_plan_policy.planner_executor_mode.as_str(),
"single_agent"
);
});
}
#[test]
fn load_rejects_removed_mode_from_env() {
without_cm_planner_executor_mode_env(|| {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("env_mode.toml");
fs::write(
&path,
r#"[agent]
api_base = "https://api.deepseek.com/v1"
model = "deepseek-chat"
"#,
)
.expect("write");
unsafe {
std::env::set_var("CM_PLANNER_EXECUTOR_MODE", "logical_dual_agent");
}
let err =
load_config(Some(path.to_str().unwrap())).expect_err("legacy env mode must fail");
assert!(
err.contains("planner_executor_mode") || err.contains("single_agent"),
"unexpected error: {err}"
);
});
}
}
#[cfg(test)]
mod llm_reasoning_split_default_tests {
use super::load_config;
use super::load_config_test_env::without_cm_planner_executor_mode_env;
use std::fs;
use std::sync::Mutex;
static CM_LLM_REASONING_SPLIT_LOCK: Mutex<()> = Mutex::new(());
fn without_cm_llm_reasoning_split_env<F, R>(f: F) -> R
where
F: FnOnce() -> R,
{
let result = {
let _guard = CM_LLM_REASONING_SPLIT_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let prev = std::env::var("CM_LLM_REASONING_SPLIT").ok();
unsafe {
std::env::remove_var("CM_LLM_REASONING_SPLIT");
}
struct RestoreEnv(Option<String>);
impl Drop for RestoreEnv {
fn drop(&mut self) {
unsafe {
match self.0.take() {
Some(v) => std::env::set_var("CM_LLM_REASONING_SPLIT", v),
None => std::env::remove_var("CM_LLM_REASONING_SPLIT"),
}
}
}
}
let _restore = RestoreEnv(prev);
let mut f_opt = Some(f);
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let fun = f_opt
.take()
.expect("without_cm_llm_reasoning_split_env closure");
fun()
}))
};
match result {
Ok(v) => v,
Err(payload) => std::panic::resume_unwind(payload),
}
}
#[test]
fn finalize_respects_omitted_reasoning_split_for_non_minimax() {
assert!(
!crate::cm_config::gateway_hints::default_llm_reasoning_split_for_gateway(
"deepseek-chat",
"https://api.deepseek.com/v1",
)
);
}
#[test]
fn minimax_user_toml_without_key_defaults_true() {
without_cm_planner_executor_mode_env(|| {
without_cm_llm_reasoning_split_env(|| {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("agent.toml");
fs::write(
&path,
r#"[agent]
api_base = "https://api.minimaxi.com/v1"
model = "MiniMax-M2.7"
"#,
)
.expect("write");
let cfg = load_config(Some(path.to_str().unwrap())).expect("load");
assert!(
cfg.llm_vendor_flags.llm_reasoning_split,
"MiniMax 网关未写 llm_reasoning_split 时应默认 true"
);
assert!(
crate::cm_config::gateway_hints::fold_system_into_user_for_config(
&cfg.llm.model,
&cfg.llm.api_base
),
"MiniMax 应自动折叠 system→user"
);
});
});
}
#[test]
fn minimax_user_toml_explicit_false() {
without_cm_planner_executor_mode_env(|| {
without_cm_llm_reasoning_split_env(|| {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("agent.toml");
fs::write(
&path,
r#"[agent]
api_base = "https://api.minimaxi.com/v1"
model = "MiniMax-M2.7"
llm_reasoning_split = false
"#,
)
.expect("write");
let cfg = load_config(Some(path.to_str().unwrap())).expect("load");
assert!(!cfg.llm_vendor_flags.llm_reasoning_split);
});
});
}
}
#[cfg(test)]
mod hot_reload_tests {
use super::ScheduledAgentTask;
use super::load_config_test_env::without_cm_planner_executor_mode_env;
use super::{apply_hot_reload_config_subset, load_config};
#[test]
fn apply_hot_reload_keeps_conversation_store_path() {
let base =
without_cm_planner_executor_mode_env(|| load_config(None).expect("default config"));
let mut dst = base.clone();
let frozen = dst
.conversation_persistence
.conversation_store_sqlite_path
.clone();
let mut src = dst.clone();
src.conversation_persistence.conversation_store_sqlite_path =
"/tmp/should_not_apply.sqlite".to_string();
apply_hot_reload_config_subset(&mut dst, &src);
assert_eq!(
dst.conversation_persistence.conversation_store_sqlite_path,
frozen
);
}
#[test]
fn apply_hot_reload_updates_sections_but_not_sqlite_path() {
let mut dst =
without_cm_planner_executor_mode_env(|| load_config(None).expect("default config"));
let frozen_store = dst
.conversation_persistence
.conversation_store_sqlite_path
.clone();
let mut src = dst.clone();
src.llm.model = "hot-reload-model-snapshot".to_string();
src.session_ui.max_message_history = src.session_ui.max_message_history.saturating_add(3);
src.weather_tool.weather_timeout_secs =
src.weather_tool.weather_timeout_secs.saturating_add(11);
src.conversation_persistence.conversation_store_sqlite_path =
"/tmp/ignored_on_hot_reload.sqlite".to_string();
apply_hot_reload_config_subset(&mut dst, &src);
assert_eq!(
dst.conversation_persistence.conversation_store_sqlite_path,
frozen_store
);
assert_eq!(dst.llm.model, src.llm.model);
assert_eq!(
dst.session_ui.max_message_history,
src.session_ui.max_message_history
);
assert_eq!(
dst.weather_tool.weather_timeout_secs,
src.weather_tool.weather_timeout_secs
);
}
#[test]
fn apply_hot_reload_updates_scheduled_tasks_from_src() {
let mut dst =
without_cm_planner_executor_mode_env(|| load_config(None).expect("default config"));
dst.conversation_persistence.scheduled_agent_tasks.clear();
let mut src = dst.clone();
src.conversation_persistence.scheduled_agent_tasks = vec![ScheduledAgentTask {
id: "cron_smoke".to_string(),
schedule: "0 0 * * * *".to_string(),
message: "hello".to_string(),
conversation_id: None,
new_conversation: false,
agent_role: None,
}];
apply_hot_reload_config_subset(&mut dst, &src);
assert_eq!(dst.conversation_persistence.scheduled_agent_tasks.len(), 1);
assert_eq!(
dst.conversation_persistence.scheduled_agent_tasks[0].id,
"cron_smoke"
);
}
}
#[cfg(test)]
mod context_budget_warning_tests {
use super::finalize::context_budget_vs_history_suspicious;
#[test]
fn suspicious_when_budget_on_and_min_ge_max_history() {
assert!(context_budget_vs_history_suspicious(8, 100_000, 8));
assert!(context_budget_vs_history_suspicious(8, 1, 10));
}
#[test]
fn not_suspicious_when_budget_off() {
assert!(!context_budget_vs_history_suspicious(8, 0, 100));
}
#[test]
fn not_suspicious_when_min_below_max_history() {
assert!(!context_budget_vs_history_suspicious(32, 50_000, 4));
}
}
#[cfg(test)]
mod numeric_validate_tests {
use super::builder::ConfigBuilder;
use super::validate;
use std::collections::HashMap;
#[test]
fn rejects_temperature_above_two() {
let mut b = ConfigBuilder::default();
b.llm_sampling.temperature = Some(3.0);
let err = validate::validate_builder_numeric_ranges(&b).unwrap_err();
assert!(err.contains("temperature"), "err: {err}");
}
#[test]
fn parallel_wall_timeout_out_of_range() {
let mut m = HashMap::new();
m.insert("http_fetch_spawn_timeout".into(), 100_000u64);
let mut b = ConfigBuilder::default();
b.tool_registry_policy
.tool_registry_parallel_wall_timeout_secs = m;
let err = validate::validate_builder_numeric_ranges(&b).unwrap_err();
assert!(err.contains("parallel_wall_timeout_secs"), "err: {err}");
}
}