use super::*;
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct TriageConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub enforce: bool,
}
impl TriageConfig {
pub fn enforces(&self) -> bool {
self.enabled && self.enforce
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct CaptureConfig {
#[serde(default = "default_rotate_at_mb")]
pub rotate_at_mb: u64,
#[serde(default = "default_keep_generations")]
pub keep_generations: u32,
}
pub(super) fn default_rotate_at_mb() -> u64 {
64
}
fn default_keep_generations() -> u32 {
5
}
impl Default for CaptureConfig {
fn default() -> Self {
Self {
rotate_at_mb: default_rotate_at_mb(),
keep_generations: default_keep_generations(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
pub struct UpdateConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub codesign_identity: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub restart_exclude: Vec<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct NotificationsConfig {
#[serde(default)]
pub desktop: bool,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct MonitorResolverConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub model: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RunsConfig {
#[serde(default = "default_heartbeat_interval_secs")]
pub heartbeat_interval_secs: u64,
#[serde(default = "default_heartbeat_stale_after_intervals")]
pub heartbeat_stale_after_intervals: u32,
}
pub(super) fn default_heartbeat_interval_secs() -> u64 {
10
}
pub(super) fn default_heartbeat_stale_after_intervals() -> u32 {
3
}
impl Default for RunsConfig {
fn default() -> Self {
Self {
heartbeat_interval_secs: default_heartbeat_interval_secs(),
heartbeat_stale_after_intervals: default_heartbeat_stale_after_intervals(),
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct ParallelJobsConfig {
#[serde(default)]
pub targets: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(default)]
pub struct SnapshotConfig {
pub poll_secs: u64,
pub request_max_age_secs: u64,
pub min_lifecycle: crate::skill::stats::LifecycleState,
}
impl Default for SnapshotConfig {
fn default() -> Self {
Self {
poll_secs: 30,
request_max_age_secs: 600,
min_lifecycle: crate::skill::stats::LifecycleState::Stable,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(default)]
pub struct MemoryConfig {
pub capture: CaptureMode,
#[serde(default = "default_memory_in_prompt")]
pub max_in_prompt: usize,
#[serde(default = "default_memory_chars")]
pub max_chars: usize,
}
fn default_memory_in_prompt() -> usize {
20
}
fn default_memory_chars() -> usize {
1500
}
impl Default for MemoryConfig {
fn default() -> Self {
Self {
capture: CaptureMode::AutoAnnounce,
max_in_prompt: default_memory_in_prompt(),
max_chars: default_memory_chars(),
}
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum CaptureMode {
Ask,
AutoAnnounce,
Off,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct FleetRunConfig {
#[serde(default)]
pub agents: Vec<String>,
#[serde(default)]
pub fleets: Vec<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct OpenItemsConfig {
#[serde(default)]
pub muted: Vec<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct FleetConfig {
#[serde(default)]
pub autorun: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CcProxyConfig {
#[serde(default = "default_cc_proxy_url")]
pub url: String,
#[serde(default = "default_true")]
pub enabled: bool,
}
fn default_cc_proxy_url() -> String {
"http://127.0.0.1:8088".to_string()
}
fn default_true() -> bool {
true
}
impl Default for CcProxyConfig {
fn default() -> Self {
Self {
url: default_cc_proxy_url(),
enabled: true,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct CliConfig {
pub skin: Option<String>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub seen_permanent_instructions_notice: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct MobileRelayConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub relay_url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub api_key: Option<String>,
}
#[cfg(test)]
mod fleet_config_tests {
use super::*;
#[test]
fn fleet_config_defaults_off_and_roundtrips() {
assert!(!FleetConfig::default().autorun);
let cfg: Config = serde_yaml_ng::from_str("fleet:\n autorun: true\n").unwrap();
assert!(cfg.fleet.autorun);
let cfg2: Config = serde_yaml_ng::from_str("{}").unwrap();
assert!(!cfg2.fleet.autorun);
}
#[test]
fn fleet_run_config_defaults_deny_all_and_roundtrips() {
let cfg: Config = serde_yaml_ng::from_str("{}").unwrap();
assert!(cfg.fleet_run.agents.is_empty());
assert!(cfg.fleet_run.fleets.is_empty());
let cfg2: Config =
serde_yaml_ng::from_str("fleet_run:\n agents: [mur]\n fleets: [deep-research]\n")
.unwrap();
assert_eq!(cfg2.fleet_run.agents, vec!["mur"]);
assert_eq!(cfg2.fleet_run.fleets, vec!["deep-research"]);
}
}
#[cfg(test)]
mod runs_config_tests {
use super::*;
#[test]
fn zero_heartbeat_values_clamp_to_defaults_at_load() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("config.yaml");
std::fs::write(
&path,
"runs:\n heartbeat_interval_secs: 0\n heartbeat_stale_after_intervals: 0\n",
)
.unwrap();
let cfg = Config::load_or_default(&path);
assert_eq!(
cfg.runs.heartbeat_interval_secs, 10,
"a zero interval must load as the default, not 0"
);
assert_eq!(
cfg.runs.heartbeat_stale_after_intervals, 3,
"a zero interval count must load as the default, not 0"
);
}
#[test]
fn positive_heartbeat_values_survive_the_load() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("config.yaml");
std::fs::write(
&path,
"runs:\n heartbeat_interval_secs: 60\n heartbeat_stale_after_intervals: 2\n",
)
.unwrap();
let cfg = Config::load_or_default(&path);
assert_eq!(cfg.runs.heartbeat_interval_secs, 60);
assert_eq!(cfg.runs.heartbeat_stale_after_intervals, 2);
}
}
#[cfg(test)]
mod ambient_capture_cfg_tests {
use super::*;
#[test]
fn session_and_harvest_defaults() {
let cfg: Config = serde_yaml::from_str("{}").unwrap();
assert_eq!(cfg.session.capture, "ambient");
assert_eq!(cfg.session.retention_days, 14);
assert!(cfg.harvest.auto_gate);
assert_eq!(cfg.harvest.llm, "local-first");
assert_eq!(cfg.harvest.min_events, 5);
assert_eq!(cfg.harvest.min_user_turns, 2);
assert_eq!(cfg.harvest.min_duration_secs, 120);
assert_eq!(cfg.harvest.idle_minutes, 30);
assert_eq!(cfg.harvest.max_llm_calls_per_day, 10);
assert_eq!(cfg.harvest.max_extract_input_tokens, 12000);
assert!(cfg.harvest.session_start_hint);
assert!((cfg.harvest.similarity_merge_threshold - 0.6).abs() < f32::EPSILON);
}
#[test]
fn session_capture_override_parses() {
let cfg: Config =
serde_yaml::from_str("session:\n capture: off\n retention_days: 3\n").unwrap();
assert_eq!(cfg.session.capture, "off");
assert_eq!(cfg.session.retention_days, 3);
}
}
#[cfg(test)]
mod cc_proxy_cfg_tests {
use super::*;
#[test]
fn defaults_to_local_cc_proxy_enabled() {
let cfg: Config = serde_yaml_ng::from_str("{}").unwrap();
assert_eq!(cfg.cc_proxy.url, "http://127.0.0.1:8088");
assert!(cfg.cc_proxy.enabled);
}
#[test]
fn url_and_enabled_override_parse() {
let cfg: Config =
serde_yaml_ng::from_str("cc_proxy:\n url: http://127.0.0.1:9999\n enabled: false\n")
.unwrap();
assert_eq!(cfg.cc_proxy.url, "http://127.0.0.1:9999");
assert!(!cfg.cc_proxy.enabled);
}
#[test]
fn partial_section_keeps_other_default() {
let cfg: Config = serde_yaml_ng::from_str("cc_proxy:\n enabled: false\n").unwrap();
assert_eq!(cfg.cc_proxy.url, "http://127.0.0.1:8088");
assert!(!cfg.cc_proxy.enabled);
}
}
#[cfg(test)]
mod notifications_config_tests {
use super::*;
#[test]
fn notifications_default_to_log_only() {
let c: Config = serde_yaml::from_str("{}").unwrap();
assert!(!c.notifications.desktop, "desktop must be opt-in");
}
#[test]
fn an_existing_config_without_the_block_still_parses() {
let c: Config = serde_yaml::from_str("retrieval:\n min_score: 0.42\n").unwrap();
assert!(!c.notifications.desktop);
}
#[test]
fn the_monitor_resolver_is_off_until_a_user_turns_it_on() {
let empty: Config = serde_yaml::from_str("{}").unwrap();
assert!(
!empty.monitor_resolver.enabled,
"asking a model must be opt-in"
);
assert!(empty.monitor_resolver.model.is_none());
let upgraded: Config = serde_yaml::from_str(
"retrieval:\n min_score: 0.42\nnotifications:\n desktop: true\n",
)
.unwrap();
assert!(
!upgraded.monitor_resolver.enabled,
"a config written before this feature existed must not enable it"
);
}
#[test]
fn the_monitor_resolver_reads_back_what_a_user_wrote() {
let c: Config =
serde_yaml::from_str("monitor_resolver:\n enabled: true\n model: claude_haiku\n")
.unwrap();
assert!(c.monitor_resolver.enabled);
assert_eq!(c.monitor_resolver.model.as_deref(), Some("claude_haiku"));
}
#[test]
fn triage_is_off_and_non_binding_by_default() {
let c: Config = serde_yaml::from_str("retrieval:\n min_score: 0.42\n").unwrap();
assert!(!c.triage.enabled, "triage must not run unasked");
assert!(!c.triage.enforce);
assert!(!c.triage.enforces());
}
#[test]
fn enforce_without_enabled_binds_nothing() {
let c: Config = serde_yaml::from_str("triage:\n enforce: true\n").unwrap();
assert!(c.triage.enforce, "the user's word is kept verbatim");
assert!(
!c.triage.enforces(),
"but it binds nothing while triage does not run"
);
}
#[test]
fn triage_binds_only_when_both_knobs_are_set() {
let c: Config =
serde_yaml::from_str("triage:\n enabled: true\n enforce: true\n").unwrap();
assert!(c.triage.enforces());
}
#[test]
fn enabled_alone_is_shadow_mode() {
let c: Config = serde_yaml::from_str("triage:\n enabled: true\n").unwrap();
assert!(c.triage.enabled);
assert!(!c.triage.enforces());
}
}