Skip to main content

agent_core/core/
config.rs

1use crate::core::shell_config::ShellConfig;
2use std::collections::BTreeMap;
3use std::path::PathBuf;
4use std::sync::OnceLock;
5
6static PROFILE_NAME: OnceLock<Option<String>> = OnceLock::new();
7static PROVIDER_KEYS: OnceLock<BTreeMap<String, String>> = OnceLock::new();
8static IDENTITY: OnceLock<String> = OnceLock::new();
9
10pub const DEFAULT_IDENTITY: &str =
11    "You are an AI assistant running in SynapsCLI, an open-source agent runtime.";
12
13thread_local! {
14    /// [`with_identity_for_tests`]'s pin; `None` everywhere else.
15    static IDENTITY_OVERRIDE: std::cell::RefCell<Option<String>> = const { std::cell::RefCell::new(None) };
16}
17
18/// Returns the configured identity string for the system prompt preamble.
19/// Falls back to `DEFAULT_IDENTITY` (the SynapsCLI identity above) if not set
20/// in config. Initialized by `load_config()` — safe to call anytime after boot.
21pub fn get_identity() -> String {
22    if let Some(pinned) = IDENTITY_OVERRIDE.with(|o| o.borrow().clone()) {
23        return pinned;
24    }
25    IDENTITY
26        .get()
27        .cloned()
28        .unwrap_or_else(|| DEFAULT_IDENTITY.to_string())
29}
30
31/// Run `f` with [`get_identity`] pinned to `identity` on this thread.
32///
33/// `IDENTITY` is a process-wide `OnceLock` latched by whichever test first
34/// calls `load_config()`, against whatever config is ambient then (on a
35/// developer machine, the real `~/.synaps-cli/config`). A test that asserts
36/// on the identity pins it here instead, so the result does not depend on
37/// test order or on HOME. Thread-scoped, so concurrent tests don't see each
38/// other's pin; code `f` hands to another thread does not see it either.
39/// Restored on unwind.
40#[doc(hidden)]
41pub fn with_identity_for_tests<R>(identity: &str, f: impl FnOnce() -> R) -> R {
42    struct Restore(Option<String>);
43    impl Drop for Restore {
44        fn drop(&mut self) {
45            let prev = self.0.take();
46            IDENTITY_OVERRIDE.with(|o| *o.borrow_mut() = prev);
47        }
48    }
49    let prev = IDENTITY_OVERRIDE.with(|o| o.borrow_mut().replace(identity.to_string()));
50    let _restore = Restore(prev);
51    f()
52}
53
54/// Provider API keys parsed from `provider.<name> = ...` lines in config.
55/// Empty if `load_config()` hasn't been called. The registry falls back to
56/// env vars, so e.g. `GROQ_API_KEY` works even with an empty map.
57pub fn get_provider_keys() -> BTreeMap<String, String> {
58    PROVIDER_KEYS.get().cloned().unwrap_or_default()
59}
60
61/// Returns the active profile name, if any.
62/// Reads from `SYNAPS_PROFILE` environment variable if not already set programmatically.
63pub fn get_profile() -> Option<String> {
64    PROFILE_NAME
65        .get_or_init(|| std::env::var("SYNAPS_PROFILE").ok())
66        .clone()
67}
68
69/// Sets the active profile name. Must be called before any `get_profile()` call
70/// (i.e., before config resolution begins). Uses OnceLock — first write wins,
71/// subsequent calls are no-ops. No env var mutation (unsafe under tokio).
72pub fn set_profile(name: Option<String>) {
73    let _ = PROFILE_NAME.set(name);
74}
75
76pub fn base_dir() -> PathBuf {
77    if let Ok(path) = std::env::var("SYNAPS_BASE_DIR") {
78        return PathBuf::from(path);
79    }
80    let home = std::env::var("HOME")
81        .or_else(|_| std::env::var("USERPROFILE"))
82        .unwrap_or_else(|_| ".".to_string());
83    PathBuf::from(home).join(".synaps-cli")
84}
85
86/// Overrides the Synaps base directory. Intended for tests and embedded harnesses.
87#[doc(hidden)]
88pub fn set_base_dir_for_tests(path: PathBuf) {
89    std::env::set_var("SYNAPS_BASE_DIR", path);
90}
91
92/// Resolves a path for reading. Checks the profile folder first, then falls back to the default folder.
93pub fn resolve_read_path(filename: &str) -> PathBuf {
94    let base = base_dir();
95
96    if let Some(profile) = get_profile() {
97        let profile_path = base.join(&profile).join(filename);
98        if profile_path.exists() {
99            return profile_path;
100        }
101    }
102
103    base.join(filename)
104}
105
106/// Resolves a path for reading with an extended arbitrary path tree.
107pub fn resolve_read_path_extended(path: &str) -> PathBuf {
108    let base = base_dir();
109
110    if let Some(profile) = get_profile() {
111        let profile_path = base.join(&profile).join(path);
112        if profile_path.exists() {
113            return profile_path;
114        }
115    }
116
117    base.join(path)
118}
119
120/// Resolves a path for writing. Unconditionally writes to the profile folder if a profile is active.
121pub fn resolve_write_path(filename: &str) -> PathBuf {
122    let mut base = base_dir();
123
124    if let Some(profile) = get_profile() {
125        base.push(profile);
126    }
127
128    let _ = std::fs::create_dir_all(&base);
129    base.join(filename)
130}
131
132/// Gets the absolute directory for the current profile (or root if default).
133pub fn get_active_config_dir() -> PathBuf {
134    let mut base = base_dir();
135    if let Some(profile) = get_profile() {
136        base.push(profile);
137    }
138    base
139}
140
141/// Automatic context management is opt-in; independent of compaction disclosure.
142#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
143pub enum ContextManagementMode {
144    #[default]
145    Off,
146    Auto,
147}
148
149impl ContextManagementMode {
150    pub fn parse(value: &str) -> Option<Self> {
151        match value.trim().to_ascii_lowercase().as_str() {
152            "off" => Some(Self::Off),
153            "auto" => Some(Self::Auto),
154            _ => None,
155        }
156    }
157
158    pub fn as_str(self) -> &'static str {
159        match self {
160            Self::Off => "off",
161            Self::Auto => "auto",
162        }
163    }
164}
165
166/// Task-aware soft context bands, parsed from `context_management.*` keys.
167///
168/// Missing thresholds use window-aware defaults (200k: 140k/180k;
169/// 1m: 250k/400k). A single override adjusts the other default as necessary
170/// to keep pressure below rollover. These settings never override the host's
171/// hard capacity or authorize tools, disclosure, or a session transition.
172#[derive(Debug, Clone, Copy, PartialEq, Eq)]
173pub struct ContextManagementConfig {
174    pub mode: ContextManagementMode,
175    /// Optional absolute token threshold; `auto` in config clears the override.
176    pub pressure_tokens: Option<u64>,
177    /// Optional absolute token threshold; `auto` in config clears the override.
178    pub rollover_tokens: Option<u64>,
179    /// Minimum remaining headroom before any model round. Default: 16,000.
180    pub reserve_tokens: u64,
181    /// Maximum extra Plan/WrapUp rounds after rollover becomes due. Default: 2.
182    /// Zero disables extensions; phase reports cannot renew this allowance.
183    pub finish_rounds: u32,
184    /// Footprint a rollover successor aims for (system prompt + tool schemas +
185    /// retained messages). `None`/`auto` = min(100,000, 2/3 of pressure).
186    /// An explicit value must be positive and below the pressure threshold.
187    pub target_tokens: Option<u64>,
188}
189
190impl Default for ContextManagementConfig {
191    fn default() -> Self {
192        Self {
193            mode: ContextManagementMode::Off,
194            pressure_tokens: None,
195            rollover_tokens: None,
196            reserve_tokens: 16_000,
197            finish_rounds: 2,
198            target_tokens: None,
199        }
200    }
201}
202
203impl ContextManagementConfig {
204    pub const MAX_FINISH_ROUNDS: u32 = 8;
205    pub const MAX_RESERVE_TOKENS: u64 = 1_000_000;
206
207    /// Validate independently of a model selection. Also call
208    /// `validate_for_window` when the effective context window is known.
209    pub fn validate(&self) -> Result<(), &'static str> {
210        if self.pressure_tokens == Some(0) {
211            return Err("pressure_tokens must be positive");
212        }
213        if self.rollover_tokens.is_some_and(|v| v < 2) {
214            return Err("rollover_tokens must be at least 2");
215        }
216        if let (Some(pressure), Some(rollover)) = (self.pressure_tokens, self.rollover_tokens) {
217            if pressure >= rollover {
218                return Err("pressure_tokens must be below rollover_tokens");
219            }
220        }
221        if self.target_tokens == Some(0) {
222            return Err("target_tokens must be positive");
223        }
224        if let (Some(target), Some(pressure)) = (self.target_tokens, self.pressure_tokens) {
225            if target >= pressure {
226                return Err("target_tokens must be below pressure_tokens");
227            }
228        }
229        if !(1..=Self::MAX_RESERVE_TOKENS).contains(&self.reserve_tokens) {
230            return Err("reserve_tokens must be between 1 and 1000000");
231        }
232        if self.finish_rounds > Self::MAX_FINISH_ROUNDS {
233            return Err("finish_rounds must be between 0 and 8");
234        }
235        Ok(())
236    }
237
238    /// Threshold overrides must fit the effective window. The hard budget
239    /// remains authoritative even when an override fits this validation.
240    pub fn validate_for_window(&self, context_window_tokens: u64) -> Result<(), &'static str> {
241        self.validate()?;
242        if context_window_tokens < 2 {
243            return Err("context window must contain at least two tokens");
244        }
245        if self
246            .pressure_tokens
247            .is_some_and(|v| v >= context_window_tokens)
248        {
249            return Err("pressure_tokens must be below the context window");
250        }
251        if self
252            .rollover_tokens
253            .is_some_and(|v| v > context_window_tokens)
254        {
255            return Err("rollover_tokens must not exceed the context window");
256        }
257        if let Some(target) = self.target_tokens {
258            let (pressure, _) =
259                crate::core::context_policy::ContextThresholds::bands(self, context_window_tokens);
260            if target >= pressure {
261                return Err("target_tokens must be below pressure_tokens");
262            }
263        }
264        Ok(())
265    }
266}
267
268/// Parse fields first; validate the complete policy after all lines so threshold
269/// ordering and an explicit context window do not depend on config key order.
270fn parse_context_management_config_key(
271    config: &mut ContextManagementConfig,
272    key: &str,
273    value: &str,
274) -> Result<(), &'static str> {
275    match key {
276        "context_management.mode" => {
277            config.mode = ContextManagementMode::parse(value).ok_or("expected off or auto")?;
278        }
279        "context_management.pressure_tokens"
280        | "context_management.rollover_tokens"
281        | "context_management.target_tokens" => {
282            let tokens = if value.eq_ignore_ascii_case("auto") {
283                None
284            } else {
285                Some(
286                    value
287                        .parse::<u64>()
288                        .map_err(|_| "expected auto or an unsigned token count")?,
289                )
290            };
291            match key {
292                "context_management.pressure_tokens" => config.pressure_tokens = tokens,
293                "context_management.rollover_tokens" => config.rollover_tokens = tokens,
294                _ => config.target_tokens = tokens,
295            }
296        }
297        "context_management.reserve_tokens" => {
298            config.reserve_tokens = value
299                .parse()
300                .map_err(|_| "expected an unsigned token count")?;
301        }
302        "context_management.finish_rounds" => {
303            config.finish_rounds = value
304                .parse()
305                .map_err(|_| "expected an unsigned round count")?;
306        }
307        _ => return Err("unknown context management key"),
308    }
309    Ok(())
310}
311
312/// Startup / first-turn behaviour (`startup.*` keys). Boot-time only — these
313/// take effect at the next launch, never mid-session.
314#[derive(Debug, Clone, Copy, PartialEq, Eq)]
315pub struct StartupConfig {
316    /// Create the session without waiting for extensions. When true the
317    /// attach path sets `await_extensions=false`: the session (and the TUI)
318    /// comes up at once, and `on_session_start` runs in the background. The
319    /// first turn starts at once too, but its model request waits until the
320    /// hook has run: for extension discovery (up to
321    /// `extensions_ready_timeout_secs`, default 30 s), then for the hook
322    /// (each handler up to 5 s). Clients are told while it waits. Default: true.
323    pub quick_start: bool,
324    /// Max seconds to wait for extension discovery before a session's
325    /// `on_session_start`: at create when Quick Start is off, before the
326    /// first turn's request when it is on. Default: 30. It does not bound
327    /// the `on_session_start` call itself, which then waits its turn at the
328    /// host's one-at-a-time gate.
329    pub extensions_ready_timeout_secs: u64,
330}
331
332impl Default for StartupConfig {
333    fn default() -> Self {
334        Self {
335            quick_start: true,
336            extensions_ready_timeout_secs: 30,
337        }
338    }
339}
340
341/// Daemon lifetime knobs (`daemon.*` keys). Config-file fallbacks; the matching
342/// `SYNAPS_DAEMON_*` env vars still WIN over these when set.
343#[derive(Debug, Clone, Copy, PartialEq, Eq)]
344pub struct DaemonConfig {
345    /// Seconds an auto-spawned daemon stays alive after the last client
346    /// disconnects. `0` = never idle-exit. Env `SYNAPS_DAEMON_IDLE_EXIT_SECS`
347    /// overrides. Default: 10.
348    pub idle_exit_secs: u64,
349    /// Seconds a pending host confirmation on a detached session survives
350    /// before it is fail-closed answered `None`. `0` = disabled (survives
351    /// forever). Env `SYNAPS_DAEMON_PROMPT_ABANDON_SECS` overrides. Default: 3600.
352    pub prompt_abandon_secs: u64,
353    /// Seconds a PARKED session row lingers before eviction. `0` = keep
354    /// forever. Env `SYNAPS_DAEMON_PARKED_EVICT_SECS` overrides. Default: 3600.
355    pub parked_evict_secs: u64,
356}
357
358impl Default for DaemonConfig {
359    fn default() -> Self {
360        Self {
361            idle_exit_secs: 10,
362            prompt_abandon_secs: 3600,
363            parked_evict_secs: 3600,
364        }
365    }
366}
367
368/// Subagent fan-out limits (`subagent.*` keys). They apply to the whole
369/// delegation tree of one session: workers started by workers count against
370/// the same limits as workers started by the foreground. A prompt manifest
371/// with an explicit delegation policy sets its own concurrency and total
372/// limits; `max_depth` applies either way.
373#[derive(Debug, Clone, Copy, PartialEq, Eq)]
374pub struct SubagentLimits {
375    /// Workers running at the same time. Default: 16.
376    pub max_concurrent: usize,
377    /// Workers dispatched and not yet collected with `reconciled=true`
378    /// (running, finished, or collected without reconciling). Default: 64.
379    pub max_total: usize,
380    /// How deep workers may nest: 1 = only the foreground delegates,
381    /// 2 = its workers may delegate once more, and so on. Default: 4.
382    pub max_depth: u16,
383}
384
385impl Default for SubagentLimits {
386    fn default() -> Self {
387        Self {
388            max_concurrent: 16,
389            max_total: 64,
390            max_depth: 4,
391        }
392    }
393}
394
395/// Parse `subagent.*` keys. Unknown keys and invalid values return an error
396/// (caller warns and keeps the default).
397fn parse_subagent_config_key(
398    config: &mut SubagentLimits,
399    key: &str,
400    value: &str,
401) -> Result<(), &'static str> {
402    let positive = || match value.trim().parse::<usize>() {
403        Ok(n) if n > 0 => Ok(n),
404        _ => Err("expected a whole number of at least 1"),
405    };
406    match key {
407        "subagent.max_concurrent" => config.max_concurrent = positive()?,
408        "subagent.max_total" => config.max_total = positive()?,
409        "subagent.max_depth" => {
410            config.max_depth =
411                u16::try_from(positive()?).map_err(|_| "expected a whole number from 1 to 65535")?
412        }
413        _ => return Err("unknown subagent key"),
414    }
415    Ok(())
416}
417
418/// Parse `startup.*` keys. Unknown keys return an error (caller warns).
419fn parse_startup_config_key(
420    config: &mut StartupConfig,
421    key: &str,
422    value: &str,
423) -> Result<(), &'static str> {
424    match key {
425        "startup.quick_start" => {
426            config.quick_start = match value.trim() {
427                "true" | "1" | "on" | "yes" => true,
428                "false" | "0" | "off" | "no" => false,
429                _ => return Err("expected on or off"),
430            };
431        }
432        "startup.extensions_ready_timeout_secs" => {
433            config.extensions_ready_timeout_secs =
434                value.trim().parse().map_err(|_| "expected an unsigned second count")?;
435        }
436        _ => return Err("unknown startup key"),
437    }
438    Ok(())
439}
440
441/// Parse `daemon.*` keys. Unknown keys return an error (caller warns).
442fn parse_daemon_config_key(
443    config: &mut DaemonConfig,
444    key: &str,
445    value: &str,
446) -> Result<(), &'static str> {
447    let secs = || value.trim().parse::<u64>().map_err(|_| "expected an unsigned second count");
448    match key {
449        "daemon.idle_exit_secs" => config.idle_exit_secs = secs()?,
450        "daemon.prompt_abandon_secs" => config.prompt_abandon_secs = secs()?,
451        "daemon.parked_evict_secs" => config.parked_evict_secs = secs()?,
452        _ => return Err("unknown daemon key"),
453    }
454    Ok(())
455}
456
457/// Server security configuration parsed from `server.*` keys.
458#[derive(Debug, Clone, Default)]
459pub struct ServerConfig {
460    /// Comma-separated list of allowed Origin headers. Empty = allow all (localhost-only protection).
461    pub allowed_origins: Vec<String>,
462    /// Pre-shared authentication token. If set, clients must provide it on WebSocket upgrade
463    /// via `?token=X` query param or `Authorization: Bearer X` header. If None, auto-generated on boot.
464    pub token: Option<String>,
465    /// When true, `HookResult::Confirm` is auto-approved without prompting (useful for headless/agent mode).
466    pub auto_approve_confirms: bool,
467    /// Maximum inbound message size in bytes. Defaults to context_window * 4 (rough token→byte estimate).
468    /// None means no artificial cap.
469    pub max_message_size: Option<usize>,
470}
471
472/// Bridge daemon configuration parsed from `bridge.*` keys.
473///
474/// Controls best-effort mirroring of watcher heartbeats over the bridge
475/// daemon's UDS `ControlSocket` (`heartbeat_emit` op). All keys are optional
476/// and the feature is OFF by default.
477#[derive(Debug, Clone)]
478pub struct BridgeConfig {
479    /// Path to the bridge daemon's UDS control socket.
480    /// When `None`, defaults to `base_dir().join("bridge/control.sock")`.
481    pub uds_path: Option<PathBuf>,
482    /// When true, every watcher heartbeat tick mirrors a `heartbeat_emit`
483    /// JSON-RPC call over the UDS socket. Errors are logged at debug only.
484    pub heartbeat_mirror: bool,
485    /// Per-call timeout in milliseconds (covers connect + write + read).
486    pub heartbeat_timeout_ms: u64,
487}
488
489impl Default for BridgeConfig {
490    fn default() -> Self {
491        Self {
492            uds_path: None,
493            heartbeat_mirror: false,
494            heartbeat_timeout_ms: 250,
495        }
496    }
497}
498
499impl BridgeConfig {
500    /// Resolve the UDS path, falling back to the default under `base_dir()`.
501    pub fn resolved_uds_path(&self) -> PathBuf {
502        self.uds_path
503            .clone()
504            .unwrap_or_else(|| base_dir().join("bridge/control.sock"))
505    }
506}
507
508/// Auth configuration parsed from `auth.*` keys. Controls whether this client
509/// resolves provider tokens from the local `auth.json` (default) or from a
510/// remote credential broker. See task #157 / `auth::credential_source`.
511#[derive(Debug, Clone, Default)]
512pub struct AuthConfig {
513    /// Broker base URL. When set (here, or via `SYNAPS_AUTH_ENDPOINT`), the
514    /// client fetches short-lived access tokens from the broker instead of
515    /// reading/refreshing the local `auth.json`.
516    pub remote_endpoint: Option<String>,
517    /// Per-machine bearer presented to the broker (or `SYNAPS_MACHINE_TOKEN`).
518    /// This is the machine's own identity, never the provider credential.
519    pub machine_token: Option<String>,
520    /// Selected OAuth account per provider from `auth.account.<provider> =
521    /// <label|default|auto>`. Keys are the raw provider ids as written;
522    /// validation (and fail-closed handling of bad labels) happens in
523    /// [`AccountPolicy::from_config_map`](crate::core::auth::AccountPolicy::from_config_map).
524    pub accounts: BTreeMap<String, String>,
525    /// Raw automatic chooser settings, relative to auth.auto.
526    pub auto: BTreeMap<String, String>,
527}
528
529impl AuthConfig {
530    /// Account policy from config only (no env overlay). Invalid entries fail
531    /// closed for their provider; see `AccountPolicy::from_config_map`.
532    pub fn account_policy(&self) -> crate::core::auth::AccountPolicy {
533        crate::core::auth::AccountPolicy::from_config_map(&self.accounts).0
534    }
535
536    /// Effective selector for one provider (env > config > default).
537    pub fn account_selector(
538        &self,
539        provider: crate::core::auth::OAuthProviderId,
540    ) -> crate::core::auth::AccountSelector {
541        self.account_policy().with_env_overlay().selector(provider)
542    }
543
544    /// Resolve the credential source. Environment variables take precedence over
545    /// config-file values: `SYNAPS_AUTH_ENDPOINT` / `SYNAPS_MACHINE_TOKEN`.
546    /// Returns `Remote` iff an endpoint is set (env or config), else `Local`.
547    ///
548    /// The machine token here is only ever the EXPLICIT one (env or config).
549    /// When none is configured it stays empty, and that emptiness is the
550    /// provenance record: the broker client ([`crate::core::auth::MachineToken`])
551    /// then uses — and may re-read after a 401 — the local broker's
552    /// auto-generated `<base_dir>/auth-broker.token`, but only for a loopback
553    /// endpoint (#147 C1, prA N1). An explicit token is never replaced by,
554    /// or sent alongside, the file token.
555    pub fn credential_source(&self) -> crate::core::auth::CredentialSource {
556        let endpoint = std::env::var("SYNAPS_AUTH_ENDPOINT")
557            .ok()
558            .filter(|s| !s.trim().is_empty())
559            .or_else(|| self.remote_endpoint.clone());
560        let machine_token = std::env::var("SYNAPS_MACHINE_TOKEN")
561            .ok()
562            .filter(|s| !s.trim().is_empty())
563            .or_else(|| self.machine_token.clone().filter(|s| !s.trim().is_empty()));
564        crate::core::auth::CredentialSource::from_parts(endpoint, machine_token)
565    }
566}
567
568/// Upper bound for the `<N>h` form of `cache_keepwarm`. The ski-rental bound
569/// already caps an abandoned session's spend at about one prefix rewrite; the
570/// hour cap is an extra, user-chosen wall on top of it.
571pub const MAX_CACHE_KEEPWARM_HOURS: u32 = 48;
572
573/// Default for `cache_keepwarm_auto_max_hours`: the longest an `auto` idle
574/// period may run. Without it the ski-rental bound alone lets a 0.05×
575/// cache-read model (Opus 5.5, Sonnet 5.5) ping ~39 times over ~36h and a
576/// 0.025× model (Fable 5.1, Mythos 5.1) ~79 times over ~72h under a 1h TTL,
577/// keeping an abandoned session unparked for days. 24h: a user who steps
578/// away for a working day or overnight still comes back to a warm cache; a
579/// session left over a weekend parks. (Owner decision, task #444.)
580pub const DEFAULT_CACHE_KEEPWARM_AUTO_MAX_HOURS: u32 = 24;
581
582/// Default for `subagent_cold_resume_compact_tokens`. Below ~100k tokens a
583/// cold full replay costs well under $1 on the priciest model and keeps every
584/// detail; above it (measured resumes reached 250k–715k tokens, up to ~$3.50
585/// each) the compact context is the cheaper trade.
586pub const DEFAULT_SUBAGENT_COLD_RESUME_COMPACT_TOKENS: u64 = 100_000;
587
588/// Default for `subagent_rollover_tokens`: the footprint at which a worker's
589/// context rolls over (to a successor of about 100k on a 1m window). Workers
590/// are long 5-minute-TTL loops with no human pauses, so every round re-reads
591/// the whole context; a 100k -> 200k cycle repays the ~100k successor write
592/// (about 17 rounds on Opus 5.5) well before the next rollover.
593pub const DEFAULT_SUBAGENT_ROLLOVER_TOKENS: u64 = 200_000;
594
595/// Smallest accepted `subagent_rollover_tokens`. Below it the derived
596/// successor target is smaller than a typical brief plus last round, so
597/// rollovers could not shrink the context and would only churn the cache.
598pub const MIN_SUBAGENT_ROLLOVER_TOKENS: u64 = 20_000;
599
600/// Prompt-cache keep-warm policy (`cache_keepwarm`, task #444). See
601/// docs/specs/cache-ttl-spec.md §7.
602///
603/// - `Off` (default): never ping.
604/// - `Auto`: ping while the measured ping spend of the current idle period
605///   stays below the cost of the cache rewrite it prevents (ski-rental rule:
606///   at most 2× the optimal spend, at most ~one rewrite on an abandoned
607///   session), and for at most `cache_keepwarm_auto_max_hours` (default
608///   [`DEFAULT_CACHE_KEEPWARM_AUTO_MAX_HOURS`]) after the session went idle.
609/// - `Hours(n)`: the same bound, and additionally stop `n` hours after the
610///   session went idle — whichever comes first.
611#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
612pub enum CacheKeepWarm {
613    #[default]
614    Off,
615    Auto,
616    Hours(u32),
617}
618
619impl CacheKeepWarm {
620    /// Parse `off` | `auto` | `<N>h` (case-insensitive; `0h` = off).
621    /// `None` for anything else, including hours above
622    /// [`MAX_CACHE_KEEPWARM_HOURS`].
623    pub fn parse(val: &str) -> Option<CacheKeepWarm> {
624        let v = val.trim().to_ascii_lowercase();
625        match v.as_str() {
626            "off" | "false" | "no" | "0" => return Some(CacheKeepWarm::Off),
627            "auto" | "on" => return Some(CacheKeepWarm::Auto),
628            _ => {}
629        }
630        let digits = v.strip_suffix('h')?;
631        match digits.parse::<u32>().ok()? {
632            0 => Some(CacheKeepWarm::Off),
633            h if h <= MAX_CACHE_KEEPWARM_HOURS => Some(CacheKeepWarm::Hours(h)),
634            _ => None,
635        }
636    }
637
638    pub fn is_off(self) -> bool {
639        self == CacheKeepWarm::Off
640    }
641}
642
643impl std::fmt::Display for CacheKeepWarm {
644    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
645        match self {
646            CacheKeepWarm::Off => f.write_str("off"),
647            CacheKeepWarm::Auto => f.write_str("auto"),
648            CacheKeepWarm::Hours(h) => write!(f, "{h}h"),
649        }
650    }
651}
652
653/// Who may drive a daemon session with several clients attached
654/// (`client_input`; spec `multi-client-sessions.md` §6). Every client is
655/// the daemon's own user.
656///
657/// - `Shared` (default): every drive client (not `--observe`) may send
658///   input; the one that did last is the *active* client.
659/// - `Exclusive`: only the input owner may; the others are read-only until
660///   they `/takeover` (the behaviour before `client_input`).
661///
662/// On the wire a missing value means `Exclusive`: an older daemon enforces
663/// the owner.
664#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
665#[serde(rename_all = "snake_case")]
666pub enum ClientInput {
667    #[default]
668    Shared,
669    Exclusive,
670}
671
672impl ClientInput {
673    /// `shared` | `exclusive` (case-insensitive).
674    pub fn parse(val: &str) -> Option<ClientInput> {
675        match val.trim().to_ascii_lowercase().as_str() {
676            "shared" => Some(ClientInput::Shared),
677            "exclusive" => Some(ClientInput::Exclusive),
678            _ => None,
679        }
680    }
681
682    /// The wire's default: what a snapshot from an older daemon means.
683    pub fn wire_default() -> ClientInput {
684        ClientInput::Exclusive
685    }
686}
687
688/// What an Anthropic thinking block carries back (`thinking_display`), sent
689/// as the request's `thinking.display` on the foreground session's requests.
690///
691/// - `Summarized` (default): the model's thinking summary streams into the
692///   block. The API holds the answer back until that summary is written,
693///   then flushes the text it generated meanwhile in one burst.
694/// - `Omitted`: the block's `thinking` text is empty; its `signature` still
695///   carries the full reasoning, so history round-trips it unchanged. The
696///   answer starts streaming sooner and evenly (measured on Opus 5.5:
697///   first text 1.8–2.1 s vs 2.9–4.8 s at `high`, 7.9–9.0 s vs 11–12 s at
698///   `xhigh`), and the TUI shows only a timed `thought` header.
699///
700/// Workers and internal requests (compaction) always send `Summarized`.
701#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
702pub enum ThinkingDisplay {
703    #[default]
704    Summarized,
705    Omitted,
706}
707
708impl ThinkingDisplay {
709    /// `summarized` | `omitted` (case-insensitive).
710    pub fn parse(val: &str) -> Option<ThinkingDisplay> {
711        match val.trim().to_ascii_lowercase().as_str() {
712            "summarized" => Some(ThinkingDisplay::Summarized),
713            "omitted" => Some(ThinkingDisplay::Omitted),
714            _ => None,
715        }
716    }
717
718    /// The wire value of `thinking.display`.
719    pub fn as_str(self) -> &'static str {
720        match self {
721            ThinkingDisplay::Summarized => "summarized",
722            ThinkingDisplay::Omitted => "omitted",
723        }
724    }
725}
726
727impl std::fmt::Display for ThinkingDisplay {
728    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
729        f.write_str(self.as_str())
730    }
731}
732
733impl std::fmt::Display for ClientInput {
734    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
735        f.write_str(match self {
736            ClientInput::Shared => "shared",
737            ClientInput::Exclusive => "exclusive",
738        })
739    }
740}
741
742/// Prompt-cache TTL strategy for Anthropic requests.
743///
744/// Controls the `cache_control` value emitted at every cache marker site:
745/// - `FiveMinutes` (default): bare `{"type": "ephemeral"}` — byte-identical
746///   to historical payloads; the default path can never invalidate existing
747///   cached prefixes.
748/// - `OneHour`: `{"type": "ephemeral", "ttl": "1h"}` on all markers.
749/// - `Hybrid`: 1h on the stable prefix (tools + system, written rarely),
750///   bare 5m on the message-tail marker (written every turn).
751#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
752pub enum CacheTtl {
753    #[default]
754    FiveMinutes,
755    OneHour,
756    Hybrid,
757}
758
759impl CacheTtl {
760    /// Parse a config value (case-insensitive). Returns `None` for unknown
761    /// values so the caller can warn and fall back to the default.
762    pub fn parse(val: &str) -> Option<CacheTtl> {
763        match val.to_ascii_lowercase().as_str() {
764            "5m" | "5min" | "default" => Some(CacheTtl::FiveMinutes),
765            "1h" | "60m" | "1hr" => Some(CacheTtl::OneHour),
766            "hybrid" => Some(CacheTtl::Hybrid),
767            _ => None,
768        }
769    }
770
771    /// Canonical config spelling (`5m` | `1h` | `hybrid`).
772    pub fn as_str(self) -> &'static str {
773        match self {
774            CacheTtl::FiveMinutes => "5m",
775            CacheTtl::OneHour => "1h",
776            CacheTtl::Hybrid => "hybrid",
777        }
778    }
779}
780
781impl std::fmt::Display for CacheTtl {
782    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
783        f.write_str(self.as_str())
784    }
785}
786
787/// The `cache_ttl` config value: either a fixed [`CacheTtl`] or `auto`.
788///
789/// `Auto` (default) resolves per credential *kind* at request time:
790/// - Anthropic OAuth / subscription credentials → [`CacheTtl::OneHour`]
791///   (what Claude Code uses on subscriptions; a subscription pays no
792///   per-token premium for 1h writes, and idle gaps over five minutes
793///   otherwise lose the conversation cache every turn).
794/// - Anthropic API key → [`CacheTtl::FiveMinutes`] (1h writes bill at 2×
795///   input versus 1.25× for 5m, so API-key users keep the cheaper write).
796/// - Other providers ignore `cache_ttl` entirely.
797///
798/// Resolution depends only on the credential kind, never on which account
799/// or token is in use, so account failover between two subscription seats
800/// can never flip the TTL (a TTL change rewrites the cache).
801#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
802pub enum CacheTtlSetting {
803    #[default]
804    Auto,
805    Fixed(CacheTtl),
806}
807
808impl CacheTtlSetting {
809    /// `auto` or any [`CacheTtl::parse`] spelling (case-insensitive).
810    /// `None` for unknown values.
811    pub fn parse(val: &str) -> Option<CacheTtlSetting> {
812        let v = val.trim();
813        if v.eq_ignore_ascii_case("auto") {
814            return Some(CacheTtlSetting::Auto);
815        }
816        CacheTtl::parse(v).map(CacheTtlSetting::Fixed)
817    }
818
819    /// Canonical config spelling (`auto` | `5m` | `1h` | `hybrid`).
820    pub fn as_str(self) -> &'static str {
821        match self {
822            CacheTtlSetting::Auto => "auto",
823            CacheTtlSetting::Fixed(ttl) => ttl.as_str(),
824        }
825    }
826
827    /// The wire policy for a request carried on Anthropic credentials of
828    /// the given kind (`oauth` = subscription / OAuth access token, else an
829    /// API key). Explicit values always win.
830    pub fn resolve(self, oauth: bool) -> CacheTtl {
831        match self {
832            CacheTtlSetting::Fixed(ttl) => ttl,
833            CacheTtlSetting::Auto if oauth => CacheTtl::OneHour,
834            CacheTtlSetting::Auto => CacheTtl::FiveMinutes,
835        }
836    }
837
838    /// [`Self::resolve`] keyed by the runtime's `auth_type` string. Only the
839    /// exact `"oauth"` kind counts as a subscription credential.
840    pub fn resolve_for_auth_type(self, auth_type: &str) -> CacheTtl {
841        self.resolve(auth_type == "oauth")
842    }
843}
844
845impl From<CacheTtl> for CacheTtlSetting {
846    fn from(ttl: CacheTtl) -> Self {
847        CacheTtlSetting::Fixed(ttl)
848    }
849}
850
851impl std::fmt::Display for CacheTtlSetting {
852    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
853        f.write_str(self.as_str())
854    }
855}
856
857/// gzip `Content-Encoding` for Anthropic request bodies
858/// (`request_compression`). Every request re-uploads the whole
859/// conversation, and the JSON compresses about 2x.
860/// - `Auto` (default): bodies of 16 KiB or more sent straight to
861///   api.anthropic.com; skipped behind an HTTPS proxy, with a custom CA
862///   bundle or a custom base URL, where a middlebox or gateway may not
863///   handle a compressed body.
864/// - `On`: those too, for a proxy or gateway known to pass gzip request
865///   bodies. A refused body is still resent uncompressed.
866/// - `Off`: never.
867///
868/// `SYNAPS_GZIP_REQUEST_BODIES=0` turns it off whatever this says.
869#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
870pub enum RequestCompressionMode {
871    #[default]
872    Auto,
873    On,
874    Off,
875}
876
877impl RequestCompressionMode {
878    /// `auto` | `on` | `off` (case-insensitive).
879    pub fn parse(val: &str) -> Option<RequestCompressionMode> {
880        match val.trim().to_ascii_lowercase().as_str() {
881            "auto" => Some(RequestCompressionMode::Auto),
882            "on" => Some(RequestCompressionMode::On),
883            "off" => Some(RequestCompressionMode::Off),
884            _ => None,
885        }
886    }
887}
888
889impl std::fmt::Display for RequestCompressionMode {
890    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
891        f.write_str(match self {
892            RequestCompressionMode::Auto => "auto",
893            RequestCompressionMode::On => "on",
894            RequestCompressionMode::Off => "off",
895        })
896    }
897}
898
899/// Default gzip level for request bodies (`request_compression_level`):
900/// fast enough to pay off up to roughly gigabit uplinks.
901pub const DEFAULT_REQUEST_COMPRESSION_LEVEL: u32 = 1;
902/// Highest gzip level. Levels 3-6 pay off on slow uplinks, where the extra
903/// CPU time is tens of milliseconds and the upload saved is seconds; above
904/// 6 the body barely shrinks further.
905pub const MAX_REQUEST_COMPRESSION_LEVEL: u32 = 9;
906
907/// Default for `tool_output_spill_bytes`: a tool result bigger than this is
908/// stored whole in a spill file and model history gets a head + tail
909/// preview pointing at it. 12 KiB is about 3k tokens. Below it the preview
910/// (head + tail + pointer, ~7.3 KiB) saves too little to be worth a second
911/// look when the model does need the middle; above it every result saves at
912/// least ~4.5 KiB on every later request, and the 22-26 KB results that
913/// were re-read hundreds of times shrink by ~70%.
914pub const DEFAULT_TOOL_OUTPUT_SPILL_BYTES: usize = 12 * 1024;
915/// Default for `tool_output_spill_head_bytes`: the start of a virtualised
916/// result kept in history (the command echo, headers, the first errors).
917pub const DEFAULT_TOOL_OUTPUT_SPILL_HEAD_BYTES: usize = 4 * 1024;
918/// Default for `tool_output_spill_tail_bytes`: the end of a virtualised
919/// result kept in history (exit status, summaries, the last errors).
920pub const DEFAULT_TOOL_OUTPUT_SPILL_TAIL_BYTES: usize = 3 * 1024;
921
922/// `tools.activation_confirm` — host policy for model-initiated
923/// `activate_tools` (progressive tool disclosure).
924#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
925pub enum ActivationConfirm {
926    /// Authorize model-initiated activation without prompting (default).
927    #[default]
928    Auto,
929    /// Ask the host: the y/n "Confirm tool activation" dialog; only an
930    /// explicit y/yes authorizes (fail-closed).
931    Prompt,
932    /// Never authorize model-initiated activation; no prompt is raised.
933    Deny,
934}
935
936impl ActivationConfirm {
937    pub fn parse(val: &str) -> Option<Self> {
938        match val.trim().to_ascii_lowercase().as_str() {
939            "auto" => Some(Self::Auto),
940            "prompt" => Some(Self::Prompt),
941            "deny" => Some(Self::Deny),
942            _ => None,
943        }
944    }
945
946    pub fn as_str(self) -> &'static str {
947        match self {
948            Self::Auto => "auto",
949            Self::Prompt => "prompt",
950            Self::Deny => "deny",
951        }
952    }
953}
954
955/// Animated theme-transition mode, parsed from `theme_transition`:
956/// - `"on"` (default): cross-fade theme changes over the default 350 ms.
957/// - `"off"`: instant snap — accessibility, and mercy on tmux-over-ssh.
958/// - integer milliseconds: cross-fade over that duration (clamped to
959///   0–2000 ms; `0` behaves like `off`).
960///
961/// Purely a TUI presentation knob: it changes *how* a new palette is
962/// applied, never *which* palette wins.
963#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
964pub enum ThemeTransitionMode {
965    #[default]
966    On,
967    Off,
968    Ms(u64),
969}
970
971impl ThemeTransitionMode {
972    /// Default cross-fade duration for `On` (and the fallback when a live
973    /// palette update carries no advisory duration of its own).
974    pub const DEFAULT_MS: u64 = 350;
975    /// Duration ceiling — both this knob and MXC wire `fade_ms` clamp here.
976    pub const MAX_MS: u64 = 2000;
977
978    /// Parse a config value (case-insensitive). Returns `None` for unknown
979    /// values so the caller can warn and fall back to the default.
980    pub fn parse(val: &str) -> Option<ThemeTransitionMode> {
981        match val.to_ascii_lowercase().as_str() {
982            "on" | "true" | "yes" => Some(ThemeTransitionMode::On),
983            "off" | "false" | "no" => Some(ThemeTransitionMode::Off),
984            other => other
985                .parse::<u64>()
986                .ok()
987                .map(|ms| ThemeTransitionMode::Ms(ms.min(Self::MAX_MS))),
988        }
989    }
990
991    /// Effective default duration in milliseconds; `0` means "snap".
992    pub fn duration_ms(self) -> u64 {
993        match self {
994            ThemeTransitionMode::On => Self::DEFAULT_MS,
995            ThemeTransitionMode::Off => 0,
996            ThemeTransitionMode::Ms(ms) => ms.min(Self::MAX_MS),
997        }
998    }
999}
1000
1001/// Runtime event routing configuration parsed from `events.*` keys.
1002///
1003/// Controls how runtime events (from the `EventQueue`) are delivered in
1004/// server and RPC modes.
1005#[derive(Debug, Clone)]
1006pub struct EventsConfig {
1007    /// When `true` (default), the server/RPC/daemon session automatically
1008    /// triggers a model turn when runtime events arrive while idle.  Set
1009    /// `events.auto_turn = false` (or `0` / `no` / `off`) to opt out.
1010    /// Unrecognised values fail safe to `false` with a warning.
1011    /// The consecutive-turn cap (`auto_turn_cap`) still applies regardless.
1012    pub auto_turn: bool,
1013    /// Maximum number of consecutive auto-triggered model turns before the
1014    /// engine parks and waits for real user input.  Set via
1015    /// `events.auto_turn_cap = N`.  Default **5**.
1016    ///
1017    /// `0` (or `unlimited` / `inf` / `infinite`) disables the cap entirely —
1018    /// "to infinity and beyond".  Unparseable values warn and keep the default.
1019    pub auto_turn_cap: u32,
1020}
1021
1022/// Default for `events.auto_turn_cap` — mirrors `engine::reactor::AUTO_TURN_CAP`.
1023pub const DEFAULT_AUTO_TURN_CAP: u32 = 5;
1024
1025impl Default for EventsConfig {
1026    fn default() -> Self {
1027        Self {
1028            auto_turn: true,
1029            auto_turn_cap: DEFAULT_AUTO_TURN_CAP,
1030        }
1031    }
1032}
1033
1034/// Parse a `events.auto_turn_cap` value.  Returns `None` on garbage.
1035///
1036/// Accepts a non-negative integer (`0` = unlimited) or one of the aliases
1037/// `unlimited` / `inf` / `infinite` / `infinity` / `none` (all → `0`).
1038pub fn parse_auto_turn_cap(val: &str) -> Option<u32> {
1039    let normalised = val.trim().to_lowercase();
1040    match normalised.as_str() {
1041        "unlimited" | "inf" | "infinite" | "infinity" | "none" => Some(0),
1042        n => n.parse::<u32>().ok(),
1043    }
1044}
1045
1046/// Parse `events.*` configuration keys. `Err` carries the diagnostic the
1047/// caller prints (stderr, unchanged); the value side effects are the same
1048/// either way (an unrecognised `auto_turn` still fails safe to `false`).
1049fn parse_events_config_key(cfg: &mut EventsConfig, key: &str, val: &str) -> Result<(), String> {
1050    if key == "events.auto_turn" {
1051        let normalised = val.trim().to_lowercase();
1052        match normalised.as_str() {
1053            // Explicit true values.
1054            "true" | "1" | "yes" | "on" => cfg.auto_turn = true,
1055            // Explicit false values.
1056            "false" | "0" | "no" | "off" => cfg.auto_turn = false,
1057            // Unrecognised: fail safe to false and warn so the user knows.
1058            other => {
1059                cfg.auto_turn = false;
1060                return Err(format!(
1061                    "warning: config: unrecognised value for events.auto_turn = {:?}; \
1062                     expected true/false/yes/no/on/off/1/0 — defaulting to false",
1063                    other
1064                ));
1065            }
1066        }
1067    } else if key == "events.auto_turn_cap" {
1068        match parse_auto_turn_cap(val) {
1069            Some(cap) => cfg.auto_turn_cap = cap,
1070            None => {
1071                return Err(format!(
1072                    "warning: config: unrecognised value for events.auto_turn_cap = {:?}; \
1073                     expected a non-negative integer or unlimited/inf/infinite (0 = unlimited) \
1074                     — keeping default {}",
1075                    val.trim(),
1076                    DEFAULT_AUTO_TURN_CAP
1077                ));
1078            }
1079        }
1080    } // unknown events.* keys ignored
1081    Ok(())
1082}
1083
1084/// A configured per-turn count limit: a number, or `unlimited`.
1085///
1086/// Used for `turn_budget.<role>.max_provider_rounds`, `max_tool_calls` and
1087/// `max_accumulated_tool_result_bytes`. `Max(0)` means "none allowed" (the
1088/// 0.10.2 meaning of `0`); the parser still accepts `0` for one release, with
1089/// a deprecation warning, and will reject it later.
1090#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1091pub enum TurnLimit<T> {
1092    Max(T),
1093    Unlimited,
1094}
1095
1096impl<T> TurnLimit<T> {
1097    /// The limit, or `None` for no limit.
1098    pub fn limit(self) -> Option<T> {
1099        match self {
1100            TurnLimit::Max(v) => Some(v),
1101            TurnLimit::Unlimited => None,
1102        }
1103    }
1104
1105    /// Convert the limit value, keeping `Unlimited`.
1106    pub fn map<U>(self, f: impl FnOnce(T) -> U) -> TurnLimit<U> {
1107        match self {
1108            TurnLimit::Max(v) => TurnLimit::Max(f(v)),
1109            TurnLimit::Unlimited => TurnLimit::Unlimited,
1110        }
1111    }
1112}
1113
1114/// Typed per-role turn-budget overrides (Task 23, spec §8.1). Every field
1115/// is optional: unset fields keep the role's compiled default. Values are
1116/// parsed from `turn_budget.<role>.<field>` config keys.
1117#[derive(Debug, Clone, Default, PartialEq)]
1118pub struct TurnBudgetOverrides {
1119    /// Provider rounds per turn: N or `unlimited`. Unset = the role default
1120    /// (foreground unlimited, autonomous 24, worker 64 per allowance). `0` =
1121    /// none allowed (deprecated).
1122    pub max_provider_rounds: Option<TurnLimit<u32>>,
1123    /// Tool calls per turn: N or `unlimited`. Unset = the role default
1124    /// (autonomous 96, otherwise unlimited). `0` = none allowed (deprecated).
1125    pub max_tool_calls: Option<TurnLimit<u32>>,
1126    /// Wall-clock limit for one turn, in seconds. Unset = no limit (the
1127    /// default for every role); `0` also means no limit.
1128    pub max_elapsed_secs: Option<u64>,
1129    /// Accumulated tool-result bytes per turn: N or `unlimited`. Unset = the
1130    /// role default (autonomous 8 MiB, otherwise unlimited). `0` = none
1131    /// allowed (deprecated).
1132    pub max_accumulated_tool_result_bytes: Option<TurnLimit<usize>>,
1133    pub max_context_tokens: Option<u64>,
1134    pub max_cost_usd: Option<f64>,
1135    /// Bounded auto-renewals of the provider-round allowance after it is
1136    /// exhausted mid-turn (spec §8.1). Each renewal resets the round counter;
1137    /// round renewals never reset wall-clock (durable context successors do). `0`
1138    /// disables graceful continuation (hard stop at the first exhaustion).
1139    pub max_round_renewals: Option<u32>,
1140}
1141
1142/// Per-role turn budgets (foreground, autonomous/watcher, worker).
1143#[derive(Debug, Clone, Default, PartialEq)]
1144pub struct TurnBudgetsConfig {
1145    pub foreground: TurnBudgetOverrides,
1146    pub autonomous: TurnBudgetOverrides,
1147    /// Turns a session starts by itself because events arrived.
1148    pub event: TurnBudgetOverrides,
1149    pub worker: TurnBudgetOverrides,
1150}
1151
1152/// Parse `turn_budget.<role>.<field>` keys. Invalid values warn and keep
1153/// the default (parity with shell.* parsing). `Err` carries the stderr
1154/// diagnostic. `Ok(Some(warning))`: applied, with a deprecation warning for
1155/// `config.warnings` (shown at TUI startup, in watcher logs and on stderr
1156/// for headless hosts).
1157fn parse_turn_budget_config_key(
1158    budgets: &mut TurnBudgetsConfig,
1159    key: &str,
1160    val: &str,
1161) -> Result<Option<String>, String> {
1162    let Some(rest) = key.strip_prefix("turn_budget.") else {
1163        return Ok(None);
1164    };
1165    let Some((role, field)) = rest.split_once('.') else {
1166        return Err(format!(
1167            "Warning: invalid turn_budget key '{key}' (expected turn_budget.<role>.<field>)"
1168        ));
1169    };
1170    let overrides = match role {
1171        "foreground" => &mut budgets.foreground,
1172        "autonomous" => &mut budgets.autonomous,
1173        "event" => &mut budgets.event,
1174        "worker" => &mut budgets.worker,
1175        other => {
1176            return Err(format!(
1177                "Warning: unknown turn_budget role '{other}' (expected foreground|autonomous|event|worker)"
1178            ));
1179        }
1180    };
1181    macro_rules! set {
1182        ($slot:expr, $ty:ty) => {
1183            match val.parse::<$ty>() {
1184                Ok(parsed) => $slot = Some(parsed),
1185                Err(_) => {
1186                    return Err(format!(
1187                        "Warning: invalid value for {key}: '{val}', using default"
1188                    ))
1189                }
1190            }
1191        };
1192    }
1193    // Count limits: a number or `unlimited`. `0` keeps its 0.10.2 meaning
1194    // ("none allowed") for one release, with a deprecation warning.
1195    let mut deprecation = None;
1196    macro_rules! set_limit {
1197        ($slot:expr, $ty:ty) => {
1198            if val.trim_matches('"').eq_ignore_ascii_case("unlimited") {
1199                $slot = Some(TurnLimit::Unlimited);
1200            } else {
1201                match val.parse::<$ty>() {
1202                    Ok(0) => {
1203                        $slot = Some(TurnLimit::Max(0));
1204                        deprecation = Some(format!(
1205                            "{key} = 0 means \"none allowed\". `0` will be rejected in a \
1206                             future release; use `unlimited` for no limit, or a positive number"
1207                        ));
1208                    }
1209                    Ok(parsed) => $slot = Some(TurnLimit::Max(parsed)),
1210                    Err(_) => {
1211                        return Err(format!(
1212                            "Warning: invalid value for {key}: '{val}' (expected a positive \
1213                             number or unlimited), using default"
1214                        ))
1215                    }
1216                }
1217            }
1218        };
1219    }
1220    match field {
1221        "max_provider_rounds" => set_limit!(overrides.max_provider_rounds, u32),
1222        "max_tool_calls" => set_limit!(overrides.max_tool_calls, u32),
1223        "max_elapsed_secs" => set!(overrides.max_elapsed_secs, u64),
1224        "max_accumulated_tool_result_bytes" => {
1225            set_limit!(overrides.max_accumulated_tool_result_bytes, usize)
1226        }
1227        "max_context_tokens" => set!(overrides.max_context_tokens, u64),
1228        "max_cost_usd" => set!(overrides.max_cost_usd, f64),
1229        "max_round_renewals" => set!(overrides.max_round_renewals, u32),
1230        other => {
1231            return Err(format!("Warning: unknown turn_budget field '{other}'"))
1232        }
1233    }
1234    Ok(deprecation)
1235}
1236
1237/// Continuous-memory settings surface (Task A2, spec §12). Every field is
1238/// fail-closed: unknown or invalid values warn and keep the compiled default.
1239///
1240/// This struct contains continuous-memory policy; backend selection lives in
1241/// the separate `MemoryBackendConfig`.
1242/// Secret handling, retention, and project-scope rules are deliberately NOT
1243/// fields here — they are not configurable at all (spec §12: "Secret,
1244/// retention, and project rules are not configurable to fail open").
1245#[derive(Debug, Clone, PartialEq)]
1246pub struct MemoryConfig {
1247    /// Durable default memory mode. "off" unless the operator explicitly
1248    /// consents via `memory.default_mode_confirmed = true` in the same
1249    /// config parse pass (spec §12 operator-consent rule). Known values:
1250    /// off | recall_once | recall_each_prompt | capture_only |
1251    /// capture_and_recall (spec §6.1).
1252    pub default_mode: String,
1253    /// Operator-consent latch for `default_mode` (spec §12). Must be set to
1254    /// `true` in the same parse pass for a non-"off" `default_mode` to take
1255    /// effect; otherwise `default_mode` reverts to "off" with a warning.
1256    pub default_mode_confirmed: bool,
1257    /// Max records surfaced per recall (spec §10.3 budget).
1258    pub recall_max_records: u32,
1259    /// Max tokens surfaced per recall (spec §10.3 budget).
1260    pub recall_max_tokens: u32,
1261    /// Recall time budget in milliseconds (spec §16).
1262    pub recall_timeout_ms: u64,
1263    /// Tool-result capture policy: off | summary_only (spec §11).
1264    pub capture_tools: String,
1265    /// Capture assistant turns (spec §8.2).
1266    pub capture_assistant: bool,
1267    /// Capture user turns (spec §8.2).
1268    pub capture_user: bool,
1269    /// Background consolidation: off | on (spec §11.3, explicit opt-in).
1270    pub auto_consolidate: String,
1271    /// Local embedding models: off | on (spec §11.2, never implicit).
1272    pub local_embeddings: String,
1273    /// GLiNER entity extraction: off | on (spec §11.2, never implicit).
1274    pub gliner: String,
1275}
1276
1277impl Default for MemoryConfig {
1278    fn default() -> Self {
1279        Self {
1280            default_mode: "off".to_string(),
1281            default_mode_confirmed: false,
1282            recall_max_records: 8,
1283            recall_max_tokens: 4096,
1284            recall_timeout_ms: 150,
1285            capture_tools: "summary_only".to_string(),
1286            capture_assistant: true,
1287            capture_user: true,
1288            auto_consolidate: "off".to_string(),
1289            local_embeddings: "off".to_string(),
1290            gliner: "off".to_string(),
1291        }
1292    }
1293}
1294
1295/// Memory mode strings accepted by `memory.default_mode` (spec §6.1,
1296/// snake_case of `MemoryContextMode`). Anything else fails closed to the
1297/// current value — `default_mode` can never silently become a non-off mode
1298/// through an unrecognised string.
1299const KNOWN_MEMORY_MODES: &[&str] = &[
1300    "off",
1301    "recall_once",
1302    "recall_each_prompt",
1303    "capture_only",
1304    "capture_and_recall",
1305];
1306
1307/// Parse `memory.<field>` keys (Task A2, spec §12). Invalid values warn and
1308/// keep the default (parity with turn_budget.* parsing) — never fail open.
1309/// `Err` carries the diagnostic the caller prints.
1310fn parse_memory_config_key(memory: &mut MemoryConfig, key: &str, val: &str) -> Result<(), String> {
1311    let Some(field) = key.strip_prefix("memory.") else {
1312        return Ok(());
1313    };
1314    /// Strict boolean: recognised forms parse; anything else warns and
1315    /// keeps the current (default) value.
1316    fn parse_bool(slot: &mut bool, key: &str, val: &str) -> Result<(), String> {
1317        match val {
1318            "true" | "1" | "yes" | "on" => *slot = true,
1319            "false" | "0" | "no" | "off" => *slot = false,
1320            other => {
1321                return Err(format!(
1322                    "Warning: invalid value for {key}: '{other}', expected true/false — using default"
1323                ))
1324            }
1325        }
1326        Ok(())
1327    }
1328    /// Closed string-enum: value must be in `allowed`, else warn and keep
1329    /// the current (default) value.
1330    fn parse_enum(slot: &mut String, key: &str, val: &str, allowed: &[&str]) -> Result<(), String> {
1331        if allowed.contains(&val) {
1332            *slot = val.to_string();
1333            Ok(())
1334        } else {
1335            Err(format!(
1336                "Warning: invalid value for {key}: '{val}', expected one of {} — using default",
1337                allowed.join("|")
1338            ))
1339        }
1340    }
1341    macro_rules! set_num {
1342        ($slot:expr, $ty:ty) => {
1343            match val.parse::<$ty>() {
1344                Ok(parsed) => {
1345                    $slot = parsed;
1346                    Ok(())
1347                }
1348                Err(_) => Err(format!("Warning: invalid value for {key}: '{val}', using default")),
1349            }
1350        };
1351    }
1352    match field {
1353        "default_mode" => parse_enum(&mut memory.default_mode, key, val, KNOWN_MEMORY_MODES),
1354        "default_mode_confirmed" => parse_bool(&mut memory.default_mode_confirmed, key, val),
1355        "recall_max_records" => set_num!(memory.recall_max_records, u32),
1356        "recall_max_tokens" => set_num!(memory.recall_max_tokens, u32),
1357        "recall_timeout_ms" => set_num!(memory.recall_timeout_ms, u64),
1358        "capture_tools" => parse_enum(
1359            &mut memory.capture_tools,
1360            key,
1361            val,
1362            &["off", "summary_only"],
1363        ),
1364        "capture_assistant" => parse_bool(&mut memory.capture_assistant, key, val),
1365        "capture_user" => parse_bool(&mut memory.capture_user, key, val),
1366        "auto_consolidate" => parse_enum(&mut memory.auto_consolidate, key, val, &["off", "on"]),
1367        "local_embeddings" => parse_enum(&mut memory.local_embeddings, key, val, &["off", "on"]),
1368        "gliner" => parse_enum(&mut memory.gliner, key, val, &["off", "on"]),
1369        other => Err(format!("Warning: unknown memory field '{other}'")),
1370    }
1371}
1372
1373/// Host-owned note storage selection, independent of continuous-memory consent.
1374#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1375pub enum MemoryBackendKind {
1376    #[default]
1377    Legacy,
1378    Axel,
1379    /// Invalid configuration must never select legacy storage implicitly.
1380    Unavailable,
1381}
1382
1383#[derive(Debug, Clone, PartialEq, Eq, Default)]
1384pub struct MemoryBackendConfig {
1385    pub kind: MemoryBackendKind,
1386    /// Explicit absolute executable path; never resolved through PATH.
1387    pub executable: Option<PathBuf>,
1388    /// Optional absolute shared Axel brain path; absent uses the private user default.
1389    pub brain: Option<PathBuf>,
1390    /// Explicit host opt-in to user-wide notes. Never grants capture/history consent.
1391    pub user_scope: bool,
1392}
1393
1394/// Parsed configuration from the config file.
1395#[derive(Debug, Clone)]
1396pub struct SynapsConfig {
1397    pub model: Option<String>,
1398    pub thinking_budget: Option<u32>,
1399    /// Named reasoning level, parsed from the same `thinking = …` key.
1400    /// When `Some`, this is the authoritative level. When `None`, fall back
1401    /// to `thinking_budget` for legacy numeric-only values.
1402    pub thinking_level: Option<crate::core::reasoning::ReasoningLevel>,
1403    pub context_window: Option<u64>, // override auto-detected context window (tokens)
1404    pub context_management: ContextManagementConfig,
1405    /// First-turn / cold-start behaviour (`startup.*`).
1406    pub startup: StartupConfig,
1407    /// Daemon lifetime knobs (`daemon.*`); env vars still win.
1408    pub daemon: DaemonConfig,
1409    /// Subagent fan-out limits (`subagent.*`).
1410    pub subagent: SubagentLimits,
1411    pub compaction_model: Option<String>, // model used for /compact (default: claude-sonnet-4-6)
1412    /// Where compaction summarization runs (spec §9.4): remote provider or
1413    /// local-only (zero network construction).
1414    pub compaction_mode: crate::core::compaction::CompactionMode,
1415    /// Content classes excluded from remote compaction disclosure.
1416    pub compaction_exclude: Vec<crate::core::compaction::ContentClass>,
1417    pub max_tool_output: usize,  // default 30000
1418    /// Tool output virtualisation: a text tool result bigger than this many
1419    /// bytes is stored whole in a spill file (`<base>/spill/<session>/`) and
1420    /// model history gets a head + tail preview with a pointer to it. `0`
1421    /// turns it off: results go into history exactly as before (cut at
1422    /// `max_tool_output`). (`tool_output_spill_bytes`, default
1423    /// [`DEFAULT_TOOL_OUTPUT_SPILL_BYTES`].)
1424    pub tool_output_spill_bytes: usize,
1425    /// Bytes from the start of a virtualised result kept in history
1426    /// (`tool_output_spill_head_bytes`, default
1427    /// [`DEFAULT_TOOL_OUTPUT_SPILL_HEAD_BYTES`]).
1428    pub tool_output_spill_head_bytes: usize,
1429    /// Bytes from the end of a virtualised result kept in history
1430    /// (`tool_output_spill_tail_bytes`, default
1431    /// [`DEFAULT_TOOL_OUTPUT_SPILL_TAIL_BYTES`]).
1432    pub tool_output_spill_tail_bytes: usize,
1433    /// Read dedupe: a `read` whose result would be byte-identical to an
1434    /// earlier `read` of the same file/range still in history is replaced by
1435    /// a short stub naming that earlier call, and `write`/`edit` results say
1436    /// there is no need to re-read to verify. Off = legacy tool-result bytes.
1437    /// (`read_dedupe`, default on).
1438    pub read_dedupe: bool,
1439    pub bash_timeout: u64,       // default 30
1440    pub bash_max_timeout: u64,   // default 300
1441    pub subagent_timeout: u64,   // default 300
1442    /// `subagent_resume` of a worker whose prompt cache has expired replays
1443    /// a compact context instead of its whole transcript once the history is
1444    /// at least this many (estimated) tokens: a cold replay rewrites every
1445    /// token at the cache-write price. `0` disables compaction
1446    /// (`subagent_cold_resume_compact_tokens`, default
1447    /// [`DEFAULT_SUBAGENT_COLD_RESUME_COMPACT_TOKENS`]).
1448    pub subagent_cold_resume_compact_tokens: u64,
1449    /// Automatic context rollover for subagent workers
1450    /// (`subagent_context_management = auto | off`, default `auto`). Workers
1451    /// evict older rounds into their archive directory instead of growing
1452    /// until the window is full. Independent of the foreground
1453    /// `context_management.mode`.
1454    pub subagent_context_management: ContextManagementMode,
1455    /// Estimated footprint (tokens) at which a worker's context rolls over
1456    /// (`subagent_rollover_tokens`, at least [`MIN_SUBAGENT_ROLLOVER_TOKENS`],
1457    /// default [`DEFAULT_SUBAGENT_ROLLOVER_TOKENS`]). The window's own
1458    /// rollover band wins when it is lower; the successor target is derived
1459    /// like the foreground's (min(100k, 2/3 of pressure)).
1460    pub subagent_rollover_tokens: u64,
1461    /// Subagent workers run the host's `after_tool_call` output transforms
1462    /// (extensions holding `tools.transform_output`: compression,
1463    /// redaction) on their own tool results, as the foreground does. Off =
1464    /// worker tool results reach history untransformed, as before.
1465    /// (`subagent_transform_hooks`, default on).
1466    pub subagent_transform_hooks: bool,
1467    /// Worker default, independent of foreground `thinking`; per-call overrides win.
1468    pub subagent_thinking: crate::core::reasoning::ReasoningLevel,
1469    pub api_retries: u32,        // default 3
1470    pub refusal_retries: u32,    // default 2 — retries on stop_reason=refusal
1471    pub telemetry: String,       // off | basic | full (default off)
1472    /// Rotated generations kept for `api-log.jsonl` and
1473    /// `request-trace.jsonl` (`telemetry_log_generations`, 1–
1474    /// [`MAX_TELEMETRY_LOG_GENERATIONS`], default 1: just `<log>.1`).
1475    pub telemetry_log_generations: u32,
1476    pub cache_diagnostics: bool, // opt into cache-diagnosis beta (default false)
1477    /// Prompt-cache TTL strategy: "auto" (default: 1h on Anthropic OAuth /
1478    /// subscription credentials, 5m on an API key) | "5m" | "1h" | "hybrid".
1479    pub cache_ttl: CacheTtlSetting,
1480    /// Prompt-cache keep-warm while idle (`cache_keepwarm`): `off` (default)
1481    /// | `auto` | `<N>h`. See [`CacheKeepWarm`] and
1482    /// docs/specs/cache-ttl-spec.md §7.
1483    pub cache_keepwarm: CacheKeepWarm,
1484    /// Longest an `auto` keep-warm idle period may run, in hours
1485    /// (`cache_keepwarm_auto_max_hours`, 1–48, default
1486    /// [`DEFAULT_CACHE_KEEPWARM_AUTO_MAX_HOURS`]). An explicit `<N>h` mode
1487    /// is its own window and ignores this.
1488    pub cache_keepwarm_auto_max_hours: u32,
1489    /// Let keep-warm run for daemon sessions driven only by non-interactive
1490    /// clients (`cache_keepwarm_headless`, default `false`): `synaps chat`,
1491    /// rpc/server clients over the daemon, piped `synaps attach`. Off, only a
1492    /// session a TUI or interactive `attach` has seen qualifies.
1493    pub cache_keepwarm_headless: bool,
1494    /// Keep the prompt cache warm while a tool round runs
1495    /// (`cache_keepwarm_tools`, default `true`): once a round's tools have
1496    /// run for the TTL minus a safety margin (4.5 min on 5m, 55 min on 1h),
1497    /// re-send that round's request with a minimal output cap so the next
1498    /// request reads the prefix instead of rewriting it. Foreground and
1499    /// workers alike; independent of the idle `cache_keepwarm`.
1500    pub cache_keepwarm_tools: bool,
1501    /// gzip for Anthropic request bodies (`request_compression`): `auto`
1502    /// (default) | `on` | `off`. See [`RequestCompressionMode`].
1503    pub request_compression: RequestCompressionMode,
1504    /// gzip level 1–9 (`request_compression_level`, default
1505    /// [`DEFAULT_REQUEST_COMPRESSION_LEVEL`]). Higher shrinks more and costs
1506    /// more CPU: raise it on a slow uplink.
1507    pub request_compression_level: u32,
1508    /// Who may drive a daemon session with several clients attached
1509    /// (`client_input`): `shared` (default) | `exclusive`. The daemon reads
1510    /// it at start, for every session it creates or restores. See
1511    /// [`ClientInput`].
1512    pub client_input: ClientInput,
1513    /// `thinking_display`: `summarized` (default) | `omitted`. The
1514    /// foreground session's `thinking.display`; see [`ThinkingDisplay`].
1515    pub thinking_display: ThinkingDisplay,
1516    /// Max TUI redraw rate in frames/sec — caps streaming redraws (e.g. 60,
1517    /// 144, 240). User input always redraws immediately regardless. Default
1518    /// 60. Range 1–1000. The frame budget is `1000 / max_fps` ms.
1519    pub max_fps: u32,
1520    /// Lines to scroll per mouse-wheel event. Different terminal emulators
1521    /// emit 1–9+ scroll events per physical notch; set this to compensate.
1522    /// Default: 3. Range 1–20.
1523    pub scroll_lines: Option<u16>,
1524    pub theme: Option<String>,
1525    /// Animated theme transitions: `"on"` (350 ms cross-fade, default),
1526    /// `"off"` (instant snap), or integer milliseconds (0–2000).
1527    pub theme_transition: ThemeTransitionMode,
1528    /// Whether the TUI paints its own opaque background. `false` preserves the terminal background.
1529    pub tui_background_opaque: bool,
1530    /// Whether the prompt shows a glow sweeping across it while a turn streams. Default off.
1531    pub tui_streaming_glow: bool,
1532    /// Whether the TUI reveals a burst of streamed text (a provider flushing
1533    /// a backlog at once) over a few frames instead of in one. Default on.
1534    pub tui_smooth_streaming: bool,
1535    /// Run the daemon, bash tool commands and PTY shells each in their own
1536    /// systemd scope (Linux, when a systemd user manager is reachable), so the
1537    /// out-of-memory killer can stop one runaway command instead of the whole
1538    /// terminal scope with the daemon in it. `process_scopes = off` disables;
1539    /// env `SYNAPS_PROCESS_SCOPES` overrides. Default on.
1540    pub process_scopes: bool,
1541    /// The systemd slice for every scope Synaps makes for a tool command
1542    /// (bash tool commands, PTY shells): `tool_scope_slice = synaps_tools.slice`
1543    /// passes `--slice=synaps_tools.slice` to `systemd-run`, so an operator
1544    /// can cap tool work on that slice apart from the desktop's
1545    /// `app.slice`. Unset or empty (the default) leaves systemd's placement
1546    /// as it is. Read with the config each spawn loads. Synaps sets no limits
1547    /// itself. Use a top-level (dash-free) name: `CPUWeight` only weighs a
1548    /// slice against its siblings (`docs/daemon-mode.md`).
1549    pub tool_scope_slice: Option<String>,
1550    /// The systemd slice for the daemon's own scope, applied when the daemon
1551    /// starts (a reload re-execs in the same scope). Separate from
1552    /// [`Self::tool_scope_slice`], so tools can be capped without the daemon.
1553    /// Unset or empty: no `--slice`.
1554    pub daemon_scope_slice: Option<String>,
1555    pub agent_name: Option<String>,
1556    pub identity: Option<String>,
1557    pub disabled_plugins: Vec<String>,
1558    pub favorite_models: Vec<String>,
1559    pub disabled_skills: Vec<String>,
1560    /// Opt-in progressive tool disclosure (Task 18). When false, providers
1561    /// receive the existing full tool schema byte-for-byte. When true, each
1562    /// stream starts with the small essential local core plus discovery and
1563    /// authorization gateways; exact activations are added per session.
1564    pub progressive_tool_disclosure: bool,
1565    /// Host confirmation policy for MODEL-INITIATED `activate_tools`
1566    /// (`tools.activation_confirm`). `Auto` (default) authorizes without
1567    /// prompting; `Prompt` asks the host (y/n confirm dialog); `Deny` never
1568    /// authorizes (locked-down hosts). `server.auto_approve_confirms = true`
1569    /// still authorizes regardless of this key.
1570    pub tools_activation_confirm: ActivationConfirm,
1571    /// `tools.deferred_loading` (default on). With progressive disclosure on
1572    /// the native Anthropic transport, every activatable tool is sent from the
1573    /// first request with `defer_loading: true` and `activate_tools` answers
1574    /// with `tool_reference` blocks, so an activation never changes the cached
1575    /// prompt prefix. `off` restores the per-activation tools-array change.
1576    pub tools_deferred_loading: bool,
1577    /// Opt-in session persistence strategy (Task 35, spec §9.8). `Json`
1578    /// (default) is the unchanged legacy full-rewrite path; `Journal` adds
1579    /// an append-only delta journal with periodic atomic snapshots. See
1580    /// docs/decisions/T35-session-journal-opt-in.md.
1581    pub session_persistence: crate::core::session_journal::SessionPersistence,
1582    /// Built-in tools to disable by runtime name (e.g. "bash", "ls"). Removed
1583    /// from the registry at boot so they're never offered to the model.
1584    pub disabled_tools: Vec<String>,
1585    pub shell: ShellConfig,
1586    pub server: ServerConfig,
1587    pub bridge: BridgeConfig,
1588    pub auth: AuthConfig,
1589    pub events: EventsConfig,
1590    pub provider_keys: BTreeMap<String, String>,
1591    pub keybinds: std::collections::HashMap<String, String>,
1592    /// Typed per-role turn budgets (Task 23, spec §8.1).
1593    pub turn_budgets: TurnBudgetsConfig,
1594    /// Continuous-memory settings surface (Task A2, spec §12).
1595    pub memory: MemoryConfig,
1596    /// Host-owned backend; selecting it does not enable recall or capture.
1597    pub memory_backend: MemoryBackendConfig,
1598    /// Non-fatal problems found while parsing the config file (unknown keys,
1599    /// unparseable values). Surfaced once at startup — never block boot.
1600    pub warnings: Vec<String>,
1601}
1602
1603impl Default for SynapsConfig {
1604    fn default() -> Self {
1605        Self {
1606            model: None,
1607            thinking_budget: None,
1608            thinking_level: None,
1609            context_window: None,
1610            context_management: ContextManagementConfig::default(),
1611            startup: StartupConfig::default(),
1612            daemon: DaemonConfig::default(),
1613            subagent: SubagentLimits::default(),
1614            compaction_model: None,
1615            compaction_mode: crate::core::compaction::CompactionMode::default(),
1616            compaction_exclude: Vec::new(),
1617            max_tool_output: 30000,
1618            tool_output_spill_bytes: DEFAULT_TOOL_OUTPUT_SPILL_BYTES,
1619            tool_output_spill_head_bytes: DEFAULT_TOOL_OUTPUT_SPILL_HEAD_BYTES,
1620            tool_output_spill_tail_bytes: DEFAULT_TOOL_OUTPUT_SPILL_TAIL_BYTES,
1621            read_dedupe: true,
1622            bash_timeout: 30,
1623            bash_max_timeout: 300,
1624            subagent_timeout: 300,
1625            subagent_cold_resume_compact_tokens: DEFAULT_SUBAGENT_COLD_RESUME_COMPACT_TOKENS,
1626            subagent_context_management: ContextManagementMode::Auto,
1627            subagent_rollover_tokens: DEFAULT_SUBAGENT_ROLLOVER_TOKENS,
1628            subagent_transform_hooks: true,
1629            subagent_thinking: crate::core::reasoning::ReasoningLevel::XHigh,
1630            api_retries: 3,
1631            refusal_retries: 2,
1632            telemetry: "off".to_string(),
1633            telemetry_log_generations: 1,
1634            cache_diagnostics: false,
1635            cache_ttl: CacheTtlSetting::default(),
1636            cache_keepwarm: CacheKeepWarm::Off,
1637            cache_keepwarm_auto_max_hours: DEFAULT_CACHE_KEEPWARM_AUTO_MAX_HOURS,
1638            cache_keepwarm_headless: false,
1639            cache_keepwarm_tools: true,
1640            request_compression: RequestCompressionMode::Auto,
1641            request_compression_level: DEFAULT_REQUEST_COMPRESSION_LEVEL,
1642            client_input: ClientInput::Shared,
1643            thinking_display: ThinkingDisplay::Summarized,
1644            max_fps: 60,
1645            scroll_lines: None,
1646            theme: None,
1647            theme_transition: ThemeTransitionMode::default(),
1648            tui_background_opaque: true,
1649            tui_streaming_glow: false,
1650            tui_smooth_streaming: true,
1651            process_scopes: true,
1652            tool_scope_slice: None,
1653            daemon_scope_slice: None,
1654            agent_name: None,
1655            identity: None,
1656            disabled_plugins: Vec::new(),
1657            favorite_models: Vec::new(),
1658            disabled_skills: Vec::new(),
1659            progressive_tool_disclosure: false,
1660            tools_activation_confirm: ActivationConfirm::default(),
1661            tools_deferred_loading: true,
1662            session_persistence: crate::core::session_journal::SessionPersistence::default(),
1663            disabled_tools: Vec::new(),
1664            shell: ShellConfig::default(),
1665            server: ServerConfig::default(),
1666            bridge: BridgeConfig::default(),
1667            auth: AuthConfig::default(),
1668            events: EventsConfig::default(),
1669            provider_keys: BTreeMap::new(),
1670            keybinds: std::collections::HashMap::new(),
1671            turn_budgets: TurnBudgetsConfig::default(),
1672            memory: MemoryConfig::default(),
1673            memory_backend: MemoryBackendConfig::default(),
1674            warnings: Vec::new(),
1675        }
1676    }
1677}
1678
1679/// Why `name` can't be a `tool_scope_slice` / `daemon_scope_slice` value, or
1680/// `None` if it can. A slice unit name: at most 255 bytes, ending in
1681/// `.slice`, only `[A-Za-z0-9:_.-]` (so no `/`), not starting with `-` and
1682/// not the root slice `-.slice`; as in systemd's own check, the part before
1683/// `.slice` is not empty, has no `--` and doesn't end in `-` (a `-` separates
1684/// the parent slices: `synaps-tools.slice` nests under `synaps.slice`). The
1685/// config file is not TOML, so a quoted value keeps its quotes and gets its
1686/// own hint. An empty value never reaches this check: the parser takes it
1687/// as "unset".
1688pub fn scope_slice_problem(name: &str) -> Option<&'static str> {
1689    if name.len() > 255 {
1690        return Some("longer than 255 bytes");
1691    }
1692    if name.starts_with(['"', '\'']) || name.ends_with(['"', '\'']) {
1693        return Some("write the name without quotes (the config file is not TOML)");
1694    }
1695    if !name.ends_with(".slice") {
1696        return Some("expected a slice unit name ending in .slice");
1697    }
1698    if !name.bytes().all(|b| b.is_ascii_alphanumeric() || matches!(b, b':' | b'_' | b'.' | b'-')) {
1699        return Some("only letters, digits, ':', '_', '.' and '-' are allowed");
1700    }
1701    if name == "-.slice" {
1702        return Some("the root slice -.slice is not allowed");
1703    }
1704    if name.starts_with('-') {
1705        return Some("must not start with '-'");
1706    }
1707    let stem = &name[..name.len() - ".slice".len()];
1708    if stem.is_empty() {
1709        return Some("needs a name before .slice");
1710    }
1711    if stem.contains("--") || stem.ends_with('-') {
1712        return Some("'-' separates parent slices: no '--' and no trailing '-'");
1713    }
1714    None
1715}
1716
1717/// Upper bound for `telemetry_log_generations` (each generation of each log
1718/// is up to 50 MB).
1719pub const MAX_TELEMETRY_LOG_GENERATIONS: u32 = 100;
1720
1721/// Known top-level config keys — used for unknown-key warnings + did-you-mean.
1722/// Public so the /settings coverage test can require a settings row (or an
1723/// explicit exclusion) for every key here.
1724pub const KNOWN_CONFIG_KEYS: &[&str] = &[
1725    "model",
1726    "thinking",
1727    "compaction_model",
1728    "compaction_mode",
1729    "compaction_exclude",
1730    "context_window",
1731    "context_management.mode",
1732    "context_management.pressure_tokens",
1733    "context_management.rollover_tokens",
1734    "context_management.reserve_tokens",
1735    "context_management.finish_rounds",
1736    "context_management.target_tokens",
1737    "startup.quick_start",
1738    "startup.extensions_ready_timeout_secs",
1739    "daemon.idle_exit_secs",
1740    "daemon.prompt_abandon_secs",
1741    "daemon.parked_evict_secs",
1742    "max_tool_output",
1743    "tool_output_spill_bytes",
1744    "tool_output_spill_head_bytes",
1745    "tool_output_spill_tail_bytes",
1746    "read_dedupe",
1747    "bash_timeout",
1748    "bash_max_timeout",
1749    "subagent_timeout",
1750    "subagent_cold_resume_compact_tokens",
1751    "subagent_context_management",
1752    "subagent_rollover_tokens",
1753    "subagent_transform_hooks",
1754    "subagent_thinking",
1755    "subagent.max_concurrent",
1756    "subagent.max_total",
1757    "subagent.max_depth",
1758    // Legacy flat spellings of `subagent.max_concurrent` / `subagent.max_total`.
1759    "subagent_max_concurrent",
1760    "subagent_max_total",
1761    "api_retries",
1762    "refusal_retries",
1763    "telemetry",
1764    "telemetry_log_generations",
1765    "cache_diagnostics",
1766    "cache_ttl",
1767    "cache_keepwarm",
1768    "cache_keepwarm_auto_max_hours",
1769    "cache_keepwarm_headless",
1770    "cache_keepwarm_tools",
1771    "request_compression",
1772    "request_compression_level",
1773    "client_input",
1774    "thinking_display",
1775    "max_fps",
1776    "scroll_lines",
1777    "theme",
1778    "theme_transition",
1779    "tui_background_opaque",
1780    "tui_streaming_glow",
1781    "tui_smooth_streaming",
1782    "process_scopes",
1783    "tool_scope_slice",
1784    "daemon_scope_slice",
1785    "agent_name",
1786    "identity",
1787    "disabled_plugins",
1788    "favorite_models",
1789    "disabled_skills",
1790    "disabled_tools",
1791    "progressive_tool_disclosure",
1792    "tools.activation_confirm",
1793    "tools.deferred_loading",
1794    "session_persistence",
1795    "sidecar_toggle_key",
1796];
1797
1798/// Every key accepted by the namespaced family parsers that is NOT in
1799/// [`KNOWN_CONFIG_KEYS`] (`shell.*`, `events.*`, `turn_budget.*`,
1800/// `memory.*`, `server.*`, `bridge.*`, `auth.*`). Open-ended families whose
1801/// suffix is user-chosen are listed as `prefix.*`. Keep this in sync when a
1802/// parser learns a key: the /settings coverage test requires each entry to
1803/// have a settings row or an explicit exclusion.
1804pub const FAMILY_CONFIG_KEYS: &[&str] = &[
1805    // parse_shell_config_key
1806    "shell.max_sessions",
1807    "shell.idle_timeout",
1808    "shell.readiness_timeout_ms",
1809    "shell.max_readiness_timeout_ms",
1810    "shell.default_rows",
1811    "shell.default_cols",
1812    "shell.readiness_strategy",
1813    "shell.max_output",
1814    // parse_events_config_key
1815    "events.auto_turn",
1816    "events.auto_turn_cap",
1817    // parse_turn_budget_config_key: turn_budget.<role>.<field>
1818    "turn_budget.foreground.max_provider_rounds",
1819    "turn_budget.foreground.max_tool_calls",
1820    "turn_budget.foreground.max_elapsed_secs",
1821    "turn_budget.foreground.max_accumulated_tool_result_bytes",
1822    "turn_budget.foreground.max_context_tokens",
1823    "turn_budget.foreground.max_cost_usd",
1824    "turn_budget.foreground.max_round_renewals",
1825    "turn_budget.autonomous.max_provider_rounds",
1826    "turn_budget.autonomous.max_tool_calls",
1827    "turn_budget.autonomous.max_elapsed_secs",
1828    "turn_budget.autonomous.max_accumulated_tool_result_bytes",
1829    "turn_budget.autonomous.max_context_tokens",
1830    "turn_budget.autonomous.max_cost_usd",
1831    "turn_budget.autonomous.max_round_renewals",
1832    "turn_budget.event.max_provider_rounds",
1833    "turn_budget.event.max_tool_calls",
1834    "turn_budget.event.max_elapsed_secs",
1835    "turn_budget.event.max_accumulated_tool_result_bytes",
1836    "turn_budget.event.max_context_tokens",
1837    "turn_budget.event.max_cost_usd",
1838    "turn_budget.event.max_round_renewals",
1839    "turn_budget.worker.max_provider_rounds",
1840    "turn_budget.worker.max_tool_calls",
1841    "turn_budget.worker.max_elapsed_secs",
1842    "turn_budget.worker.max_accumulated_tool_result_bytes",
1843    "turn_budget.worker.max_context_tokens",
1844    "turn_budget.worker.max_cost_usd",
1845    "turn_budget.worker.max_round_renewals",
1846    // memory backend (top-level arms of apply_config_content)
1847    "memory.backend",
1848    "memory.user_scope",
1849    "memory.axel.executable",
1850    "memory.axel.brain",
1851    // parse_memory_config_key
1852    "memory.default_mode",
1853    "memory.default_mode_confirmed",
1854    "memory.recall_max_records",
1855    "memory.recall_max_tokens",
1856    "memory.recall_timeout_ms",
1857    "memory.capture_tools",
1858    "memory.capture_assistant",
1859    "memory.capture_user",
1860    "memory.auto_consolidate",
1861    "memory.local_embeddings",
1862    "memory.gliner",
1863    // parse_server_config_key
1864    "server.allowed_origins",
1865    "server.token",
1866    "server.auto_approve_confirms",
1867    "server.max_message_size",
1868    // parse_bridge_config_key
1869    "bridge.uds_path",
1870    "bridge.heartbeat_mirror",
1871    "bridge.heartbeat_timeout_ms",
1872    // parse_auth_config_key
1873    "auth.remote_endpoint",
1874    "auth.machine_token",
1875    // open-ended families
1876    "provider.*",
1877    "keybind.*",
1878];
1879
1880/// Simple Levenshtein distance for did-you-mean suggestions.
1881fn levenshtein(a: &str, b: &str) -> usize {
1882    let a: Vec<char> = a.chars().collect();
1883    let b: Vec<char> = b.chars().collect();
1884    let mut prev: Vec<usize> = (0..=b.len()).collect();
1885    let mut cur = vec![0; b.len() + 1];
1886    for (i, ca) in a.iter().enumerate() {
1887        cur[0] = i + 1;
1888        for (j, cb) in b.iter().enumerate() {
1889            let cost = if ca == cb { 0 } else { 1 };
1890            cur[j + 1] = (prev[j + 1] + 1).min(cur[j] + 1).min(prev[j] + cost);
1891        }
1892        std::mem::swap(&mut prev, &mut cur);
1893    }
1894    prev[b.len()]
1895}
1896
1897/// Closest known key within edit distance 2, for typo suggestions.
1898fn did_you_mean(key: &str) -> Option<&'static str> {
1899    KNOWN_CONFIG_KEYS
1900        .iter()
1901        .map(|k| (*k, levenshtein(key, k)))
1902        .filter(|(_, d)| *d <= 2)
1903        .min_by_key(|(_, d)| *d)
1904        .map(|(k, _)| k)
1905}
1906
1907// parse_thinking_budget replaced by ThinkingSpec::parse in apply_config_content.
1908
1909fn parse_comma_list(val: &str) -> Vec<String> {
1910    val.split(',')
1911        .map(|s| s.trim().to_string())
1912        .filter(|s| !s.is_empty())
1913        .collect()
1914}
1915
1916fn write_comma_list_locked(key: &str, values: &[String]) -> std::io::Result<()> {
1917    write_config_value_locked(key, &values.join(", "))
1918}
1919
1920/// Parse shell.* configuration keys and update the ShellConfig. `Err`
1921/// carries the diagnostic the caller prints.
1922fn parse_shell_config_key(shell_config: &mut ShellConfig, key: &str, val: &str) -> Result<(), String> {
1923    let invalid = || format!("Warning: invalid value for {key}: '{val}', using default");
1924    match key {
1925        "shell.max_sessions" => {
1926            shell_config.max_sessions = val.parse::<usize>().map_err(|_| invalid())?;
1927        }
1928        "shell.idle_timeout" => {
1929            let timeout = val.parse::<u64>().map_err(|_| invalid())?;
1930            shell_config.idle_timeout = std::time::Duration::from_secs(timeout);
1931        }
1932        "shell.readiness_timeout_ms" => {
1933            shell_config.readiness_timeout_ms = val.parse::<u64>().map_err(|_| invalid())?;
1934        }
1935        "shell.max_readiness_timeout_ms" => {
1936            shell_config.max_readiness_timeout_ms = val.parse::<u64>().map_err(|_| invalid())?;
1937        }
1938        "shell.default_rows" => {
1939            shell_config.default_rows = val.parse::<u16>().map_err(|_| invalid())?;
1940        }
1941        "shell.default_cols" => {
1942            shell_config.default_cols = val.parse::<u16>().map_err(|_| invalid())?;
1943        }
1944        "shell.readiness_strategy" => {
1945            let val_lower = val.to_lowercase();
1946            match val_lower.as_str() {
1947                "timeout" | "prompt" | "hybrid" => {
1948                    shell_config.readiness_strategy = val.to_string();
1949                }
1950                _ => return Err(invalid()),
1951            }
1952        }
1953        "shell.max_output" => {
1954            shell_config.max_output = val.parse::<usize>().map_err(|_| invalid())?;
1955        }
1956        _ => {
1957            // Unknown shell.* keys are preserved (not rejected)
1958        }
1959    }
1960    Ok(())
1961}
1962
1963/// Parse server.* configuration keys and update the ServerConfig.
1964#[allow(clippy::collapsible_match)]
1965fn parse_server_config_key(server_config: &mut ServerConfig, key: &str, val: &str) {
1966    match key {
1967        "server.allowed_origins" => {
1968            server_config.allowed_origins = parse_comma_list(val);
1969        }
1970        "server.token" => {
1971            if !val.is_empty() {
1972                server_config.token = Some(val.to_string());
1973            }
1974        }
1975        "server.auto_approve_confirms" => {
1976            server_config.auto_approve_confirms = matches!(val, "true" | "1" | "yes");
1977        }
1978        "server.max_message_size" => {
1979            if let Ok(size) = val.parse::<usize>() {
1980                server_config.max_message_size = Some(size);
1981            } else {
1982                eprintln!(
1983                    "Warning: invalid value for server.max_message_size: '{}', ignored",
1984                    val
1985                );
1986            }
1987        }
1988        _ => {
1989            // Unknown server.* keys preserved (not rejected)
1990        }
1991    }
1992}
1993
1994/// Parse bridge.* configuration keys and update the BridgeConfig.
1995fn parse_bridge_config_key(bridge_config: &mut BridgeConfig, key: &str, val: &str) {
1996    match key {
1997        "bridge.uds_path" => {
1998            if val.is_empty() {
1999                bridge_config.uds_path = None;
2000            } else {
2001                bridge_config.uds_path = Some(PathBuf::from(val));
2002            }
2003        }
2004        "bridge.heartbeat_mirror" => {
2005            bridge_config.heartbeat_mirror = matches!(val, "true" | "1" | "yes");
2006        }
2007        "bridge.heartbeat_timeout_ms" => {
2008            if let Ok(ms) = val.parse::<u64>() {
2009                bridge_config.heartbeat_timeout_ms = ms;
2010            } else {
2011                eprintln!(
2012                    "Warning: invalid value for bridge.heartbeat_timeout_ms: '{}', using default",
2013                    val
2014                );
2015            }
2016        }
2017        _ => {
2018            // Unknown bridge.* keys preserved (not rejected)
2019        }
2020    }
2021}
2022
2023/// Warning for an `auth.*` key no parser reads (a typo would otherwise
2024/// silently leave traffic on the default account), with a did-you-mean.
2025fn unknown_auth_key_warning(key: &str) -> Option<String> {
2026    const EXACT: [&str; 2] = ["auth.remote_endpoint", "auth.machine_token"];
2027    const FAMILIES: [&str; 2] = ["auth.account", "auth.auto"];
2028    const PLACEHOLDERS: [&str; 2] = ["<provider>", "<key>"];
2029    if EXACT.contains(&key) {
2030        return None;
2031    }
2032    for (family, placeholder) in FAMILIES.iter().zip(PLACEHOLDERS) {
2033        match key.strip_prefix(family) {
2034            // `auth.account` or `auth.account.` (no provider): name the shape.
2035            Some("") | Some(".") => {
2036                return Some(format!(
2037                    "unknown key '{key}' (did you mean '{family}.{placeholder}'?)"
2038                ))
2039            }
2040            Some(rest) if rest.starts_with('.') && !rest[1..].trim().is_empty() => return None,
2041            _ => {}
2042        }
2043    }
2044    // A family typo keeps its suffix: `auth.acount.x` → `auth.account.x`.
2045    let (head, rest) = match key.match_indices('.').nth(1) {
2046        Some((i, _)) => key.split_at(i),
2047        None => (key, ""),
2048    };
2049    let suggestion = EXACT
2050        .iter()
2051        .map(|k| (levenshtein(key, k), k.to_string()))
2052        .chain(
2053            FAMILIES
2054                .iter()
2055                .filter(|_| !rest.is_empty())
2056                .map(|f| (levenshtein(head, f), format!("{f}{rest}"))),
2057        )
2058        .filter(|(d, _)| *d <= 2)
2059        .min_by_key(|(d, _)| *d)
2060        .map(|(_, k)| k);
2061    Some(match suggestion {
2062        Some(s) => format!("unknown key '{key}' (did you mean '{s}'?)"),
2063        None => format!("unknown key '{key}' — ignored"),
2064    })
2065}
2066
2067/// Parse auth.* configuration keys and update the AuthConfig.
2068fn parse_auth_config_key(auth_config: &mut AuthConfig, key: &str, val: &str) {
2069    let v = val.trim();
2070    match key {
2071        "auth.remote_endpoint" => {
2072            auth_config.remote_endpoint = if v.is_empty() {
2073                None
2074            } else {
2075                Some(v.to_string())
2076            };
2077        }
2078        "auth.machine_token" => {
2079            auth_config.machine_token = if v.is_empty() {
2080                None
2081            } else {
2082                Some(v.to_string())
2083            };
2084        }
2085        _ => {
2086            if let Some(provider) = key.strip_prefix("auth.account.") {
2087                let provider = provider.trim();
2088                if !provider.is_empty() {
2089                    // Raw value; validated (fail closed) by AccountPolicy.
2090                    auth_config
2091                        .accounts
2092                        .insert(provider.to_string(), v.to_string());
2093                }
2094            }
2095            if let Some(key) = key.strip_prefix("auth.auto.").filter(|k| !k.trim().is_empty()) {
2096                auth_config.auto.insert(key.to_string(), v.to_string());
2097            }
2098            // Other unknown auth.* keys preserved (not rejected)
2099        }
2100    }
2101}
2102
2103/// Parse config from a raw string — useful for tests and embedded harnesses.
2104/// Does NOT write to `PROVIDER_KEYS` or `IDENTITY` OnceLocks.
2105pub fn load_config_from_str(content: &str) -> SynapsConfig {
2106    let mut config = SynapsConfig::default();
2107    apply_config_content(&mut config, content);
2108    config
2109}
2110
2111/// Every problem the parser reports for `content`: the `warnings` surfaced
2112/// at startup plus the stderr-only diagnostics of the `shell.*`, `events.*`,
2113/// `turn_budget.*` and `memory.*` families. Pure — prints nothing and
2114/// touches no process state.
2115pub fn config_content_problems(content: &str) -> Vec<String> {
2116    let mut config = SynapsConfig::default();
2117    let mut problems = Vec::new();
2118    apply_config_content_with(&mut config, content, &mut |diagnostic: String| problems.push(diagnostic));
2119    problems.extend(config.warnings);
2120    problems
2121}
2122
2123/// Check a `key = value` edit before it is written (the /settings modal).
2124///
2125/// Runs the real parser over the config file as it would look after
2126/// [`write_config_value`] and returns `Err` with the parser's own messages
2127/// for any problem the edit introduces — invalid values, and cross-key
2128/// rules such as `context_management` threshold ordering, `subagent`
2129/// concurrency vs total, or `memory.default_mode` without operator consent.
2130/// Problems already present before the edit are not blamed on it.
2131pub fn validate_config_edit(key: &str, value: &str) -> Result<(), String> {
2132    if key.contains(['=', '\n', '\r'])
2133        || value.contains(['\n', '\r'])
2134    {
2135        return Err("value must be a single line".to_string());
2136    }
2137    // The file the edit lands in (profile-aware, like `write_config_value`),
2138    // computed without `resolve_write_path`'s directory creation.
2139    let target = get_active_config_dir().join("config");
2140    let existing = std::fs::read_to_string(&target).unwrap_or_default();
2141    let before = config_content_problems(&existing);
2142    let after = config_content_problems(&replace_config_line(&existing, key, value));
2143    let introduced: Vec<String> = after.into_iter().filter(|p| !before.contains(p)).collect();
2144    if introduced.is_empty() {
2145        Ok(())
2146    } else {
2147        Err(introduced.join("; "))
2148    }
2149}
2150
2151/// Parse the config file at ~/.synaps-cli/config (or profile variant).
2152/// Returns default config if file doesn't exist or can't be read.
2153pub fn load_config() -> SynapsConfig {
2154    let path = resolve_read_path("config");
2155    let mut config = SynapsConfig::default();
2156
2157    let Ok(content) = std::fs::read_to_string(&path) else {
2158        return config;
2159    };
2160
2161    apply_config_content(&mut config, &content);
2162
2163    // Publish provider keys to the process-wide cache for the API router.
2164    // First writer wins (OnceLock) — subsequent load_config calls are no-ops.
2165    let _ = PROVIDER_KEYS.set(config.provider_keys.clone());
2166
2167    // Publish identity to the process-wide cache for API system prompt preamble.
2168    let identity_val = config
2169        .identity
2170        .clone()
2171        .unwrap_or_else(|| DEFAULT_IDENTITY.to_string());
2172    let _ = IDENTITY.set(identity_val);
2173
2174    config
2175}
2176
2177/// Apply key=value config lines from `content` into `config`.
2178/// Shared by `load_config` (file path) and `load_config_from_str` (test helper).
2179fn apply_config_content(config: &mut SynapsConfig, content: &str) {
2180    // The shell/events/turn_budget/memory families report to stderr (not
2181    // `config.warnings`) — unchanged behaviour.
2182    apply_config_content_with(config, content, &mut |diagnostic: String| eprintln!("{diagnostic}"));
2183}
2184
2185/// [`apply_config_content`] with the stderr-only family diagnostics routed
2186/// to `family_diagnostic` instead, so [`config_content_problems`] can see
2187/// every value the parser rejects.
2188fn apply_config_content_with(
2189    config: &mut SynapsConfig,
2190    content: &str,
2191    family_diagnostic: &mut dyn FnMut(String),
2192) {
2193    let mut invalid_context_management = false;
2194    let mut invalid_memory_backend = false;
2195    for line in content.lines() {
2196        let line = line.trim();
2197        if line.is_empty() || line.starts_with('#') {
2198            continue;
2199        }
2200        let Some((key, val)) = line.split_once('=') else {
2201            continue;
2202        };
2203        let key = key.trim();
2204        let val = val.trim();
2205        match key {
2206            "model" => config.model = Some(val.to_string()),
2207            "thinking" => {
2208                use crate::core::reasoning::ThinkingSpec;
2209                match ThinkingSpec::parse(val) {
2210                    Some(ThinkingSpec::Named(level)) => {
2211                        config.thinking_level = Some(level);
2212                        config.thinking_budget = level.to_legacy_budget();
2213                    }
2214                    Some(ThinkingSpec::Budget(budget)) => {
2215                        // Preserve exact legacy budgets; do not make the derived
2216                        // bucket authoritative over the user's token count.
2217                        config.thinking_level = None;
2218                        config.thinking_budget = Some(budget);
2219                    }
2220                    None => {
2221                        config.warnings.push(format!("thinking = {val} — expected off|adaptive|low|medium|high|xhigh|max|ultra|ultracode or a token count; thinking disabled"));
2222                    }
2223                }
2224            }
2225            "compaction_model" => config.compaction_model = Some(val.to_string()),
2226            "compaction_mode" => match val {
2227                "remote" => {
2228                    config.compaction_mode = crate::core::compaction::CompactionMode::Remote
2229                }
2230                "local" | "local_only" | "local-only" => {
2231                    config.compaction_mode = crate::core::compaction::CompactionMode::LocalOnly
2232                }
2233                _ => {
2234                    config.warnings.push(format!(
2235                        "compaction_mode = {val} — expected remote or local; ignored"
2236                    ));
2237                }
2238            },
2239            "compaction_exclude" => {
2240                let mut classes = Vec::new();
2241                for part in val.split(',').map(str::trim).filter(|p| !p.is_empty()) {
2242                    match crate::core::compaction::ContentClass::parse(part) {
2243                        Some(class) => {
2244                            if !classes.contains(&class) {
2245                                classes.push(class);
2246                            }
2247                        }
2248                        None => config.warnings.push(format!(
2249                            "compaction_exclude — unknown content class '{part}'; \
2250                             expected one of: user_text, assistant_text, thinking, \
2251                             tool_calls, tool_results, file_paths, event_data"
2252                        )),
2253                    }
2254                }
2255                config.compaction_exclude = classes;
2256            }
2257            // `auto` = model default (what /settings and `/context auto`
2258            // write): clears any override, no warning.
2259            "context_window" if val.eq_ignore_ascii_case("auto") => config.context_window = None,
2260            "context_window" => {
2261                let parsed = match val {
2262                    "200k" | "200K" => Some(200_000),
2263                    "1m" | "1M" => Some(1_000_000),
2264                    _ => val.parse::<u64>().ok(),
2265                };
2266                if parsed.is_none() {
2267                    config.warnings.push(format!(
2268                        "context_window = {val} — expected 200k, 1m, auto, or a token count; ignored"
2269                    ));
2270                }
2271                config.context_window = parsed;
2272            }
2273            "max_tool_output" => match val.parse::<usize>() {
2274                Ok(size) => config.max_tool_output = size,
2275                Err(_) => config.warnings.push(format!(
2276                    "max_tool_output = {val} — not a number; using {}",
2277                    config.max_tool_output
2278                )),
2279            },
2280            "tool_output_spill_bytes" => match val.parse::<usize>() {
2281                Ok(bytes) => config.tool_output_spill_bytes = bytes,
2282                Err(_) => config.warnings.push(format!(
2283                    "tool_output_spill_bytes = {val} — expected a byte count (0 turns virtualisation off); using {DEFAULT_TOOL_OUTPUT_SPILL_BYTES}"
2284                )),
2285            },
2286            "tool_output_spill_head_bytes" => match val.parse::<usize>() {
2287                Ok(bytes) => config.tool_output_spill_head_bytes = bytes,
2288                Err(_) => config.warnings.push(format!(
2289                    "tool_output_spill_head_bytes = {val} — expected a byte count; using {DEFAULT_TOOL_OUTPUT_SPILL_HEAD_BYTES}"
2290                )),
2291            },
2292            "tool_output_spill_tail_bytes" => match val.parse::<usize>() {
2293                Ok(bytes) => config.tool_output_spill_tail_bytes = bytes,
2294                Err(_) => config.warnings.push(format!(
2295                    "tool_output_spill_tail_bytes = {val} — expected a byte count; using {DEFAULT_TOOL_OUTPUT_SPILL_TAIL_BYTES}"
2296                )),
2297            },
2298            "bash_timeout" => match val.parse::<u64>() {
2299                Ok(t) if t >= 1 => config.bash_timeout = t,
2300                Ok(_) => config.warnings.push(format!(
2301                    "bash_timeout = {val} — below minimum (1s); using {}",
2302                    config.bash_timeout
2303                )),
2304                Err(_) => config.warnings.push(format!(
2305                    "bash_timeout = {val} — not a number; using {}",
2306                    config.bash_timeout
2307                )),
2308            },
2309            "bash_max_timeout" => {
2310                if let Ok(timeout) = val.parse::<u64>() {
2311                    config.bash_max_timeout = timeout;
2312                }
2313            }
2314            "subagent_thinking" => {
2315                match crate::core::reasoning::parse_subagent_thinking(val) {
2316                    Some(level) => config.subagent_thinking = level,
2317                    None => config.warnings.push(format!(
2318                        "subagent_thinking = {val} — expected low|medium|high|xhigh|max; keeping {}",
2319                        config.subagent_thinking
2320                    )),
2321                }
2322            }
2323            "subagent_timeout" => {
2324                if let Ok(timeout) = val.parse::<u64>() {
2325                    config.subagent_timeout = timeout;
2326                }
2327            }
2328            "subagent_cold_resume_compact_tokens" => match val.parse::<u64>() {
2329                Ok(tokens) => config.subagent_cold_resume_compact_tokens = tokens,
2330                Err(_) => config.warnings.push(format!(
2331                    "subagent_cold_resume_compact_tokens = {val} — expected a token count (0 disables); using {DEFAULT_SUBAGENT_COLD_RESUME_COMPACT_TOKENS}"
2332                )),
2333            },
2334            "subagent_context_management" => match ContextManagementMode::parse(val) {
2335                Some(mode) => config.subagent_context_management = mode,
2336                None => config.warnings.push(format!(
2337                    "subagent_context_management = {val} — expected auto or off; using auto"
2338                )),
2339            },
2340            "subagent_rollover_tokens" => match val.parse::<u64>() {
2341                Ok(tokens) if tokens >= MIN_SUBAGENT_ROLLOVER_TOKENS => {
2342                    config.subagent_rollover_tokens = tokens
2343                }
2344                _ => config.warnings.push(format!(
2345                    "subagent_rollover_tokens = {val} — expected a token count of at least {MIN_SUBAGENT_ROLLOVER_TOKENS}; using {DEFAULT_SUBAGENT_ROLLOVER_TOKENS}"
2346                )),
2347            },
2348            "subagent_transform_hooks" => match val {
2349                "true" | "1" | "on" | "yes" => config.subagent_transform_hooks = true,
2350                "false" | "0" | "off" | "no" => config.subagent_transform_hooks = false,
2351                _ => config.warnings.push(format!(
2352                    "subagent_transform_hooks = {val} — expected on or off; using on"
2353                )),
2354            },
2355            // Legacy flat spellings (pre-`subagent.*`): same parser, same limits.
2356            "subagent_max_concurrent" | "subagent_max_total" => {
2357                let dotted = key.replacen("subagent_", "subagent.", 1);
2358                if let Err(reason) =
2359                    parse_subagent_config_key(&mut config.subagent, &dotted, val)
2360                {
2361                    config.warnings.push(format!("{key} — {reason}"));
2362                }
2363            }
2364            "api_retries" => {
2365                if let Ok(retries) = val.parse::<u32>() {
2366                    config.api_retries = retries;
2367                }
2368            }
2369            "refusal_retries" => {
2370                if let Ok(retries) = val.parse::<u32>() {
2371                    config.refusal_retries = retries;
2372                }
2373            }
2374            "telemetry" => config.telemetry = val.to_string(),
2375            "telemetry_log_generations" => match val.parse::<u32>() {
2376                Ok(n) if (1..=MAX_TELEMETRY_LOG_GENERATIONS).contains(&n) => {
2377                    config.telemetry_log_generations = n;
2378                }
2379                _ => config.warnings.push(format!(
2380                    "telemetry_log_generations = {val} — expected 1–{MAX_TELEMETRY_LOG_GENERATIONS}; line ignored"
2381                )),
2382            },
2383            "cache_diagnostics" => {
2384                config.cache_diagnostics = matches!(val, "true" | "1" | "on" | "yes");
2385            }
2386            "cache_ttl" => match CacheTtlSetting::parse(val) {
2387                Some(ttl) => config.cache_ttl = ttl,
2388                None => config.warnings.push(format!(
2389                    "cache_ttl = {val} — expected auto, 5m, 1h, or hybrid; using auto"
2390                )),
2391            },
2392            "cache_keepwarm" => match CacheKeepWarm::parse(val) {
2393                Some(mode) => config.cache_keepwarm = mode,
2394                None => config.warnings.push(format!(
2395                    "cache_keepwarm = {val} — expected off, auto, or 1h–{MAX_CACHE_KEEPWARM_HOURS}h; keep-warm stays off"
2396                )),
2397            },
2398            "thinking_display" => match ThinkingDisplay::parse(val) {
2399                Some(display) => config.thinking_display = display,
2400                None => config.warnings.push(format!(
2401                    "thinking_display = {val} — expected summarized or omitted; using summarized"
2402                )),
2403            },
2404            "client_input" => match ClientInput::parse(val) {
2405                Some(mode) => config.client_input = mode,
2406                None => config.warnings.push(format!(
2407                    "client_input = {val} — expected shared or exclusive; using shared"
2408                )),
2409            },
2410            "cache_keepwarm_auto_max_hours" => {
2411                match val.trim().trim_end_matches(['h', 'H']).parse::<u32>() {
2412                    Ok(h) if (1..=MAX_CACHE_KEEPWARM_HOURS).contains(&h) => {
2413                        config.cache_keepwarm_auto_max_hours = h
2414                    }
2415                    _ => config.warnings.push(format!(
2416                        "cache_keepwarm_auto_max_hours = {val} — expected 1–{MAX_CACHE_KEEPWARM_HOURS}; using {DEFAULT_CACHE_KEEPWARM_AUTO_MAX_HOURS}"
2417                    )),
2418                }
2419            }
2420            "cache_keepwarm_headless" => match val.trim().to_ascii_lowercase().as_str() {
2421                "true" | "1" | "on" | "yes" => config.cache_keepwarm_headless = true,
2422                "false" | "0" | "off" | "no" => config.cache_keepwarm_headless = false,
2423                _ => config.warnings.push(format!(
2424                    "cache_keepwarm_headless = {val} — expected true or false; keeping false"
2425                )),
2426            },
2427            "cache_keepwarm_tools" => match val.trim().to_ascii_lowercase().as_str() {
2428                "true" | "1" | "on" | "yes" => config.cache_keepwarm_tools = true,
2429                "false" | "0" | "off" | "no" => config.cache_keepwarm_tools = false,
2430                _ => config.warnings.push(format!(
2431                    "cache_keepwarm_tools = {val} — expected on or off; keeping on"
2432                )),
2433            },
2434            "request_compression" => match RequestCompressionMode::parse(val) {
2435                Some(mode) => config.request_compression = mode,
2436                None => config.warnings.push(format!(
2437                    "request_compression = {val} — expected auto, on, or off; using auto"
2438                )),
2439            },
2440            "request_compression_level" => match val.trim().parse::<u32>() {
2441                Ok(n) if (1..=MAX_REQUEST_COMPRESSION_LEVEL).contains(&n) => {
2442                    config.request_compression_level = n
2443                }
2444                _ => config.warnings.push(format!(
2445                    "request_compression_level = {val} — expected 1–{MAX_REQUEST_COMPRESSION_LEVEL}; using {DEFAULT_REQUEST_COMPRESSION_LEVEL}"
2446                )),
2447            },
2448            "max_fps" => match val.parse::<u32>() {
2449                Ok(fps) if (1..=1000).contains(&fps) => config.max_fps = fps,
2450                Ok(_) => config.warnings.push(format!(
2451                    "max_fps = {val} — expected 1–1000; using {}",
2452                    config.max_fps
2453                )),
2454                Err(_) => config.warnings.push(format!(
2455                    "max_fps = {val} — not a number; using {}",
2456                    config.max_fps
2457                )),
2458            },
2459            "scroll_lines" => match val.parse::<u16>() {
2460                Ok(n) if (1..=20).contains(&n) => config.scroll_lines = Some(n),
2461                Ok(_) => config
2462                    .warnings
2463                    .push(format!("scroll_lines = {val} — expected 1–20; ignoring")),
2464                Err(_) => config
2465                    .warnings
2466                    .push(format!("scroll_lines = {val} — not a number; ignoring")),
2467            },
2468            "theme" => config.theme = Some(val.to_string()),
2469            "theme_transition" => match ThemeTransitionMode::parse(val) {
2470                Some(mode) => config.theme_transition = mode,
2471                None => config.warnings.push(format!(
2472                    "theme_transition = {val} — expected on, off, or milliseconds (0–2000); using on"
2473                )),
2474            },
2475            "tui_background_opaque" => match val {
2476                "true" | "1" | "on" | "yes" | "opaque" => config.tui_background_opaque = true,
2477                "false" | "0" | "off" | "no" | "invisible" => config.tui_background_opaque = false,
2478                _ => config.warnings.push(format!(
2479                    "tui_background_opaque = {val} — expected opaque or invisible; using {}",
2480                    if config.tui_background_opaque {
2481                        "opaque"
2482                    } else {
2483                        "invisible"
2484                    }
2485                )),
2486            },
2487            "read_dedupe" => match val {
2488                "true" | "1" | "on" | "yes" => config.read_dedupe = true,
2489                "false" | "0" | "off" | "no" => config.read_dedupe = false,
2490                _ => config.warnings.push(format!(
2491                    "read_dedupe = {val} — expected on or off; using on"
2492                )),
2493            },
2494            "tui_streaming_glow" => match val {
2495                "true" | "1" | "on" | "yes" => config.tui_streaming_glow = true,
2496                "false" | "0" | "off" | "no" => config.tui_streaming_glow = false,
2497                _ => config.warnings.push(format!(
2498                    "tui_streaming_glow = {val} — expected on or off; using off"
2499                )),
2500            },
2501            "tui_smooth_streaming" => match val {
2502                "true" | "1" | "on" | "yes" => config.tui_smooth_streaming = true,
2503                "false" | "0" | "off" | "no" => config.tui_smooth_streaming = false,
2504                _ => config.warnings.push(format!(
2505                    "tui_smooth_streaming = {val} — expected on or off; using on"
2506                )),
2507            },
2508            "process_scopes" => match val {
2509                "true" | "1" | "on" | "yes" | "auto" => config.process_scopes = true,
2510                "false" | "0" | "off" | "no" => config.process_scopes = false,
2511                _ => config.warnings.push(format!(
2512                    "process_scopes = {val} — expected auto or off; keeping the previous value (default auto)"
2513                )),
2514            },
2515            "tool_scope_slice" | "daemon_scope_slice" => {
2516                let field = if key == "tool_scope_slice" {
2517                    &mut config.tool_scope_slice
2518                } else {
2519                    &mut config.daemon_scope_slice
2520                };
2521                // `key =` with nothing after it unsets the key (no warning),
2522                // so a later line can undo an earlier one.
2523                if val.is_empty() {
2524                    *field = None;
2525                } else if let Some(why) = scope_slice_problem(val) {
2526                    config.warnings.push(format!(
2527                        "{key} = {val} — {why}; keeping the previous value (default: no slice, systemd's default placement)"
2528                    ));
2529                } else {
2530                    *field = Some(val.to_string());
2531                }
2532            }
2533            // Read directly by the keybind registry (`read_config_value`);
2534            // recognised here so the /settings row never trips the
2535            // unknown-key warning.
2536            "sidecar_toggle_key" => {}
2537            "agent_name" => config.agent_name = Some(val.to_string()),
2538            "identity" => config.identity = Some(val.to_string()),
2539            "disabled_plugins" => {
2540                config.disabled_plugins = parse_comma_list(val);
2541            }
2542            "favorite_models" => {
2543                config.favorite_models = parse_comma_list(val);
2544            }
2545            "disabled_skills" => {
2546                config.disabled_skills = parse_comma_list(val);
2547            }
2548            "progressive_tool_disclosure" => {
2549                config.progressive_tool_disclosure = matches!(val, "true" | "1" | "on" | "yes");
2550            }
2551            "tools.deferred_loading" => match val {
2552                "on" | "true" | "1" | "yes" => config.tools_deferred_loading = true,
2553                "off" | "false" | "0" | "no" => config.tools_deferred_loading = false,
2554                _ => config.warnings.push(format!(
2555                    "tools.deferred_loading = {val} — expected on or off; keeping the default (on)"
2556                )),
2557            },
2558            "tools.activation_confirm" => match ActivationConfirm::parse(val) {
2559                Some(mode) => config.tools_activation_confirm = mode,
2560                None => config.warnings.push(format!(
2561                    "tools.activation_confirm = {val} — expected auto, prompt or deny; \
2562                     keeping the default (auto)"
2563                )),
2564            },
2565            "session_persistence" => {
2566                match crate::core::session_journal::SessionPersistence::parse(val) {
2567                    Some(mode) => config.session_persistence = mode,
2568                    None => config.warnings.push(format!(
2569                        "session_persistence = {val} — expected json or journal; \
2570                         keeping the default (json)"
2571                    )),
2572                }
2573            }
2574            "disabled_tools" => {
2575                config.disabled_tools = parse_comma_list(val);
2576            }
2577            "memory.backend" => {
2578                config.memory_backend.kind = match val {
2579                    "legacy" => MemoryBackendKind::Legacy,
2580                    "axel" => MemoryBackendKind::Axel,
2581                    _ => {
2582                        invalid_memory_backend = true;
2583                        config.warnings.push(format!(
2584                            "memory.backend = {val} — expected legacy or axel; memory backend unavailable"
2585                        ));
2586                        MemoryBackendKind::Unavailable
2587                    }
2588                };
2589            }
2590            "memory.user_scope" => match val {
2591                "true" => config.memory_backend.user_scope = true,
2592                "false" => config.memory_backend.user_scope = false,
2593                _ => {
2594                    invalid_memory_backend = true;
2595                    config.warnings.push("memory.user_scope — expected true or false; memory backend unavailable".into());
2596                }
2597            },
2598            "memory.axel.executable" | "memory.axel.brain" => {
2599                let path = PathBuf::from(val);
2600                if !path.is_absolute() {
2601                    invalid_memory_backend = true;
2602                    config.warnings.push(format!(
2603                        "{key} — expected an absolute path; memory backend unavailable"
2604                    ));
2605                }
2606                // Retain invalid paths too: direct config consumers must not
2607                // mistake an invalid explicit path for an absent default.
2608                if key == "memory.axel.executable" {
2609                    config.memory_backend.executable = Some(path);
2610                } else {
2611                    config.memory_backend.brain = Some(path);
2612                }
2613            }
2614            _ => {
2615                // Handle namespaced keys
2616                if key.starts_with("shell.") {
2617                    if let Err(diagnostic) = parse_shell_config_key(&mut config.shell, key, val) {
2618                        family_diagnostic(diagnostic);
2619                    }
2620                } else if key.starts_with("server.") {
2621                    parse_server_config_key(&mut config.server, key, val);
2622                } else if key.starts_with("bridge.") {
2623                    parse_bridge_config_key(&mut config.bridge, key, val);
2624                } else if key.starts_with("auth.") {
2625                    parse_auth_config_key(&mut config.auth, key, val);
2626                    if let Some(key) = key.strip_prefix("auth.auto.").filter(|k| !k.trim().is_empty()) {
2627                        let (_, warnings) = crate::auth::AutoSelectionPolicy::from_config_map(
2628                            &BTreeMap::from([(key.to_string(), val.trim().to_string())]));
2629                        config.warnings.extend(warnings);
2630                    }
2631                    if let Some(provider) =
2632                        key.strip_prefix("auth.account.").filter(|p| !p.trim().is_empty())
2633                    {
2634                        let mut one = BTreeMap::new();
2635                        one.insert(provider.trim().to_string(), val.trim().to_string());
2636                        let (_, warnings) =
2637                            crate::core::auth::AccountPolicy::from_config_map(&one);
2638                        config.warnings.extend(warnings);
2639                    }
2640                    if let Some(warning) = unknown_auth_key_warning(key) {
2641                        config.warnings.push(warning);
2642                    }
2643                } else if key.starts_with("events.") {
2644                    if let Err(diagnostic) = parse_events_config_key(&mut config.events, key, val)
2645                    {
2646                        family_diagnostic(diagnostic);
2647                    }
2648                } else if key.starts_with("turn_budget.") {
2649                    match parse_turn_budget_config_key(&mut config.turn_budgets, key, val) {
2650                        Ok(None) => {}
2651                        Ok(Some(warning)) => config.warnings.push(warning),
2652                        Err(diagnostic) => family_diagnostic(diagnostic),
2653                    }
2654                } else if key.starts_with("memory.") {
2655                    if let Err(diagnostic) = parse_memory_config_key(&mut config.memory, key, val)
2656                    {
2657                        family_diagnostic(diagnostic);
2658                    }
2659                } else if key.starts_with("startup.") {
2660                    if let Err(reason) =
2661                        parse_startup_config_key(&mut config.startup, key, val)
2662                    {
2663                        config.warnings.push(format!("{key} — {reason}"));
2664                    }
2665                } else if key.starts_with("daemon.") {
2666                    if let Err(reason) =
2667                        parse_daemon_config_key(&mut config.daemon, key, val)
2668                    {
2669                        config.warnings.push(format!("{key} — {reason}"));
2670                    }
2671                } else if key.starts_with("subagent.") {
2672                    if let Err(reason) =
2673                        parse_subagent_config_key(&mut config.subagent, key, val)
2674                    {
2675                        config.warnings.push(format!("{key} — {reason}"));
2676                    }
2677                } else if key.starts_with("context_management.") {
2678                    if let Err(reason) = parse_context_management_config_key(
2679                        &mut config.context_management, key, val,
2680                    ) {
2681                        invalid_context_management = true;
2682                        config.warnings.push(format!("{key} — {reason}"));
2683                    }
2684                } else if let Some(provider_key) = key.strip_prefix("provider.") {
2685                    config
2686                        .provider_keys
2687                        .insert(provider_key.to_string(), val.to_string());
2688                } else if let Some(keybind_key) = key.strip_prefix("keybind.") {
2689                    config
2690                        .keybinds
2691                        .insert(keybind_key.to_string(), val.to_string());
2692                } else if key.contains('.') {
2693                    // Dotted keys are namespaced (plugin/extension config, e.g.
2694                    // `knowledge.jawz_notes`). Plugins define their own keys —
2695                    // not ours to police. Silently preserved.
2696                } else {
2697                    // Unknown top-level key — warn with a did-you-mean if close.
2698                    match did_you_mean(key) {
2699                        Some(suggestion) => config.warnings.push(format!(
2700                            "unknown key '{key}' (did you mean '{suggestion}'?)"
2701                        )),
2702                        None => config
2703                            .warnings
2704                            .push(format!("unknown key '{key}' — ignored")),
2705                    }
2706                }
2707            }
2708        }
2709    }
2710
2711    // Never leave a partially parsed automatic policy enabled. Syntax errors
2712    // invalidate this whole parse pass, even if a later duplicate is valid.
2713    let context_validation = match config.context_window {
2714        Some(window) => config.context_management.validate_for_window(window),
2715        None => config.context_management.validate(),
2716    };
2717    if let Err(reason) = context_validation {
2718        invalid_context_management = true;
2719        config
2720            .warnings
2721            .push(format!("context_management — {reason}"));
2722    }
2723    // The delegation policy refuses concurrent > total, which would take
2724    // subagents away entirely. Keep the stricter number for both. Checked
2725    // after the loop so key order never matters.
2726    if config.subagent.max_concurrent > config.subagent.max_total {
2727        config.warnings.push(format!(
2728            "subagent.max_concurrent ({}) is above subagent.max_total ({}); using {} for both",
2729            config.subagent.max_concurrent, config.subagent.max_total, config.subagent.max_total
2730        ));
2731        config.subagent.max_concurrent = config.subagent.max_total;
2732    }
2733    if invalid_context_management {
2734        config.context_management = ContextManagementConfig::default();
2735        config.warnings.push(
2736            "invalid context_management configuration — using defaults (mode off)".to_string(),
2737        );
2738    }
2739
2740    // Any invalid backend setting poisons this parse pass, regardless of key
2741    // order or later duplicate selectors. Never silently route to legacy.
2742    if invalid_memory_backend {
2743        config.memory_backend.kind = MemoryBackendKind::Unavailable;
2744    }
2745
2746    // Fail-closed operator-consent rule (Task A2, spec §12): a non-"off"
2747    // `memory.default_mode` takes effect only when the operator also set
2748    // `memory.default_mode_confirmed = true` in this same parse pass.
2749    // Checked after the loop so key order never matters.
2750    if config.memory.default_mode != "off" && !config.memory.default_mode_confirmed {
2751        config.warnings.push(format!(
2752            "memory.default_mode = {} requires memory.default_mode_confirmed = true \
2753             in the same config; reverting to off",
2754            config.memory.default_mode
2755        ));
2756        config.memory.default_mode = "off".to_string();
2757    }
2758
2759    // Derive max_message_size from context_window if not explicitly set.
2760    // Rough estimate: 1 token ≈ 4 bytes. Context window in tokens → bytes.
2761    if config.server.max_message_size.is_none() {
2762        if let Some(ctx_tokens) = config.context_window {
2763            config.server.max_message_size = Some((ctx_tokens as usize) * 4);
2764        }
2765    }
2766}
2767
2768/// Read a single config value by exact key from the active config file.
2769pub fn read_config_value(key: &str) -> Option<String> {
2770    let path = resolve_read_path("config");
2771    let content = std::fs::read_to_string(&path).ok()?;
2772    for line in content.lines() {
2773        let line = line.trim();
2774        if line.is_empty() || line.starts_with('#') {
2775            continue;
2776        }
2777        let Some((k, v)) = line.split_once('=') else {
2778            continue;
2779        };
2780        if k.trim() == key.trim() {
2781            return Some(v.trim().to_string());
2782        }
2783    }
2784    None
2785}
2786
2787/// Exclusive advisory lock on `<config>.lock`, held around every
2788/// read-modify-write of the config file (same pattern as `auth.json`).
2789/// Two writers (`/settings` in two processes, a `/model` favorite toggle
2790/// racing a settings edit) no longer lose each other's updates.
2791///
2792/// Held for the RMW only (< 1 ms); readers (`load_config`) never take it —
2793/// they already tolerate the atomic rename. `SYNAPS_CONFIG_LOCK=0` bypasses.
2794///
2795/// NOT reentrant: flock on a fresh fd of the same file from the same process
2796/// conflicts on Linux. Callers that already hold the guard must use the
2797/// `_locked` inner fns.
2798struct ConfigLock {
2799    _file: Option<std::fs::File>,
2800}
2801
2802impl ConfigLock {
2803    fn acquire() -> std::io::Result<Self> {
2804        if std::env::var("SYNAPS_CONFIG_LOCK").as_deref() == Ok("0") {
2805            return Ok(Self { _file: None });
2806        }
2807        use fs4::fs_std::FileExt;
2808        let lock_path = resolve_write_path("config").with_extension("lock");
2809        if let Some(parent) = lock_path.parent() {
2810            std::fs::create_dir_all(parent)?;
2811        }
2812        let mut open = std::fs::OpenOptions::new();
2813        open.create(true).write(true).truncate(false);
2814        #[cfg(unix)]
2815        {
2816            use std::os::unix::fs::OpenOptionsExt;
2817            open.mode(0o600);
2818        }
2819        let file = open.open(&lock_path)?;
2820        FileExt::lock_exclusive(&file)?;
2821        Ok(Self { _file: Some(file) })
2822    }
2823}
2824
2825/// Write a single `key = value` pair to `~/.synaps-cli/config` (or profile config).
2826/// Replaces the first existing line that matches the key, or appends if absent.
2827/// Preserves comments and unknown keys. Writes atomically via temp file + rename.
2828pub fn write_config_value(key: &str, value: &str) -> std::io::Result<()> {
2829    let _guard = ConfigLock::acquire()?;
2830    write_config_value_locked(key, value)
2831}
2832
2833/// The RMW body; caller holds [`ConfigLock`].
2834fn write_config_value_locked(key: &str, value: &str) -> std::io::Result<()> {
2835    let path = resolve_write_path("config");
2836    let existing = std::fs::read_to_string(&path).unwrap_or_default();
2837    let out = replace_config_line(&existing, key, value);
2838
2839    let tmp = path.with_extension("tmp");
2840    std::fs::write(&tmp, out)?;
2841    // Config may contain API keys — restrict to owner-only
2842    #[cfg(unix)]
2843    {
2844        use std::os::unix::fs::PermissionsExt;
2845        std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o600))?;
2846    }
2847    std::fs::rename(&tmp, &path)?;
2848    Ok(())
2849}
2850
2851/// `existing` with the first `key = …` line replaced by `key = value` (or
2852/// the pair appended), comments and unknown keys preserved — exactly what
2853/// [`write_config_value`] writes.
2854fn replace_config_line(existing: &str, key: &str, value: &str) -> String {
2855    let key_trimmed = key.trim();
2856    let replacement = format!("{} = {}", key_trimmed, value);
2857
2858    let mut found = false;
2859    let mut new_lines: Vec<String> = existing
2860        .lines()
2861        .map(|line| {
2862            if found {
2863                return line.to_string();
2864            }
2865            let t = line.trim_start();
2866            if t.starts_with('#') || t.is_empty() {
2867                return line.to_string();
2868            }
2869            if let Some((k, _)) = t.split_once('=') {
2870                if k.trim() == key_trimmed {
2871                    found = true;
2872                    return replacement.clone();
2873                }
2874            }
2875            line.to_string()
2876        })
2877        .collect();
2878
2879    if !found {
2880        new_lines.push(replacement);
2881    }
2882
2883    let mut out = new_lines.join("\n");
2884    if !out.ends_with('\n') {
2885        out.push('\n');
2886    }
2887    out
2888}
2889
2890/// Add a favorite model id (`provider/model`) to config, preserving sort/dedup.
2891pub fn add_favorite_model(id: &str) -> std::io::Result<()> {
2892    let trimmed = id.trim();
2893    if trimmed.is_empty() {
2894        return Ok(());
2895    }
2896    let _guard = ConfigLock::acquire()?;
2897    let mut values = load_config().favorite_models;
2898    if !values.iter().any(|v| v == trimmed) {
2899        values.push(trimmed.to_string());
2900        values.sort();
2901    }
2902    write_comma_list_locked("favorite_models", &values)
2903}
2904
2905/// Remove a favorite model id (`provider/model`) from config.
2906pub fn remove_favorite_model(id: &str) -> std::io::Result<()> {
2907    let _guard = ConfigLock::acquire()?;
2908    let mut values = load_config().favorite_models;
2909    values.retain(|v| v != id.trim());
2910    write_comma_list_locked("favorite_models", &values)
2911}
2912
2913/// Return whether a model id is marked as favorite.
2914pub fn is_favorite_model(id: &str) -> bool {
2915    load_config().favorite_models.iter().any(|v| v == id.trim())
2916}
2917
2918/// Whether a key matches the secret denylist (case-insensitive).
2919/// Used by the journal save path to ensure secret values never reach disk,
2920/// and by the client env capture to strip secrets before `Hello`.
2921pub fn is_secret_key(key: &str) -> bool {
2922    let upper = key.to_ascii_uppercase();
2923    upper.ends_with("_KEY")
2924        || upper.ends_with("_TOKEN")
2925        || upper.ends_with("_CREDENTIALS")
2926        || upper.ends_with("_API_KEY")
2927        || upper.contains("SECRET")
2928        || upper.contains("PASSWORD")
2929        || upper.contains("PASSWD")
2930        || upper.ends_with("_PAT")
2931        || upper.ends_with("_DSN")
2932}
2933
2934/// Whether an env VALUE carries a credential regardless of its key name:
2935/// any URL with userinfo (`scheme://user:pass@host`, `scheme://key@host`),
2936/// anywhere in the value — `DATABASE_URL`, `HTTPS_PROXY`, a Sentry DSN or a
2937/// JDBC option string all qualify. The key-name denylist alone missed these,
2938/// so their credentials reached the daemon and the session files on disk.
2939/// A username-only URL (`ssh://git@host/…`) is treated as a secret too:
2940/// rare in an environment, and failing safe beats a guess.
2941pub fn is_secret_env_value(value: &str) -> bool {
2942    let mut rest = value;
2943    while let Some(i) = rest.find("://") {
2944        let scheme_ok = rest[..i]
2945            .chars()
2946            .next_back()
2947            .is_some_and(|c| c.is_ascii_alphanumeric());
2948        let after = &rest[i + 3..];
2949        // The authority ends at the path, query or fragment, or at a list /
2950        // option separator when several URLs share one value.
2951        let end = after
2952            .find(|c: char| {
2953                matches!(c, '/' | '?' | '#' | ',' | ';' | '"' | '\'') || c.is_whitespace()
2954            })
2955            .unwrap_or(after.len());
2956        if scheme_ok && after[..end].rfind('@').is_some_and(|at| at > 0) {
2957            return true;
2958        }
2959        rest = after;
2960    }
2961    false
2962}
2963
2964/// Whether an env pair must never reach the daemon or disk: a secret key
2965/// name OR a value carrying credentials (see [`is_secret_env_value`]).
2966pub fn is_secret_env(key: &str, value: &str) -> bool {
2967    is_secret_key(key) || is_secret_env_value(value)
2968}
2969
2970/// Working-habit steering shared by both stock prompts. It keeps output,
2971/// re-reads and file sizes down; those tokens otherwise ride along on every
2972/// later request. Phrased without hard tool names: any tool can be disabled.
2973macro_rules! default_prompt_steering {
2974    ($output_filters:literal) => {
2975        concat!(
2976            " Change existing files with targeted edits rather than rewriting them whole. \
2977            A file you just read or wrote is current in your context; re-read it only if it may have changed. \
2978            Keep files, docs and comments as lean as the task allows. \
2979            Keep command output small (quiet flags, ",
2980            $output_filters,
2981            ") and read large outputs selectively."
2982        )
2983    };
2984}
2985
2986/// Stock system prompt on Windows, used only when no `--system` value and no
2987/// `system.md` exist. Custom prompts never include it.
2988pub const DEFAULT_PROMPT_WINDOWS: &str = concat!(
2989    "You are a helpful AI agent running in a terminal on Windows. \
2990    You have access to powershell, bash, read, and write tools. \
2991    Prefer the powershell tool for system commands and filesystem operations — it is native \
2992    to Windows and avoids quoting/path translation issues. \
2993    Use bash only when the task specifically requires WSL or a Unix environment. \
2994    Be concise and direct. Use tools when the user asks you to interact with the filesystem or run commands.",
2995    default_prompt_steering!("`Select-Object -Last N`, `Select-String`")
2996);
2997
2998/// Stock system prompt on every other platform; see [`DEFAULT_PROMPT_WINDOWS`].
2999pub const DEFAULT_PROMPT_UNIX: &str = concat!(
3000    "You are a helpful AI agent running in a terminal. \
3001    You have access to bash, read, and write tools. \
3002    Be concise and direct. Use tools when the user asks you to interact with the filesystem or run commands.",
3003    default_prompt_steering!("`| tail -n`, `grep`, `head`")
3004);
3005
3006/// The stock system prompt for this platform.
3007#[cfg(windows)]
3008pub const DEFAULT_PROMPT: &str = DEFAULT_PROMPT_WINDOWS;
3009/// The stock system prompt for this platform.
3010#[cfg(not(windows))]
3011pub const DEFAULT_PROMPT: &str = DEFAULT_PROMPT_UNIX;
3012
3013/// Resolve the system prompt from CLI flag, config file, or default.
3014/// Priority: explicit value > ~/.synaps-cli/system.md > built-in default.
3015pub fn resolve_system_prompt(explicit: Option<&str>) -> String {
3016    if let Some(val) = explicit {
3017        let path = std::path::Path::new(val);
3018        if path.exists() && path.is_file() {
3019            return std::fs::read_to_string(path).unwrap_or_else(|_| val.to_string());
3020        }
3021        return val.to_string();
3022    }
3023
3024    let system_path = resolve_read_path("system.md");
3025    if system_path.exists() {
3026        return std::fs::read_to_string(&system_path).unwrap_or_default();
3027    }
3028
3029    DEFAULT_PROMPT.to_string()
3030}
3031
3032#[cfg(test)]
3033mod tests {
3034    #[test]
3035    fn tools_deferred_loading_defaults_on_and_parses() {
3036        assert!(super::load_config_from_str("").tools_deferred_loading);
3037        assert!(
3038            !super::load_config_from_str("tools.deferred_loading = off\n").tools_deferred_loading
3039        );
3040        let bad = super::load_config_from_str("tools.deferred_loading = maybe\n");
3041        assert!(
3042            bad.tools_deferred_loading,
3043            "invalid value keeps the default"
3044        );
3045        assert!(bad
3046            .warnings
3047            .iter()
3048            .any(|w| w.contains("tools.deferred_loading")));
3049    }
3050
3051    #[test]
3052    fn secret_env_value_detects_credential_urls() {
3053        use super::is_secret_env_value as v;
3054        // user:password in the authority
3055        assert!(v("postgres://app:hunter2@db.internal:5432/prod"));
3056        assert!(v("redis://:pw@cache:6379/0"));
3057        assert!(v("http://user:pass@proxy.corp:3128"));
3058        assert!(v(
3059            "mongodb+srv://u:p@cluster0.example.net/db?retryWrites=true"
3060        ));
3061        // a key as the username, no password (Sentry DSN)
3062        assert!(v("https://abc123def456@o1.ingest.sentry.io/42"));
3063        // anywhere in the value, not only at the start; lists
3064        assert!(v("-Dspring.datasource.url=jdbc:postgresql://u:p@h/db"));
3065        assert!(v("redis://h1:6379,redis://u:p@h2:6379"));
3066        // no userinfo → not a secret
3067        assert!(!v("https://api.example.com/v1/items?q=a@b"));
3068        assert!(!v("http://host/path/with@sign"));
3069        assert!(!v("https://@host/empty-userinfo"));
3070        assert!(!v("git@github.com:org/repo.git"));
3071        assert!(!v("mailto:someone@example.com"));
3072        assert!(!v("someone@example.com"));
3073        assert!(!v("/usr/local/bin:/usr/bin"));
3074        assert!(!v(""));
3075    }
3076
3077    #[test]
3078    fn secret_key_covers_pat_and_dsn() {
3079        assert!(super::is_secret_key("GITHUB_PAT"));
3080        assert!(super::is_secret_key("gh_pat"));
3081        assert!(super::is_secret_key("SENTRY_DSN"));
3082        assert!(!super::is_secret_key("PATH"));
3083        assert!(!super::is_secret_key("PATTERN_DIR"));
3084    }
3085
3086    #[test]
3087    fn secret_env_is_key_or_value() {
3088        assert!(super::is_secret_env(
3089            "DATABASE_URL",
3090            "postgres://app:hunter2@db/prod"
3091        ));
3092        assert!(super::is_secret_env("GH_TOKEN", "anything"));
3093        assert!(!super::is_secret_env("DATABASE_URL", "postgres://db/prod"));
3094        assert!(!super::is_secret_env("HOME", "/home/u"));
3095    }
3096    #[test]
3097    fn startup_and_daemon_defaults() {
3098        let c = super::load_config_from_str("");
3099        assert!(c.startup.quick_start, "quick_start defaults ON");
3100        assert_eq!(c.startup.extensions_ready_timeout_secs, 30);
3101        assert_eq!(c.daemon.idle_exit_secs, 10);
3102        assert_eq!(c.daemon.prompt_abandon_secs, 3600);
3103        assert_eq!(c.daemon.parked_evict_secs, 3600);
3104        // No warnings for the absent keys.
3105        assert!(c.warnings.is_empty(), "warnings: {:?}", c.warnings);
3106    }
3107
3108    #[test]
3109    fn startup_and_daemon_parse_all_keys() {
3110        let c = super::load_config_from_str(concat!(
3111            "startup.quick_start = off\n",
3112            "startup.extensions_ready_timeout_secs = 45\n",
3113            "daemon.idle_exit_secs = 20\n",
3114            "daemon.prompt_abandon_secs = 0\n",
3115            "daemon.parked_evict_secs = 120\n",
3116        ));
3117        assert!(!c.startup.quick_start);
3118        assert_eq!(c.startup.extensions_ready_timeout_secs, 45);
3119        assert_eq!(c.daemon.idle_exit_secs, 20);
3120        assert_eq!(c.daemon.prompt_abandon_secs, 0);
3121        assert_eq!(c.daemon.parked_evict_secs, 120);
3122        assert!(
3123            !c.warnings.iter().any(|w| w.contains("unknown")),
3124            "no unknown-key warning: {:?}",
3125            c.warnings
3126        );
3127    }
3128
3129    #[test]
3130    fn subagent_limits_default_to_16_64_4() {
3131        let c = super::load_config_from_str("");
3132        assert_eq!(c.subagent, super::SubagentLimits::default());
3133        assert_eq!(c.subagent.max_concurrent, 16);
3134        assert_eq!(c.subagent.max_total, 64);
3135        assert_eq!(c.subagent.max_depth, 4);
3136        assert!(c.warnings.is_empty(), "warnings: {:?}", c.warnings);
3137    }
3138
3139    #[test]
3140    fn subagent_limits_parse_and_are_known_keys() {
3141        let c = super::load_config_from_str(concat!(
3142            "subagent.max_concurrent = 24\n",
3143            "subagent.max_total = 100\n",
3144            "subagent.max_depth = 2\n",
3145            "subagent_timeout = 600\n",
3146        ));
3147        assert_eq!(c.subagent.max_concurrent, 24);
3148        assert_eq!(c.subagent.max_total, 100);
3149        assert_eq!(c.subagent.max_depth, 2);
3150        assert_eq!(c.subagent_timeout, 600, "the old flat key is unaffected");
3151        assert!(c.warnings.is_empty(), "warnings: {:?}", c.warnings);
3152        for k in [
3153            "subagent.max_concurrent",
3154            "subagent.max_total",
3155            "subagent.max_depth",
3156        ] {
3157            assert!(
3158                super::KNOWN_CONFIG_KEYS.contains(&k),
3159                "{k} not in KNOWN_CONFIG_KEYS"
3160            );
3161        }
3162    }
3163
3164    #[test]
3165    fn subagent_cold_resume_compact_tokens_parses_defaults_and_warns() {
3166        let c = super::load_config_from_str("");
3167        assert_eq!(c.subagent_cold_resume_compact_tokens, 100_000);
3168        assert_eq!(
3169            super::SynapsConfig::default().subagent_cold_resume_compact_tokens,
3170            super::DEFAULT_SUBAGENT_COLD_RESUME_COMPACT_TOKENS
3171        );
3172        let c = super::load_config_from_str("subagent_cold_resume_compact_tokens = 250000\n");
3173        assert_eq!(c.subagent_cold_resume_compact_tokens, 250_000);
3174        assert!(c.warnings.is_empty(), "warnings: {:?}", c.warnings);
3175        let c = super::load_config_from_str("subagent_cold_resume_compact_tokens = 0\n");
3176        assert_eq!(c.subagent_cold_resume_compact_tokens, 0, "0 disables");
3177        assert!(c.warnings.is_empty(), "warnings: {:?}", c.warnings);
3178        let c = super::load_config_from_str("subagent_cold_resume_compact_tokens = lots\n");
3179        assert_eq!(c.subagent_cold_resume_compact_tokens, 100_000);
3180        assert!(
3181            c.warnings
3182                .iter()
3183                .any(|w| w.contains("subagent_cold_resume_compact_tokens")),
3184            "warnings: {:?}",
3185            c.warnings
3186        );
3187        assert!(super::KNOWN_CONFIG_KEYS.contains(&"subagent_cold_resume_compact_tokens"));
3188    }
3189
3190    #[test]
3191    fn subagent_transform_hooks_key_parses_defaults_on_and_warns() {
3192        assert!(super::load_config_from_str("").subagent_transform_hooks, "default on");
3193        assert!(super::SynapsConfig::default().subagent_transform_hooks);
3194        for (line, want) in [
3195            ("subagent_transform_hooks = off\n", false),
3196            ("subagent_transform_hooks = false\n", false),
3197            ("subagent_transform_hooks = on\n", true),
3198            ("subagent_transform_hooks = true\n", true),
3199        ] {
3200            let c = super::load_config_from_str(line);
3201            assert_eq!(c.subagent_transform_hooks, want, "{line}");
3202            assert!(c.warnings.is_empty(), "{line}: {:?}", c.warnings);
3203        }
3204        let c = super::load_config_from_str("subagent_transform_hooks = maybe\n");
3205        assert!(c.subagent_transform_hooks, "bad value keeps the default");
3206        assert!(c.warnings.iter().any(|w| w.contains("subagent_transform_hooks")));
3207    }
3208
3209    #[test]
3210    fn subagent_rollover_keys_parse_default_and_warn() {
3211        use super::ContextManagementMode;
3212        let c = super::load_config_from_str("");
3213        assert_eq!(c.subagent_context_management, ContextManagementMode::Auto);
3214        assert_eq!(c.subagent_rollover_tokens, 200_000);
3215        let d = super::SynapsConfig::default();
3216        assert_eq!(d.subagent_context_management, ContextManagementMode::Auto);
3217        assert_eq!(d.subagent_rollover_tokens, super::DEFAULT_SUBAGENT_ROLLOVER_TOKENS);
3218
3219        let c = super::load_config_from_str(
3220            "subagent_context_management = OFF\nsubagent_rollover_tokens = 150000\n",
3221        );
3222        assert_eq!(c.subagent_context_management, ContextManagementMode::Off);
3223        assert_eq!(c.subagent_rollover_tokens, 150_000);
3224        assert!(c.warnings.is_empty(), "warnings: {:?}", c.warnings);
3225        let c = super::load_config_from_str("subagent_rollover_tokens = 20000\n");
3226        assert_eq!(c.subagent_rollover_tokens, 20_000, "the floor itself is accepted");
3227        assert!(c.warnings.is_empty(), "warnings: {:?}", c.warnings);
3228
3229        for (line, key) in [
3230            ("subagent_context_management = sometimes", "subagent_context_management"),
3231            ("subagent_rollover_tokens = lots", "subagent_rollover_tokens"),
3232            ("subagent_rollover_tokens = 19999", "subagent_rollover_tokens"),
3233            ("subagent_rollover_tokens = 0", "subagent_rollover_tokens"),
3234        ] {
3235            let c = super::load_config_from_str(line);
3236            assert_eq!(c.subagent_context_management, ContextManagementMode::Auto, "{line}");
3237            assert_eq!(c.subagent_rollover_tokens, 200_000, "{line}");
3238            assert!(
3239                c.warnings.iter().any(|w| w.contains(key)),
3240                "{line}: warnings {:?}",
3241                c.warnings
3242            );
3243        }
3244        for key in ["subagent_context_management", "subagent_rollover_tokens"] {
3245            assert!(super::KNOWN_CONFIG_KEYS.contains(&key), "{key}");
3246        }
3247    }
3248
3249    #[test]
3250    fn legacy_flat_subagent_limit_keys_alias_the_dotted_keys() {
3251        let c = super::load_config_from_str(concat!(
3252            "subagent_max_concurrent = 256\n",
3253            "subagent_max_total = 1024\n",
3254        ));
3255        assert_eq!(c.subagent.max_concurrent, 256);
3256        assert_eq!(c.subagent.max_total, 1024);
3257        assert!(c.warnings.is_empty(), "warnings: {:?}", c.warnings);
3258        let c = super::load_config_from_str("subagent_max_concurrent = 0\n");
3259        assert_eq!(c.subagent, super::SubagentLimits::default());
3260        assert!(c.warnings.iter().any(|w| w.starts_with("subagent_max_concurrent")));
3261    }
3262
3263    #[test]
3264    fn subagent_limits_reject_zero_and_junk_and_keep_defaults() {
3265        let c = super::load_config_from_str(concat!(
3266            "subagent.max_concurrent = 0\n",
3267            "subagent.max_total = lots\n",
3268            "subagent.max_depth = 70000\n",
3269            "subagent.max_children = 4\n",
3270        ));
3271        assert_eq!(c.subagent, super::SubagentLimits::default());
3272        for key in [
3273            "subagent.max_concurrent",
3274            "subagent.max_total",
3275            "subagent.max_depth",
3276            "subagent.max_children",
3277        ] {
3278            assert!(
3279                c.warnings.iter().any(|w| w.starts_with(key)),
3280                "expected a warning for {key}: {:?}",
3281                c.warnings
3282            );
3283        }
3284    }
3285
3286    #[test]
3287    fn subagent_concurrency_above_total_uses_the_stricter_number() {
3288        // Either key order: the check runs after the whole file is parsed.
3289        for text in [
3290            "subagent.max_total = 10\nsubagent.max_concurrent = 32\n",
3291            "subagent.max_concurrent = 32\nsubagent.max_total = 10\n",
3292        ] {
3293            let c = super::load_config_from_str(text);
3294            assert_eq!(c.subagent.max_concurrent, 10);
3295            assert_eq!(c.subagent.max_total, 10);
3296            assert!(
3297                c.warnings
3298                    .iter()
3299                    .any(|w| w.contains("subagent.max_concurrent (32)")),
3300                "{:?}",
3301                c.warnings
3302            );
3303        }
3304        // Lowering only the total below the default concurrency also clamps.
3305        let c = super::load_config_from_str("subagent.max_total = 5\n");
3306        assert_eq!(c.subagent.max_concurrent, 5);
3307    }
3308
3309    #[test]
3310    fn process_scopes_defaults_on_and_parses_auto_and_off() {
3311        assert!(super::load_config_from_str("").process_scopes);
3312        assert!(super::load_config_from_str("process_scopes = auto\n").process_scopes);
3313        let off = super::load_config_from_str("process_scopes = off\n");
3314        assert!(!off.process_scopes);
3315        assert!(off.warnings.is_empty(), "{:?}", off.warnings);
3316        let bad = super::load_config_from_str("process_scopes = sometimes\n");
3317        assert!(bad.process_scopes, "an invalid value keeps the default");
3318        assert!(bad.warnings.iter().any(|w| w.starts_with("process_scopes")));
3319        assert!(super::KNOWN_CONFIG_KEYS.contains(&"process_scopes"));
3320    }
3321
3322    #[test]
3323    fn an_invalid_repeated_process_scopes_keeps_the_previous_value() {
3324        let c = super::load_config_from_str("process_scopes = off\nprocess_scopes = sometimes\n");
3325        assert!(
3326            c.warnings.iter().any(|w| w.starts_with("process_scopes = sometimes") && w.contains("keeping the previous value")),
3327            "{:?}",
3328            c.warnings
3329        );
3330        assert!(!c.process_scopes, "the earlier valid value is kept");
3331    }
3332
3333    #[test]
3334    fn an_invalid_repeated_scope_slice_keeps_the_previous_value() {
3335        for key in ["tool_scope_slice", "daemon_scope_slice"] {
3336            let c = super::load_config_from_str(&format!("{key} = a.slice\n{key} = worker\n"));
3337            assert!(
3338                c.warnings.iter().any(|w| w.starts_with(&format!("{key} = worker")) && w.contains("keeping the previous value")),
3339                "{key}: {:?}",
3340                c.warnings
3341            );
3342            let kept = if key == "tool_scope_slice" { &c.tool_scope_slice } else { &c.daemon_scope_slice };
3343            assert_eq!(kept.as_deref(), Some("a.slice"), "{key}: the earlier valid value is kept");
3344        }
3345    }
3346
3347    #[test]
3348    fn scope_slices_default_unset_and_parse_valid_names() {
3349        let c = super::load_config_from_str("");
3350        assert_eq!((c.tool_scope_slice, c.daemon_scope_slice), (None, None));
3351        let c = super::load_config_from_str(
3352            "tool_scope_slice = synaps_tools.slice\ndaemon_scope_slice = synaps:daemon_1.slice\n",
3353        );
3354        assert!(c.warnings.is_empty(), "{:?}", c.warnings);
3355        assert_eq!(c.tool_scope_slice.as_deref(), Some("synaps_tools.slice"));
3356        assert_eq!(c.daemon_scope_slice.as_deref(), Some("synaps:daemon_1.slice"));
3357        // A nested name is valid too (the docs say why a flat one is better).
3358        let c = super::load_config_from_str("tool_scope_slice = synaps-tools.slice\n");
3359        assert_eq!((c.warnings.len(), c.tool_scope_slice.as_deref()), (0, Some("synaps-tools.slice")));
3360        // Each key sets only its own field.
3361        let c = super::load_config_from_str("tool_scope_slice = a.slice\n");
3362        assert_eq!((c.tool_scope_slice.as_deref(), c.daemon_scope_slice.as_deref()), (Some("a.slice"), None));
3363        let c = super::load_config_from_str("daemon_scope_slice = d.slice\n");
3364        assert_eq!((c.tool_scope_slice.as_deref(), c.daemon_scope_slice.as_deref()), (None, Some("d.slice")));
3365        let longest = format!("{}.slice", "a".repeat(255 - ".slice".len()));
3366        assert_eq!(super::scope_slice_problem(&longest), None);
3367        for k in ["tool_scope_slice", "daemon_scope_slice"] {
3368            assert!(super::KNOWN_CONFIG_KEYS.contains(&k), "{k} not in KNOWN_CONFIG_KEYS");
3369        }
3370    }
3371
3372    #[test]
3373    fn an_invalid_scope_slice_warns_and_keeps_the_default() {
3374        let too_long = format!("{}.slice", "a".repeat(256 - ".slice".len()));
3375        // Every invalid value the function or the parser lets through.
3376        let mut accepted: Vec<String> = Vec::new();
3377        for bad in [
3378            "worker",            // no .slice suffix
3379            "worker.service",    // another unit type
3380            "a/b.slice",         // a path
3381            "work er.slice",     // a space
3382            "work@er.slice",     // outside [A-Za-z0-9:_.-]
3383            "-.slice",           // the root slice
3384            "-worker.slice",     // a leading '-'
3385            "--worker.slice",
3386            ".slice",            // no name
3387            "worker--agent.slice",
3388            "worker-.slice",
3389            too_long.as_str(),   // 256 bytes
3390            "wörker.slice",      // non-ASCII
3391            "worker\0.slice",    // NUL
3392            "\"worker-agent.slice\"", // quoted: the file is not TOML
3393            "'worker-agent.slice'",
3394            "5",                 // other types: a number, a boolean
3395            "true",
3396        ] {
3397            if super::scope_slice_problem(bad).is_none() {
3398                accepted.push(bad.to_string());
3399            }
3400            for key in ["tool_scope_slice", "daemon_scope_slice"] {
3401                let c = super::load_config_from_str(&format!("{key} = {bad}\n"));
3402                let unset = (c.tool_scope_slice.as_deref(), c.daemon_scope_slice.as_deref()) == (None, None);
3403                if !unset || !c.warnings.iter().any(|w| w.starts_with(&format!("{key} = "))) {
3404                    accepted.push(format!("{key} = {bad}"));
3405                }
3406            }
3407        }
3408        assert!(accepted.is_empty(), "accepted: {accepted:?}");
3409    }
3410
3411    #[test]
3412    fn an_empty_scope_slice_unsets_the_key_without_a_warning() {
3413        for key in ["tool_scope_slice", "daemon_scope_slice"] {
3414            for empty in [format!("{key} ="), format!("{key} =   "), format!("  {key}=")] {
3415                // Alone: unset, silently.
3416                let c = super::load_config_from_str(&format!("{empty}\n"));
3417                assert!(c.warnings.is_empty(), "{empty:?}: {:?}", c.warnings);
3418                assert_eq!((c.tool_scope_slice.as_deref(), c.daemon_scope_slice.as_deref()), (None, None), "{empty:?}");
3419                // After a valid line: that line is undone, the other key kept.
3420                let c = super::load_config_from_str(&format!(
3421                    "tool_scope_slice = synaps_tools.slice\ndaemon_scope_slice = synaps_daemon.slice\n{empty}\n"
3422                ));
3423                assert!(c.warnings.is_empty(), "{empty:?}: {:?}", c.warnings);
3424                let (unset, kept) = if key == "tool_scope_slice" {
3425                    (&c.tool_scope_slice, c.daemon_scope_slice.as_deref())
3426                } else {
3427                    (&c.daemon_scope_slice, c.tool_scope_slice.as_deref())
3428                };
3429                assert_eq!(unset, &None, "{empty:?}");
3430                assert!(kept.is_some_and(|k| k.starts_with("synaps_")), "{empty:?}: {kept:?}");
3431            }
3432        }
3433    }
3434
3435    #[test]
3436    fn a_quoted_scope_slice_says_to_drop_the_quotes() {
3437        for quoted in ["\"worker-agent.slice\"", "'worker-agent.slice'", "\"worker-agent.slice", "worker-agent.slice'"] {
3438            let why = super::scope_slice_problem(quoted);
3439            assert!(why.is_some_and(|w| w.contains("without quotes")), "{quoted}: {why:?}");
3440        }
3441    }
3442
3443    #[test]
3444    fn startup_quick_start_accepts_on() {
3445        let c = super::load_config_from_str("startup.quick_start = on\n");
3446        assert!(c.startup.quick_start);
3447    }
3448
3449    #[test]
3450    fn startup_and_daemon_keys_are_known() {
3451        for k in [
3452            "startup.quick_start",
3453            "startup.extensions_ready_timeout_secs",
3454            "daemon.idle_exit_secs",
3455            "daemon.prompt_abandon_secs",
3456            "daemon.parked_evict_secs",
3457        ] {
3458            assert!(super::KNOWN_CONFIG_KEYS.contains(&k), "{k} not in KNOWN_CONFIG_KEYS");
3459        }
3460    }
3461
3462    #[test]
3463    fn startup_daemon_bad_values_warn_and_keep_defaults() {
3464        let c = super::load_config_from_str(concat!(
3465            "startup.quick_start = maybe\n",
3466            "daemon.idle_exit_secs = soon\n",
3467        ));
3468        assert!(c.startup.quick_start, "invalid bool keeps default");
3469        assert_eq!(c.daemon.idle_exit_secs, 10, "invalid u64 keeps default");
3470        assert!(c.warnings.iter().any(|w| w.contains("startup.quick_start")));
3471        assert!(c.warnings.iter().any(|w| w.contains("daemon.idle_exit_secs")));
3472    }
3473
3474    #[test]
3475    fn context_management_defaults_off_and_parses_all_keys() {
3476        use super::{ContextManagementConfig, ContextManagementMode};
3477        assert_eq!(
3478            super::load_config_from_str("").context_management,
3479            ContextManagementConfig::default()
3480        );
3481        let parsed = super::load_config_from_str(
3482            "context_management.mode = AUTO\n\
3483             context_management.pressure_tokens = 250000\n\
3484             context_management.rollover_tokens = 400000\n\
3485             context_management.reserve_tokens = 24000\n\
3486             context_management.finish_rounds = 3\n\
3487             context_window = 1m\n",
3488        );
3489        assert_eq!(
3490            parsed.context_management,
3491            ContextManagementConfig {
3492                mode: ContextManagementMode::Auto,
3493                pressure_tokens: Some(250_000),
3494                rollover_tokens: Some(400_000),
3495                reserve_tokens: 24_000,
3496                finish_rounds: 3,
3497                target_tokens: None,
3498            }
3499        );
3500        assert!(parsed.warnings.is_empty(), "{:?}", parsed.warnings);
3501        assert!(parsed.provider_keys.is_empty());
3502        assert!(super::load_config_from_str("context_management.mode = off")
3503            .warnings
3504            .is_empty());
3505    }
3506
3507    #[test]
3508    fn context_management_auto_clears_overrides_and_allows_bounded_extremes() {
3509        for rounds in [0, super::ContextManagementConfig::MAX_FINISH_ROUNDS] {
3510            let parsed = super::load_config_from_str(&format!(
3511                "context_management.mode = auto\n\
3512                 context_management.pressure_tokens = 300000\n\
3513                 context_management.rollover_tokens = 450000\n\
3514                 context_management.pressure_tokens = auto\n\
3515                 context_management.rollover_tokens = AUTO\n\
3516                 context_management.finish_rounds = {rounds}\n"
3517            ));
3518            assert_eq!(parsed.context_management.pressure_tokens, None);
3519            assert_eq!(parsed.context_management.rollover_tokens, None);
3520            assert_eq!(parsed.context_management.finish_rounds, rounds);
3521            assert!(parsed.warnings.is_empty());
3522        }
3523    }
3524
3525    #[test]
3526    fn context_management_invalid_values_reset_entire_policy() {
3527        for (key, value) in [
3528            ("mode", "on"),
3529            ("pressure_tokens", "0"),
3530            ("pressure_tokens", "-1"),
3531            ("pressure_tokens", "250k"),
3532            ("pressure_tokens", "18446744073709551616"),
3533            ("rollover_tokens", "1"),
3534            ("rollover_tokens", "nope"),
3535            ("reserve_tokens", "0"),
3536            ("reserve_tokens", "1000001"),
3537            ("reserve_tokens", "auto"),
3538            ("finish_rounds", "9"),
3539            ("finish_rounds", "-1"),
3540            ("finish_rounds", "4294967296"),
3541            ("finish_rounds", ""),
3542            ("mod", "auto"),
3543        ] {
3544            let parsed = super::load_config_from_str(&format!(
3545                "context_management.mode = auto\ncontext_management.{key} = {value}\n"
3546            ));
3547            assert_eq!(
3548                parsed.context_management,
3549                super::ContextManagementConfig::default(),
3550                "{key}={value}"
3551            );
3552            assert!(!parsed.warnings.is_empty(), "{key}={value}");
3553            assert!(parsed.provider_keys.is_empty());
3554        }
3555        let duplicate = super::load_config_from_str(
3556            "context_management.mode = auto\ncontext_management.finish_rounds = bad\ncontext_management.finish_rounds = 2"
3557        );
3558        assert_eq!(
3559            duplicate.context_management,
3560            super::ContextManagementConfig::default()
3561        );
3562    }
3563
3564    #[test]
3565    fn context_management_target_tokens_parses_and_validates() {
3566        assert_eq!(
3567            super::load_config_from_str("")
3568                .context_management
3569                .target_tokens,
3570            None
3571        );
3572        let parsed = super::load_config_from_str(
3573            "context_management.mode = auto\ncontext_management.target_tokens = 120000\ncontext_window = 1m\n",
3574        );
3575        assert!(parsed.warnings.is_empty(), "{:?}", parsed.warnings);
3576        assert_eq!(parsed.context_management.target_tokens, Some(120_000));
3577        let auto = super::load_config_from_str(
3578            "context_management.target_tokens = 5000\ncontext_management.target_tokens = auto\n",
3579        );
3580        assert_eq!(auto.context_management.target_tokens, None);
3581        for fields in [
3582            "target_tokens = 0",
3583            "target_tokens = nope",
3584            "target_tokens = 250000",
3585            "pressure_tokens = 200000\ncontext_management.target_tokens = 200000",
3586        ] {
3587            let parsed = super::load_config_from_str(&format!(
3588                "context_management.mode = auto\ncontext_management.{fields}\ncontext_window = 1m\n"
3589            ));
3590            assert_eq!(
3591                parsed.context_management,
3592                super::ContextManagementConfig::default(),
3593                "{fields}"
3594            );
3595            assert!(!parsed.warnings.is_empty(), "{fields}");
3596        }
3597    }
3598
3599    #[test]
3600    fn context_management_validates_final_threshold_order_and_window() {
3601        for fields in [
3602            "pressure_tokens = 400000\ncontext_management.rollover_tokens = 400000",
3603            "pressure_tokens = 500000\ncontext_management.rollover_tokens = 400000",
3604            "pressure_tokens = 1000000",
3605            "rollover_tokens = 1000001",
3606        ] {
3607            for window_first in [false, true] {
3608                let policy =
3609                    format!("context_management.mode = auto\ncontext_management.{fields}\n");
3610                let content = if window_first {
3611                    format!("context_window = 1m\n{policy}")
3612                } else {
3613                    format!("{policy}context_window = 1m\n")
3614                };
3615                let parsed = super::load_config_from_str(&content);
3616                assert_eq!(
3617                    parsed.context_management,
3618                    super::ContextManagementConfig::default()
3619                );
3620                assert!(!parsed.warnings.is_empty());
3621            }
3622        }
3623        // Temporarily reversed overrides are valid once the whole file is parsed.
3624        let parsed = super::load_config_from_str(
3625            "context_management.mode = auto\n\
3626             context_management.rollover_tokens = 100000\n\
3627             context_management.pressure_tokens = 300000\n\
3628             context_management.rollover_tokens = 450000\ncontext_window = 1m",
3629        );
3630        assert!(parsed.warnings.is_empty());
3631        assert_eq!(parsed.context_management.rollover_tokens, Some(450_000));
3632    }
3633
3634    #[test]
3635    fn context_management_parsed_finish_override_bounds_rollover_extension() {
3636        use crate::core::context_policy::{
3637            assess_context, ContextAction, ContextBudget, ContextState, WorkPhase,
3638        };
3639        let config = super::load_config_from_str(
3640            "context_management.mode = auto\n\
3641             context_management.finish_rounds = 1\ncontext_window = 1m",
3642        );
3643        let budget = ContextBudget {
3644            context_window_tokens: 1_000_000,
3645            used_tokens: 400_000,
3646            hard_remaining_tokens: 600_000,
3647            required_next_round_tokens: 8_000,
3648        };
3649        let mut state = ContextState::default();
3650        state.report_phase(WorkPhase::Plan);
3651        let first = assess_context(&config.context_management, &state, budget);
3652        assert_eq!(
3653            first.action,
3654            ContextAction::FinishBounded {
3655                rounds_remaining: 0
3656            }
3657        );
3658        state = first.next_state;
3659        state.report_phase(WorkPhase::WrapUp);
3660        assert_eq!(
3661            assess_context(&config.context_management, &state, budget).action,
3662            ContextAction::Rollover
3663        );
3664    }
3665
3666    #[test]
3667    fn context_management_keys_are_known_for_suggestions() {
3668        for key in [
3669            "mode",
3670            "pressure_tokens",
3671            "rollover_tokens",
3672            "reserve_tokens",
3673            "finish_rounds",
3674        ] {
3675            let full = format!("context_management.{key}");
3676            assert!(super::KNOWN_CONFIG_KEYS.contains(&full.as_str()));
3677            assert_eq!(
3678                super::did_you_mean(&format!("{full}x")),
3679                Some(full.as_str())
3680            );
3681        }
3682    }
3683
3684    #[test]
3685    fn context_management_parsed_policy_rolls_over_before_executing_350k_plan() {
3686        use crate::core::context_policy::{
3687            assess_context, ContextAction, ContextBudget, ContextState, WorkPhase,
3688        };
3689        let config =
3690            super::load_config_from_str("context_management.mode = auto\ncontext_window = 1m");
3691        let budget = ContextBudget {
3692            context_window_tokens: config.context_window.unwrap(),
3693            used_tokens: 350_000,
3694            hard_remaining_tokens: 650_000,
3695            required_next_round_tokens: 8_000,
3696        };
3697        let mut state = ContextState::default();
3698        state.report_phase(WorkPhase::Plan);
3699        let plan = assess_context(&config.context_management, &state, budget);
3700        assert_eq!(plan.action, ContextAction::Advisory);
3701        state = plan.next_state;
3702        state.report_phase(WorkPhase::Execute);
3703        assert_eq!(
3704            assess_context(&config.context_management, &state, budget).action,
3705            ContextAction::Rollover
3706        );
3707    }
3708
3709    #[test]
3710    fn thinking_display_defaults_summarized_and_parses() {
3711        use super::ThinkingDisplay;
3712        assert_eq!(super::load_config_from_str("").thinking_display, ThinkingDisplay::Summarized);
3713        assert_eq!(
3714            super::load_config_from_str("thinking_display = omitted\n").thinking_display,
3715            ThinkingDisplay::Omitted
3716        );
3717        assert_eq!(
3718            super::load_config_from_str("thinking_display = Summarized\n").thinking_display,
3719            ThinkingDisplay::Summarized
3720        );
3721        let invalid = super::load_config_from_str("thinking_display = raw\n");
3722        assert_eq!(invalid.thinking_display, ThinkingDisplay::Summarized, "invalid keeps the default");
3723        assert!(invalid.warnings.iter().any(|w| w.contains("thinking_display")));
3724        assert_eq!(ThinkingDisplay::Omitted.as_str(), "omitted");
3725    }
3726
3727    #[test]
3728    fn tui_smooth_streaming_defaults_on_and_parses() {
3729        assert!(super::load_config_from_str("").tui_smooth_streaming);
3730        assert!(!super::load_config_from_str("tui_smooth_streaming = off\n").tui_smooth_streaming);
3731        assert!(super::load_config_from_str("tui_smooth_streaming = on\n").tui_smooth_streaming);
3732        let invalid = super::load_config_from_str("tui_smooth_streaming = jerky\n");
3733        assert!(invalid.tui_smooth_streaming, "invalid keeps the default (on)");
3734        assert!(invalid
3735            .warnings
3736            .iter()
3737            .any(|w| w.contains("tui_smooth_streaming")));
3738    }
3739
3740    #[test]
3741    fn tui_streaming_glow_defaults_off_and_parses() {
3742        assert!(!super::load_config_from_str("").tui_streaming_glow);
3743        assert!(super::load_config_from_str("tui_streaming_glow = on\n").tui_streaming_glow);
3744        assert!(!super::load_config_from_str("tui_streaming_glow = off\n").tui_streaming_glow);
3745        let invalid = super::load_config_from_str("tui_streaming_glow = sparkly\n");
3746        assert!(
3747            !invalid.tui_streaming_glow,
3748            "invalid keeps the default (off)"
3749        );
3750        assert!(invalid
3751            .warnings
3752            .iter()
3753            .any(|w| w.contains("tui_streaming_glow")));
3754    }
3755
3756    #[test]
3757    fn tui_background_opaque_defaults_and_parses_supported_values() {
3758        assert!(super::load_config_from_str("").tui_background_opaque);
3759        assert!(
3760            super::load_config_from_str("tui_background_opaque = opaque\n").tui_background_opaque
3761        );
3762        assert!(
3763            !super::load_config_from_str("tui_background_opaque = invisible\n")
3764                .tui_background_opaque
3765        );
3766        let invalid = super::load_config_from_str("tui_background_opaque = translucent\n");
3767        assert!(invalid.tui_background_opaque);
3768        assert!(invalid
3769            .warnings
3770            .iter()
3771            .any(|w| w.contains("tui_background_opaque")));
3772    }
3773
3774    #[test]
3775    fn turn_budget_keys_parse_typed_per_role() {
3776        let config = super::load_config_from_str(
3777            "turn_budget.worker.max_provider_rounds = 3\n\
3778             turn_budget.worker.max_cost_usd = 0.25\n\
3779             turn_budget.autonomous.max_elapsed_secs = 90\n\
3780             turn_budget.foreground.max_tool_calls = 7\n\
3781             turn_budget.foreground.max_round_renewals = 12\n\
3782             turn_budget.worker.max_provider_rounds_bogus = 1\n\
3783             turn_budget.nosuchrole.max_tool_calls = 1\n",
3784        );
3785        assert_eq!(
3786            config.turn_budgets.worker.max_provider_rounds,
3787            Some(super::TurnLimit::Max(3))
3788        );
3789        assert_eq!(config.turn_budgets.worker.max_cost_usd, Some(0.25));
3790        assert_eq!(config.turn_budgets.autonomous.max_elapsed_secs, Some(90));
3791        assert_eq!(
3792            config.turn_budgets.foreground.max_tool_calls,
3793            Some(super::TurnLimit::Max(7))
3794        );
3795        assert_eq!(config.turn_budgets.foreground.max_round_renewals, Some(12));
3796        // Unknown fields/roles warn and change nothing.
3797        assert_eq!(config.turn_budgets.worker.max_tool_calls, None);
3798        // Invalid values keep the default (None).
3799        let bad = super::load_config_from_str("turn_budget.worker.max_provider_rounds = nope\n");
3800        assert_eq!(bad.turn_budgets.worker.max_provider_rounds, None);
3801    }
3802
3803    #[test]
3804    fn turn_budget_count_limits_accept_unlimited() {
3805        let config = super::load_config_from_str(
3806            "turn_budget.autonomous.max_provider_rounds = unlimited\n\
3807             turn_budget.autonomous.max_tool_calls = \"unlimited\"\n\
3808             turn_budget.worker.max_accumulated_tool_result_bytes = Unlimited\n",
3809        );
3810        assert!(config.warnings.is_empty(), "{:?}", config.warnings);
3811        let unlimited = Some(super::TurnLimit::Unlimited);
3812        assert_eq!(config.turn_budgets.autonomous.max_provider_rounds, unlimited);
3813        assert_eq!(config.turn_budgets.autonomous.max_tool_calls, unlimited);
3814        assert_eq!(
3815            config.turn_budgets.worker.max_accumulated_tool_result_bytes,
3816            Some(super::TurnLimit::Unlimited)
3817        );
3818        let bad = super::load_config_from_str("turn_budget.worker.max_tool_calls = lots\n");
3819        assert_eq!(bad.turn_budgets.worker.max_tool_calls, None);
3820    }
3821
3822    #[test]
3823    fn turn_budget_zero_count_limits_keep_none_allowed_with_a_deprecation_warning() {
3824        // 0.10.2 read `0` as "none allowed". For one release it keeps that
3825        // meaning in every role (also where the default is unlimited), and
3826        // says it will be rejected later.
3827        for role in ["foreground", "autonomous", "event", "worker"] {
3828            for field in [
3829                "max_provider_rounds",
3830                "max_tool_calls",
3831                "max_accumulated_tool_result_bytes",
3832            ] {
3833                let key = format!("turn_budget.{role}.{field}");
3834                let content = format!("{key} = 0\n");
3835                let config = super::load_config_from_str(&content);
3836                let overrides = match role {
3837                    "foreground" => &config.turn_budgets.foreground,
3838                    "autonomous" => &config.turn_budgets.autonomous,
3839                    "event" => &config.turn_budgets.event,
3840                    _ => &config.turn_budgets.worker,
3841                };
3842                let parsed = match field {
3843                    "max_provider_rounds" => overrides.max_provider_rounds.map(|l| l.map(|v| v as usize)),
3844                    "max_tool_calls" => overrides.max_tool_calls.map(|l| l.map(|v| v as usize)),
3845                    _ => overrides.max_accumulated_tool_result_bytes,
3846                };
3847                assert_eq!(parsed, Some(super::TurnLimit::Max(0)), "{key}");
3848                assert!(
3849                    config.warnings.iter().any(|w| w.contains(&key)
3850                        && w.contains("none allowed")
3851                        && w.contains(
3852                            "`0` will be rejected in a future release; use `unlimited` for no limit, or a positive number"
3853                        )),
3854                    "{key}: {:?}",
3855                    config.warnings
3856                );
3857                assert!(!super::config_content_problems(&content).is_empty());
3858            }
3859        }
3860    }
3861
3862    #[test]
3863    fn memory_keys_all_parse_with_consent() {
3864        let config = super::load_config_from_str(
3865            "memory.default_mode = capture_and_recall\n\
3866             memory.default_mode_confirmed = true\n\
3867             memory.recall_max_records = 12\n\
3868             memory.recall_max_tokens = 2048\n\
3869             memory.recall_timeout_ms = 250\n\
3870             memory.capture_tools = off\n\
3871             memory.capture_assistant = false\n\
3872             memory.capture_user = false\n\
3873             memory.auto_consolidate = on\n\
3874             memory.local_embeddings = on\n\
3875             memory.gliner = on\n",
3876        );
3877        assert_eq!(config.memory.default_mode, "capture_and_recall");
3878        assert!(config.memory.default_mode_confirmed);
3879        assert_eq!(config.memory.recall_max_records, 12);
3880        assert_eq!(config.memory.recall_max_tokens, 2048);
3881        assert_eq!(config.memory.recall_timeout_ms, 250);
3882        assert_eq!(config.memory.capture_tools, "off");
3883        assert!(!config.memory.capture_assistant);
3884        assert!(!config.memory.capture_user);
3885        assert_eq!(config.memory.auto_consolidate, "on");
3886        assert_eq!(config.memory.local_embeddings, "on");
3887        assert_eq!(config.memory.gliner, "on");
3888    }
3889
3890    #[test]
3891    fn memory_defaults_match_spec_12() {
3892        let config = super::load_config_from_str("");
3893        assert_eq!(config.memory, super::MemoryConfig::default());
3894        assert_eq!(config.memory.default_mode, "off");
3895        assert!(!config.memory.default_mode_confirmed);
3896        assert_eq!(config.memory.recall_max_records, 8);
3897        assert_eq!(config.memory.recall_max_tokens, 4096);
3898        assert_eq!(config.memory.recall_timeout_ms, 150);
3899        assert_eq!(config.memory.capture_tools, "summary_only");
3900        assert!(config.memory.capture_assistant);
3901        assert!(config.memory.capture_user);
3902        assert_eq!(config.memory.auto_consolidate, "off");
3903        assert_eq!(config.memory.local_embeddings, "off");
3904        assert_eq!(config.memory.gliner, "off");
3905    }
3906
3907    #[test]
3908    fn memory_unknown_values_fail_closed_to_defaults() {
3909        let config = super::load_config_from_str(
3910            "memory.default_mode = recall_everything_forever\n\
3911             memory.default_mode_confirmed = true\n\
3912             memory.recall_max_records = lots\n\
3913             memory.recall_max_tokens = -5\n\
3914             memory.recall_timeout_ms = fast\n\
3915             memory.capture_tools = full\n\
3916             memory.capture_assistant = maybe\n\
3917             memory.capture_user = sometimes\n\
3918             memory.auto_consolidate = aggressive\n\
3919             memory.local_embeddings = auto\n\
3920             memory.gliner = download\n\
3921             memory.nosuchfield = 1\n",
3922        );
3923        // Unrecognised mode string must stay "off" — never a non-off mode.
3924        assert_eq!(config.memory.default_mode, "off");
3925        assert_eq!(config.memory.recall_max_records, 8);
3926        assert_eq!(config.memory.recall_max_tokens, 4096);
3927        assert_eq!(config.memory.recall_timeout_ms, 150);
3928        assert_eq!(config.memory.capture_tools, "summary_only");
3929        assert!(config.memory.capture_assistant);
3930        assert!(config.memory.capture_user);
3931        assert_eq!(config.memory.auto_consolidate, "off");
3932        assert_eq!(config.memory.local_embeddings, "off");
3933        assert_eq!(config.memory.gliner, "off");
3934    }
3935
3936    #[test]
3937    fn memory_default_mode_without_consent_reverts_to_off() {
3938        let config = super::load_config_from_str("memory.default_mode = recall_each_prompt\n");
3939        assert_eq!(config.memory.default_mode, "off");
3940        assert!(
3941            config
3942                .warnings
3943                .iter()
3944                .any(|w| w.contains("memory.default_mode_confirmed")),
3945            "reversion must surface a warning; got: {:?}",
3946            config.warnings
3947        );
3948
3949        // Explicit false consent is not consent.
3950        let denied = super::load_config_from_str(
3951            "memory.default_mode = capture_only\n\
3952             memory.default_mode_confirmed = false\n",
3953        );
3954        assert_eq!(denied.memory.default_mode, "off");
3955
3956        // Garbage consent value fails closed — not consent.
3957        let garbage = super::load_config_from_str(
3958            "memory.default_mode = capture_only\n\
3959             memory.default_mode_confirmed = definitely\n",
3960        );
3961        assert_eq!(garbage.memory.default_mode, "off");
3962    }
3963
3964    #[test]
3965    fn memory_consent_is_order_independent() {
3966        // Consent key before the mode key.
3967        let before = super::load_config_from_str(
3968            "memory.default_mode_confirmed = true\n\
3969             memory.default_mode = recall_once\n",
3970        );
3971        assert_eq!(before.memory.default_mode, "recall_once");
3972        // Consent key after the mode key.
3973        let after = super::load_config_from_str(
3974            "memory.default_mode = recall_once\n\
3975             memory.default_mode_confirmed = true\n",
3976        );
3977        assert_eq!(after.memory.default_mode, "recall_once");
3978    }
3979
3980    #[test]
3981    fn memory_backend_defaults_are_independent_of_consent() {
3982        let config = super::load_config_from_str("");
3983        assert_eq!(config.memory_backend, super::MemoryBackendConfig::default());
3984        assert_eq!(config.memory_backend.kind, super::MemoryBackendKind::Legacy);
3985        let axel = super::load_config_from_str("memory.backend = axel\n");
3986        assert_eq!(axel.memory_backend.kind, super::MemoryBackendKind::Axel);
3987        assert_eq!(axel.memory, config.memory);
3988        assert!(axel.warnings.is_empty());
3989    }
3990
3991    #[test]
3992    fn user_wide_notes_require_explicit_valid_opt_in() {
3993        let default = super::load_config_from_str("memory.backend = axel\n");
3994        assert!(!default.memory_backend.user_scope);
3995        let enabled =
3996            super::load_config_from_str("memory.backend = axel\nmemory.user_scope = true\n");
3997        assert!(enabled.memory_backend.user_scope);
3998        assert_eq!(enabled.memory, default.memory);
3999        for bad in ["yes", "", "1", "TRUE"] {
4000            let config = super::load_config_from_str(&format!(
4001                "memory.user_scope = {bad}\nmemory.backend = axel\n"
4002            ));
4003            assert_eq!(
4004                config.memory_backend.kind,
4005                super::MemoryBackendKind::Unavailable
4006            );
4007        }
4008    }
4009
4010    #[test]
4011    fn memory_backend_invalid_selector_fails_closed() {
4012        for content in [
4013            "memory.backend = typo\n",
4014            "memory.backend = \n",
4015            "memory.backend = unavailable\n",
4016            "memory.backend = axel\nmemory.backend = typo\n",
4017            "memory.backend = typo\nmemory.backend = legacy\n",
4018        ] {
4019            let config = super::load_config_from_str(content);
4020            assert_eq!(
4021                config.memory_backend.kind,
4022                super::MemoryBackendKind::Unavailable
4023            );
4024            assert!(config
4025                .warnings
4026                .iter()
4027                .any(|w| w.contains("memory backend unavailable")));
4028        }
4029    }
4030
4031    #[test]
4032    fn memory_backend_absolute_paths_are_preserved() {
4033        let root = std::env::current_dir().unwrap();
4034        let executable = root.join("fixture bin/axel");
4035        let brain = root.join("fixture brain");
4036        let config = super::load_config_from_str(&format!(
4037            "memory.axel.brain = {}\nmemory.backend = axel\nmemory.axel.executable = {}\n",
4038            brain.display(),
4039            executable.display()
4040        ));
4041        assert_eq!(config.memory_backend.kind, super::MemoryBackendKind::Axel);
4042        assert_eq!(config.memory_backend.executable, Some(executable));
4043        assert_eq!(config.memory_backend.brain, Some(brain));
4044        assert!(config.warnings.is_empty());
4045    }
4046
4047    #[test]
4048    fn memory_backend_relative_or_empty_paths_fail_closed_in_any_order() {
4049        for key in ["memory.axel.executable", "memory.axel.brain"] {
4050            for path in ["relative/path", "~/brain", ""] {
4051                for content in [
4052                    format!("memory.backend = axel\n{key} = {path}\n"),
4053                    format!("{key} = {path}\nmemory.backend = legacy\n"),
4054                ] {
4055                    let config = super::load_config_from_str(&content);
4056                    assert_eq!(
4057                        config.memory_backend.kind,
4058                        super::MemoryBackendKind::Unavailable
4059                    );
4060                    assert!(config.warnings.iter().any(|w| w.contains("absolute path")));
4061                }
4062            }
4063        }
4064    }
4065
4066    #[test]
4067    fn memory_config_surface_is_closed_by_construction() {
4068        // Exhaustive struct literal — no `..Default::default()` spread. This
4069        // fails to COMPILE if any field is added to or removed from
4070        // MemoryConfig, pinning the exact memory.* config surface. Secret,
4071        // retention, and project-scope semantics have no field here, so no
4072        // `memory.secret*` / `memory.retention*` / `memory.project*` config
4073        // key can exist (spec §12: those rules are not configurable to fail
4074        // open). Asserted by construction, not by test disproof.
4075        let _closed_surface = super::MemoryConfig {
4076            default_mode: "off".to_string(),
4077            default_mode_confirmed: false,
4078            recall_max_records: 8,
4079            recall_max_tokens: 4096,
4080            recall_timeout_ms: 150,
4081            capture_tools: "summary_only".to_string(),
4082            capture_assistant: true,
4083            capture_user: true,
4084            auto_consolidate: "off".to_string(),
4085            local_embeddings: "off".to_string(),
4086            gliner: "off".to_string(),
4087        };
4088    }
4089
4090    use super::*;
4091    use serial_test::serial;
4092
4093    #[test]
4094    fn test_levenshtein_basics() {
4095        assert_eq!(levenshtein("model", "model"), 0);
4096        assert_eq!(levenshtein("modle", "model"), 2);
4097        assert_eq!(levenshtein("them", "theme"), 1);
4098    }
4099
4100    #[test]
4101    fn test_did_you_mean_close_typos() {
4102        assert_eq!(did_you_mean("modle"), Some("model"));
4103        assert_eq!(did_you_mean("them"), Some("theme"));
4104        assert_eq!(did_you_mean("thinkng"), Some("thinking"));
4105        assert_eq!(did_you_mean("completely_unrelated_key"), None);
4106    }
4107
4108    #[test]
4109    #[serial]
4110    fn test_config_warnings_unknown_key_and_bad_values() {
4111        let home = std::env::temp_dir().join(format!("synaps-warn-test-{}", std::process::id()));
4112        let dir = home.join(".synaps-cli");
4113        std::fs::create_dir_all(&dir).unwrap();
4114        std::fs::write(dir.join("config"), "modle = claude-opus-4-6\nthinking = hgih\nbash_timeout = 0\nknowledge.jawz_notes = ~/Jawz/notes\ncustom.plugin.key = 42\n").unwrap();
4115
4116        with_home(&home, || {
4117            let config = load_config();
4118            // Dotted (namespaced) keys must NOT warn — plugins own those.
4119            assert_eq!(config.warnings.len(), 3, "warnings: {:?}", config.warnings);
4120            assert!(
4121                !config.warnings.iter().any(|w| w.contains("knowledge")),
4122                "{:?}",
4123                config.warnings
4124            );
4125            assert!(
4126                config
4127                    .warnings
4128                    .iter()
4129                    .any(|w| w.contains("did you mean 'model'")),
4130                "{:?}",
4131                config.warnings
4132            );
4133            assert!(
4134                config.warnings.iter().any(|w| w.contains("thinking")),
4135                "{:?}",
4136                config.warnings
4137            );
4138            assert!(
4139                config.warnings.iter().any(|w| w.contains("below minimum")),
4140                "{:?}",
4141                config.warnings
4142            );
4143            // Bad values fall back to defaults
4144            assert_eq!(config.bash_timeout, 30);
4145            assert_eq!(config.thinking_budget, None);
4146        });
4147        let _ = std::fs::remove_dir_all(&home);
4148    }
4149
4150    #[test]
4151    fn test_client_input_is_shared_by_default_and_parses() {
4152        assert_eq!(SynapsConfig::default().client_input, ClientInput::Shared);
4153        assert_eq!(load_config_from_str("").client_input, ClientInput::Shared);
4154        assert_eq!(load_config_from_str("client_input = exclusive\n").client_input, ClientInput::Exclusive);
4155        assert_eq!(load_config_from_str("client_input = Shared\n").client_input, ClientInput::Shared);
4156        let c = load_config_from_str("client_input = mine\n");
4157        assert_eq!(c.client_input, ClientInput::Shared);
4158        assert!(c.warnings.iter().any(|w| w.contains("client_input")), "{:?}", c.warnings);
4159        assert_eq!(ClientInput::wire_default(), ClientInput::Exclusive, "an older daemon enforces the owner");
4160    }
4161
4162    // ── cache_keepwarm (task #444) ──
4163
4164    #[test]
4165    fn test_cache_keepwarm_is_off_by_default() {
4166        assert_eq!(SynapsConfig::default().cache_keepwarm, CacheKeepWarm::Off);
4167        assert_eq!(load_config_from_str("").cache_keepwarm, CacheKeepWarm::Off);
4168    }
4169
4170    #[test]
4171    fn test_cache_keepwarm_parse_table() {
4172        for (raw, want) in [
4173            ("off", Some(CacheKeepWarm::Off)),
4174            ("OFF", Some(CacheKeepWarm::Off)),
4175            ("0h", Some(CacheKeepWarm::Off)),
4176            ("auto", Some(CacheKeepWarm::Auto)),
4177            (" Auto ", Some(CacheKeepWarm::Auto)),
4178            ("1h", Some(CacheKeepWarm::Hours(1))),
4179            ("12H", Some(CacheKeepWarm::Hours(12))),
4180            ("48h", Some(CacheKeepWarm::Hours(MAX_CACHE_KEEPWARM_HOURS))),
4181            ("49h", None),
4182            ("12", None),
4183            ("h", None),
4184            ("-1h", None),
4185            ("lots", None),
4186        ] {
4187            assert_eq!(CacheKeepWarm::parse(raw), want, "{raw:?}");
4188        }
4189        for mode in [CacheKeepWarm::Off, CacheKeepWarm::Auto, CacheKeepWarm::Hours(6)] {
4190            assert_eq!(CacheKeepWarm::parse(&mode.to_string()), Some(mode));
4191        }
4192    }
4193
4194    #[test]
4195    fn test_telemetry_log_generations_config_key() {
4196        let c = load_config_from_str("telemetry = basic\n");
4197        assert_eq!(c.telemetry_log_generations, 1, "default: one generation");
4198
4199        let c = load_config_from_str("telemetry_log_generations = 7\n");
4200        assert_eq!(c.telemetry_log_generations, 7);
4201        assert!(c.warnings.is_empty(), "warnings: {:?}", c.warnings);
4202
4203        for bad in ["0", "101", "-1", "lots"] {
4204            let c = load_config_from_str(&format!("telemetry_log_generations = {bad}\n"));
4205            assert_eq!(c.telemetry_log_generations, 1, "{bad}");
4206            assert!(
4207                c.warnings
4208                    .iter()
4209                    .any(|w| w.contains("telemetry_log_generations")),
4210                "{bad}: {:?}",
4211                c.warnings
4212            );
4213        }
4214    }
4215
4216    #[test]
4217    fn test_cache_keepwarm_headless_key() {
4218        assert!(!SynapsConfig::default().cache_keepwarm_headless, "default off");
4219        assert!(!load_config_from_str("").cache_keepwarm_headless);
4220        for on in ["true", "1", "on", "yes", "TRUE"] {
4221            let c = load_config_from_str(&format!("cache_keepwarm_headless = {on}\n"));
4222            assert!(c.cache_keepwarm_headless, "{on}");
4223            assert!(c.warnings.is_empty(), "{on}: {:?}", c.warnings);
4224        }
4225        for off in ["false", "0", "off", "no"] {
4226            let c = load_config_from_str(&format!("cache_keepwarm_headless = {off}\n"));
4227            assert!(!c.cache_keepwarm_headless, "{off}");
4228        }
4229        let c = load_config_from_str("cache_keepwarm_headless = maybe\n");
4230        assert!(!c.cache_keepwarm_headless, "garbage keeps it off");
4231        assert!(c.warnings.iter().any(|w| w.contains("cache_keepwarm_headless")));
4232    }
4233
4234    #[test]
4235    fn test_cache_keepwarm_tools_key() {
4236        assert!(SynapsConfig::default().cache_keepwarm_tools, "default on");
4237        assert!(load_config_from_str("").cache_keepwarm_tools);
4238        for off in ["false", "0", "off", "no", "OFF"] {
4239            let c = load_config_from_str(&format!("cache_keepwarm_tools = {off}\n"));
4240            assert!(!c.cache_keepwarm_tools, "{off}");
4241            assert!(c.warnings.is_empty(), "{off}: {:?}", c.warnings);
4242        }
4243        for on in ["true", "1", "on", "yes"] {
4244            assert!(load_config_from_str(&format!("cache_keepwarm_tools = {on}\n")).cache_keepwarm_tools);
4245        }
4246        let c = load_config_from_str("cache_keepwarm_tools = maybe\n");
4247        assert!(c.cache_keepwarm_tools, "garbage keeps it on");
4248        assert!(c.warnings.iter().any(|w| w.contains("cache_keepwarm_tools")));
4249    }
4250
4251    #[test]
4252    fn test_cache_keepwarm_config_key() {
4253        let c = load_config_from_str("cache_keepwarm = auto\ncache_ttl = 5m\n");
4254        assert_eq!(c.cache_keepwarm, CacheKeepWarm::Auto);
4255        assert_eq!(c.cache_ttl, CacheTtlSetting::Fixed(CacheTtl::FiveMinutes));
4256        assert!(c.warnings.is_empty(), "warnings: {:?}", c.warnings);
4257
4258        let c = load_config_from_str("cache_keepwarm = 6h\n");
4259        assert_eq!(c.cache_keepwarm, CacheKeepWarm::Hours(6));
4260
4261        assert_eq!(c.cache_keepwarm_auto_max_hours, 24, "default auto cap");
4262
4263        let c = load_config_from_str("cache_keepwarm_auto_max_hours = 8\n");
4264        assert_eq!(c.cache_keepwarm_auto_max_hours, 8);
4265        assert!(c.warnings.is_empty(), "warnings: {:?}", c.warnings);
4266        let c = load_config_from_str("cache_keepwarm_auto_max_hours = 12h\n");
4267        assert_eq!(c.cache_keepwarm_auto_max_hours, 12);
4268        for bad in ["0", "49", "lots"] {
4269            let c = load_config_from_str(&format!("cache_keepwarm_auto_max_hours = {bad}\n"));
4270            assert_eq!(c.cache_keepwarm_auto_max_hours, 24, "{bad}");
4271            assert!(
4272                c.warnings.iter().any(|w| w.contains("auto_max_hours")),
4273                "{bad}"
4274            );
4275        }
4276
4277        let c = load_config_from_str("cache_keepwarm = forever\n");
4278        assert_eq!(c.cache_keepwarm, CacheKeepWarm::Off, "garbage keeps keep-warm off");
4279        assert!(c.warnings.iter().any(|w| w.contains("cache_keepwarm")));
4280    }
4281
4282    #[test]
4283    fn request_compression_keys_parse_and_warn() {
4284        let c = load_config_from_str("");
4285        assert_eq!(c.request_compression, RequestCompressionMode::Auto);
4286        assert_eq!(c.request_compression_level, DEFAULT_REQUEST_COMPRESSION_LEVEL);
4287
4288        for (val, want) in [
4289            ("auto", RequestCompressionMode::Auto),
4290            ("ON", RequestCompressionMode::On),
4291            (" off ", RequestCompressionMode::Off),
4292        ] {
4293            let c = load_config_from_str(&format!("request_compression = {val}\n"));
4294            assert_eq!(c.request_compression, want, "{val:?}");
4295            assert!(c.warnings.is_empty(), "{val:?}: {:?}", c.warnings);
4296            assert_eq!(RequestCompressionMode::parse(&want.to_string()), Some(want));
4297        }
4298        let c = load_config_from_str("request_compression = brotli\n");
4299        assert_eq!(c.request_compression, RequestCompressionMode::Auto);
4300        assert!(c.warnings.iter().any(|w| w.contains("request_compression")));
4301
4302        for level in 1..=MAX_REQUEST_COMPRESSION_LEVEL {
4303            let c = load_config_from_str(&format!("request_compression_level = {level}\n"));
4304            assert_eq!(c.request_compression_level, level);
4305            assert!(c.warnings.is_empty());
4306        }
4307        for bad in ["0", "10", "max", "-1"] {
4308            let c = load_config_from_str(&format!("request_compression_level = {bad}\n"));
4309            assert_eq!(c.request_compression_level, DEFAULT_REQUEST_COMPRESSION_LEVEL, "{bad}");
4310            assert!(
4311                c.warnings.iter().any(|w| w.contains("request_compression_level")),
4312                "{bad}"
4313            );
4314        }
4315    }
4316
4317    // ── cache_ttl parse table (spec §3.1) ──
4318
4319    #[test]
4320    fn test_cache_ttl_parse_table() {
4321        // 5m aliases
4322        assert_eq!(CacheTtl::parse("5m"), Some(CacheTtl::FiveMinutes));
4323        assert_eq!(CacheTtl::parse("5min"), Some(CacheTtl::FiveMinutes));
4324        assert_eq!(CacheTtl::parse("default"), Some(CacheTtl::FiveMinutes));
4325        // 1h aliases
4326        assert_eq!(CacheTtl::parse("1h"), Some(CacheTtl::OneHour));
4327        assert_eq!(CacheTtl::parse("60m"), Some(CacheTtl::OneHour));
4328        assert_eq!(CacheTtl::parse("1hr"), Some(CacheTtl::OneHour));
4329        // hybrid
4330        assert_eq!(CacheTtl::parse("hybrid"), Some(CacheTtl::Hybrid));
4331        // case-insensitive
4332        assert_eq!(CacheTtl::parse("1H"), Some(CacheTtl::OneHour));
4333        assert_eq!(CacheTtl::parse("HYBRID"), Some(CacheTtl::Hybrid));
4334        assert_eq!(CacheTtl::parse("Default"), Some(CacheTtl::FiveMinutes));
4335        // garbage → None (caller warns + defaults)
4336        assert_eq!(CacheTtl::parse("2h"), None);
4337        assert_eq!(CacheTtl::parse(""), None);
4338        assert_eq!(CacheTtl::parse("forever"), None);
4339    }
4340
4341    #[test]
4342    fn test_cache_ttl_default_is_auto() {
4343        // The wire policy's own default stays the historical bare 5m marker;
4344        // the *config* default is `auto` (resolved per credential kind).
4345        assert_eq!(CacheTtl::default(), CacheTtl::FiveMinutes);
4346        assert_eq!(CacheTtlSetting::default(), CacheTtlSetting::Auto);
4347        assert_eq!(SynapsConfig::default().cache_ttl, CacheTtlSetting::Auto);
4348        // An empty config file leaves it at auto.
4349        assert_eq!(load_config_from_str("").cache_ttl, CacheTtlSetting::Auto);
4350    }
4351
4352    #[test]
4353    fn test_cache_ttl_setting_parse_and_round_trip() {
4354        assert_eq!(CacheTtlSetting::parse("auto"), Some(CacheTtlSetting::Auto));
4355        assert_eq!(CacheTtlSetting::parse(" AUTO "), Some(CacheTtlSetting::Auto));
4356        assert_eq!(
4357            CacheTtlSetting::parse("5m"),
4358            Some(CacheTtlSetting::Fixed(CacheTtl::FiveMinutes))
4359        );
4360        assert_eq!(
4361            CacheTtlSetting::parse("1hr"),
4362            Some(CacheTtlSetting::Fixed(CacheTtl::OneHour))
4363        );
4364        assert_eq!(
4365            CacheTtlSetting::parse("hybrid"),
4366            Some(CacheTtlSetting::Fixed(CacheTtl::Hybrid))
4367        );
4368        for bad in ["", "2h", "forever", "automatic", "on"] {
4369            assert_eq!(CacheTtlSetting::parse(bad), None, "{bad:?}");
4370        }
4371        // as_str → parse is the identity for every value, and the written
4372        // config line reloads to the same setting.
4373        for setting in [
4374            CacheTtlSetting::Auto,
4375            CacheTtlSetting::Fixed(CacheTtl::FiveMinutes),
4376            CacheTtlSetting::Fixed(CacheTtl::OneHour),
4377            CacheTtlSetting::Fixed(CacheTtl::Hybrid),
4378        ] {
4379            assert_eq!(CacheTtlSetting::parse(setting.as_str()), Some(setting));
4380            assert_eq!(setting.to_string(), setting.as_str());
4381            let cfg = load_config_from_str(&format!("cache_ttl = {setting}\n"));
4382            assert_eq!(cfg.cache_ttl, setting);
4383            assert!(cfg.warnings.is_empty(), "{setting}: {:?}", cfg.warnings);
4384        }
4385    }
4386
4387    #[test]
4388    fn test_cache_ttl_setting_resolution() {
4389        // auto: subscription (OAuth) → 1h, API key → 5m.
4390        assert_eq!(CacheTtlSetting::Auto.resolve(true), CacheTtl::OneHour);
4391        assert_eq!(CacheTtlSetting::Auto.resolve(false), CacheTtl::FiveMinutes);
4392        assert_eq!(
4393            CacheTtlSetting::Auto.resolve_for_auth_type("oauth"),
4394            CacheTtl::OneHour
4395        );
4396        for not_oauth in ["api_key", "none", "", "OAuth"] {
4397            assert_eq!(
4398                CacheTtlSetting::Auto.resolve_for_auth_type(not_oauth),
4399                CacheTtl::FiveMinutes,
4400                "{not_oauth:?}"
4401            );
4402        }
4403        // Explicit values win over the credential kind, both ways.
4404        for ttl in [CacheTtl::FiveMinutes, CacheTtl::OneHour, CacheTtl::Hybrid] {
4405            for oauth in [true, false] {
4406                assert_eq!(CacheTtlSetting::Fixed(ttl).resolve(oauth), ttl);
4407            }
4408        }
4409    }
4410
4411    #[test]
4412    fn test_max_fps_default_is_60() {
4413        assert_eq!(SynapsConfig::default().max_fps, 60);
4414    }
4415
4416    // ── theme_transition parse table (animated theme transitions) ──
4417
4418    #[test]
4419    fn test_theme_transition_parse_table() {
4420        use ThemeTransitionMode as M;
4421        assert_eq!(M::parse("on"), Some(M::On));
4422        assert_eq!(M::parse("ON"), Some(M::On));
4423        assert_eq!(M::parse("off"), Some(M::Off));
4424        assert_eq!(M::parse("Off"), Some(M::Off));
4425        // integer ms, clamped to 0..=2000
4426        assert_eq!(M::parse("500"), Some(M::Ms(500)));
4427        assert_eq!(M::parse("0"), Some(M::Ms(0)));
4428        assert_eq!(M::parse("2000"), Some(M::Ms(2000)));
4429        assert_eq!(M::parse("99999"), Some(M::Ms(2000)), "clamped to MAX_MS");
4430        // garbage → None (caller warns + keeps default)
4431        assert_eq!(M::parse("fast"), None);
4432        assert_eq!(M::parse(""), None);
4433        assert_eq!(M::parse("-5"), None);
4434        assert_eq!(M::parse("1.5"), None);
4435    }
4436
4437    #[test]
4438    fn test_theme_transition_duration_ms() {
4439        use ThemeTransitionMode as M;
4440        assert_eq!(M::On.duration_ms(), M::DEFAULT_MS);
4441        assert_eq!(M::DEFAULT_MS, 350);
4442        assert_eq!(M::Off.duration_ms(), 0);
4443        assert_eq!(M::Ms(120).duration_ms(), 120);
4444        assert_eq!(M::Ms(0).duration_ms(), 0, "0 ms behaves like off");
4445        assert_eq!(M::Ms(u64::MAX).duration_ms(), M::MAX_MS);
4446    }
4447
4448    #[test]
4449    fn test_theme_transition_config_key_parses_and_defaults() {
4450        use ThemeTransitionMode as M;
4451        assert_eq!(SynapsConfig::default().theme_transition, M::On);
4452        assert_eq!(
4453            super::load_config_from_str("theme_transition = off\n").theme_transition,
4454            M::Off
4455        );
4456        assert_eq!(
4457            super::load_config_from_str("theme_transition = 750\n").theme_transition,
4458            M::Ms(750)
4459        );
4460        let bad = super::load_config_from_str("theme_transition = warp\n");
4461        assert_eq!(bad.theme_transition, M::On, "bad value keeps default");
4462        assert!(
4463            bad.warnings.iter().any(|w| w.contains("theme_transition")),
4464            "{:?}",
4465            bad.warnings
4466        );
4467    }
4468
4469    #[test]
4470    #[serial]
4471    fn test_max_fps_config_parse_and_validation() {
4472        let home = std::env::temp_dir().join(format!("synaps-maxfps-test-{}", std::process::id()));
4473        let dir = home.join(".synaps-cli");
4474        std::fs::create_dir_all(&dir).unwrap();
4475
4476        // Valid high-refresh value parses, no warning.
4477        std::fs::write(dir.join("config"), "max_fps = 144\n").unwrap();
4478        with_home(&home, || {
4479            let config = load_config();
4480            assert_eq!(config.max_fps, 144);
4481            assert!(
4482                config.warnings.is_empty(),
4483                "warnings: {:?}",
4484                config.warnings
4485            );
4486        });
4487
4488        // Out-of-range (0) → default 60 + boot warning (never blocks boot).
4489        std::fs::write(dir.join("config"), "max_fps = 0\n").unwrap();
4490        with_home(&home, || {
4491            let config = load_config();
4492            assert_eq!(config.max_fps, 60);
4493            assert!(
4494                config.warnings.iter().any(|w| w.contains("max_fps")),
4495                "warnings: {:?}",
4496                config.warnings
4497            );
4498        });
4499
4500        // Non-numeric → default 60 + warning.
4501        std::fs::write(dir.join("config"), "max_fps = fast\n").unwrap();
4502        with_home(&home, || {
4503            let config = load_config();
4504            assert_eq!(config.max_fps, 60);
4505            assert!(
4506                config.warnings.iter().any(|w| w.contains("max_fps")),
4507                "warnings: {:?}",
4508                config.warnings
4509            );
4510        });
4511
4512        let _ = std::fs::remove_dir_all(&home);
4513    }
4514
4515    #[test]
4516    #[serial]
4517    fn test_cache_ttl_config_parse_and_garbage_warning() {
4518        let home =
4519            std::env::temp_dir().join(format!("synaps-cachettl-test-{}", std::process::id()));
4520        let dir = home.join(".synaps-cli");
4521        std::fs::create_dir_all(&dir).unwrap();
4522
4523        // Valid value parses, no warning.
4524        std::fs::write(dir.join("config"), "cache_ttl = hybrid\n").unwrap();
4525        with_home(&home, || {
4526            let config = load_config();
4527            assert_eq!(config.cache_ttl, CacheTtlSetting::Fixed(CacheTtl::Hybrid));
4528            assert!(
4529                config.warnings.is_empty(),
4530                "warnings: {:?}",
4531                config.warnings
4532            );
4533        });
4534
4535        // Garbage value → 5m default + boot warning (never blocks boot).
4536        std::fs::write(dir.join("config"), "cache_ttl = 2h\n").unwrap();
4537        with_home(&home, || {
4538            let config = load_config();
4539            assert_eq!(config.cache_ttl, CacheTtlSetting::Auto);
4540            assert!(
4541                config.warnings.iter().any(|w| w.contains("cache_ttl")),
4542                "warnings: {:?}",
4543                config.warnings
4544            );
4545        });
4546
4547        let _ = std::fs::remove_dir_all(&home);
4548    }
4549
4550    #[test]
4551    fn subagent_thinking_config_is_independent_and_validated() {
4552        use crate::core::reasoning::ReasoningLevel;
4553        let cfg = load_config_from_str("thinking = low\n");
4554        assert_eq!(cfg.subagent_thinking, ReasoningLevel::XHigh);
4555        for value in ["low", "medium", "high", "xhigh", "max"] {
4556            let cfg = load_config_from_str(&format!("thinking = high\nsubagent_thinking = {value}\n"));
4557            assert_eq!(cfg.subagent_thinking.as_str(), value);
4558            assert_eq!(cfg.thinking_level, Some(ReasoningLevel::High));
4559            assert!(cfg.warnings.is_empty(), "{:?}", cfg.warnings);
4560        }
4561        for value in ["typo", "", "32768", "off", "ultra"] {
4562            let cfg = load_config_from_str(&format!("subagent_thinking = {value}\n"));
4563            assert_eq!(cfg.subagent_thinking, ReasoningLevel::XHigh);
4564            assert!(!cfg.warnings.is_empty(), "{value:?}");
4565        }
4566    }
4567
4568    #[test]
4569    fn test_parse_thinking_budget() {
4570        use crate::core::reasoning::ThinkingSpec;
4571        // ThinkingSpec::parse replaced parse_thinking_budget; verify budget values.
4572        assert_eq!(ThinkingSpec::parse("low").unwrap().to_budget(), Some(2048));
4573        assert_eq!(
4574            ThinkingSpec::parse("medium").unwrap().to_budget(),
4575            Some(4096)
4576        );
4577        assert_eq!(
4578            ThinkingSpec::parse("high").unwrap().to_budget(),
4579            Some(16384)
4580        );
4581        assert_eq!(
4582            ThinkingSpec::parse("xhigh").unwrap().to_budget(),
4583            Some(32768)
4584        );
4585        assert_eq!(ThinkingSpec::parse("8192").unwrap().to_budget(), Some(8192));
4586        let config = load_config_from_str("thinking = 8192\n");
4587        assert_eq!(config.thinking_level, None);
4588        assert_eq!(config.thinking_budget, Some(8192));
4589        assert_eq!(ThinkingSpec::parse("invalid"), None);
4590    }
4591
4592    #[test]
4593    fn test_base_dir() {
4594        let path = base_dir();
4595        assert!(path.to_string_lossy().ends_with(".synaps-cli"));
4596    }
4597
4598    #[test]
4599    fn test_resolve_system_prompt_explicit() {
4600        let result = resolve_system_prompt(Some("test prompt"));
4601        assert_eq!(result, "test prompt");
4602    }
4603
4604    #[test]
4605    #[serial]
4606    fn test_resolve_system_prompt_none() {
4607        // Empty base dir: no system.md, so the stock default applies.
4608        let home = std::env::temp_dir().join(format!("synaps-prompt-none-{}", std::process::id()));
4609        std::fs::create_dir_all(home.join(".synaps-cli")).unwrap();
4610        with_home(&home, || {
4611            assert_eq!(resolve_system_prompt(None), DEFAULT_PROMPT);
4612        });
4613        let _ = std::fs::remove_dir_all(&home);
4614    }
4615
4616    #[test]
4617    fn default_prompts_carry_working_habit_steering() {
4618        for (name, prompt) in [
4619            ("unix", DEFAULT_PROMPT_UNIX),
4620            ("windows", DEFAULT_PROMPT_WINDOWS),
4621        ] {
4622            for needle in [
4623                "helpful AI agent running in a terminal",
4624                "Be concise and direct.",
4625                "targeted edits rather than rewriting them whole",
4626                "re-read it only if it may have changed",
4627                "as lean as the task allows",
4628                "Keep command output small",
4629                "read large outputs selectively",
4630            ] {
4631                assert!(prompt.contains(needle), "{name} prompt lacks {needle:?}");
4632            }
4633            assert!(!prompt.contains("  "), "{name} prompt has a double space");
4634            assert!(prompt.ends_with("selectively."), "{name}: {prompt}");
4635        }
4636        assert!(DEFAULT_PROMPT_UNIX.contains("`| tail -n`"));
4637        assert!(DEFAULT_PROMPT_WINDOWS.contains("on Windows"));
4638        assert!(DEFAULT_PROMPT_WINDOWS.contains("`Select-Object -Last N`"));
4639        assert!(!DEFAULT_PROMPT_WINDOWS.contains("tail -n"));
4640    }
4641
4642    /// The stock prompt sits in the cached prefix of every stock request:
4643    /// keep it from silently ballooning.
4644    #[test]
4645    fn default_prompts_stay_small() {
4646        assert!(DEFAULT_PROMPT_UNIX.len() <= 640, "{}", DEFAULT_PROMPT_UNIX.len());
4647        assert!(DEFAULT_PROMPT_WINDOWS.len() <= 900, "{}", DEFAULT_PROMPT_WINDOWS.len());
4648    }
4649
4650    /// Explicit `--system` text and files are used byte for byte; the stock
4651    /// steering never leaks into them.
4652    #[test]
4653    fn explicit_system_prompt_is_byte_identical() {
4654        let text = "  Custom prompt.\n\nKeep trailing whitespace.  \n";
4655        assert_eq!(resolve_system_prompt(Some(text)), text);
4656
4657        let dir = std::env::temp_dir().join(format!("synaps-prompt-file-{}", std::process::id()));
4658        std::fs::create_dir_all(&dir).unwrap();
4659        let file = dir.join("system.txt");
4660        std::fs::write(&file, text).unwrap();
4661        assert_eq!(resolve_system_prompt(Some(file.to_str().unwrap())), text);
4662        let _ = std::fs::remove_dir_all(&dir);
4663    }
4664
4665    /// A user `system.md` (base dir or active profile) replaces the stock
4666    /// default entirely, byte for byte.
4667    #[test]
4668    #[serial]
4669    fn system_md_prompt_is_byte_identical() {
4670        let text = "You are my agent.\nNo extra steering.\n";
4671        let home = std::env::temp_dir().join(format!("synaps-prompt-md-{}", std::process::id()));
4672        let dir = home.join(".synaps-cli");
4673        std::fs::create_dir_all(&dir).unwrap();
4674        std::fs::write(dir.join("system.md"), text).unwrap();
4675        with_home(&home, || {
4676            assert_eq!(resolve_system_prompt(None), text);
4677            // An explicit value still wins over system.md.
4678            assert_eq!(resolve_system_prompt(Some("explicit")), "explicit");
4679        });
4680        let _ = std::fs::remove_dir_all(&home);
4681    }
4682
4683    // Note: test_load_config_nonexistent_file removed — HOME env var mutation
4684    // is not thread-safe and races with shell config tests. Coverage provided
4685    // by shell::config::tests::test_shell_config_from_file.
4686
4687    #[test]
4688    fn test_synaps_config_default() {
4689        let config = SynapsConfig::default();
4690        assert_eq!(config.model, None);
4691        assert_eq!(config.thinking_budget, None);
4692        assert_eq!(config.max_tool_output, 30000);
4693        assert_eq!(config.bash_timeout, 30);
4694        assert_eq!(config.bash_max_timeout, 300);
4695        assert_eq!(config.subagent_timeout, 300);
4696        assert_eq!(config.api_retries, 3);
4697        assert_eq!(config.theme, None);
4698        assert!(config.disabled_plugins.is_empty());
4699        assert!(config.favorite_models.is_empty());
4700        assert!(config.disabled_skills.is_empty());
4701        assert!(!config.progressive_tool_disclosure);
4702        assert_eq!(config.tools_activation_confirm, ActivationConfirm::Auto);
4703        assert_eq!(config.shell.max_sessions, 5);
4704        assert_eq!(config.shell.idle_timeout.as_secs(), 600);
4705        // Server config defaults
4706        assert!(config.server.allowed_origins.is_empty());
4707        assert_eq!(config.server.token, None);
4708        assert!(!config.server.auto_approve_confirms);
4709        assert_eq!(config.server.max_message_size, None);
4710        // Bridge config defaults
4711        assert!(config.bridge.uds_path.is_none());
4712        assert!(!config.bridge.heartbeat_mirror);
4713        assert_eq!(config.bridge.heartbeat_timeout_ms, 250);
4714    }
4715
4716    #[test]
4717    fn read_dedupe_key_parses_defaults_on_and_warns() {
4718        assert!(super::load_config_from_str("").read_dedupe, "default on");
4719        for (line, want) in [
4720            ("read_dedupe = off\n", false),
4721            ("read_dedupe = false\n", false),
4722            ("read_dedupe = on\n", true),
4723            ("read_dedupe = true\n", true),
4724        ] {
4725            let c = super::load_config_from_str(line);
4726            assert_eq!(c.read_dedupe, want, "{line}");
4727            assert!(c.warnings.is_empty(), "{line}: {:?}", c.warnings);
4728        }
4729        let c = super::load_config_from_str("read_dedupe = maybe\n");
4730        assert!(c.read_dedupe, "bad value keeps the default");
4731        assert!(c.warnings.iter().any(|w| w.contains("read_dedupe")));
4732    }
4733
4734    #[test]
4735    fn tool_output_spill_keys_parse_defaults_and_warn() {
4736        let c = super::load_config_from_str("");
4737        assert_eq!(c.tool_output_spill_bytes, 12 * 1024);
4738        assert_eq!(c.tool_output_spill_head_bytes, 4 * 1024);
4739        assert_eq!(c.tool_output_spill_tail_bytes, 3 * 1024);
4740        let d = super::SynapsConfig::default();
4741        assert_eq!(d.tool_output_spill_bytes, super::DEFAULT_TOOL_OUTPUT_SPILL_BYTES);
4742        assert_eq!(d.tool_output_spill_head_bytes, super::DEFAULT_TOOL_OUTPUT_SPILL_HEAD_BYTES);
4743        assert_eq!(d.tool_output_spill_tail_bytes, super::DEFAULT_TOOL_OUTPUT_SPILL_TAIL_BYTES);
4744
4745        let c = super::load_config_from_str(concat!(
4746            "tool_output_spill_bytes = 20000\n",
4747            "tool_output_spill_head_bytes = 6000\n",
4748            "tool_output_spill_tail_bytes = 0\n",
4749        ));
4750        assert_eq!(c.tool_output_spill_bytes, 20_000);
4751        assert_eq!(c.tool_output_spill_head_bytes, 6_000);
4752        assert_eq!(c.tool_output_spill_tail_bytes, 0, "a head-only preview is allowed");
4753        assert!(c.warnings.is_empty(), "warnings: {:?}", c.warnings);
4754
4755        let c = super::load_config_from_str("tool_output_spill_bytes = 0\n");
4756        assert_eq!(c.tool_output_spill_bytes, 0, "0 turns virtualisation off");
4757        assert!(c.warnings.is_empty(), "warnings: {:?}", c.warnings);
4758
4759        for key in [
4760            "tool_output_spill_bytes",
4761            "tool_output_spill_head_bytes",
4762            "tool_output_spill_tail_bytes",
4763        ] {
4764            let c = super::load_config_from_str(&format!("{key} = lots\n"));
4765            assert_eq!(c.tool_output_spill_bytes, super::DEFAULT_TOOL_OUTPUT_SPILL_BYTES);
4766            assert_eq!(c.tool_output_spill_head_bytes, super::DEFAULT_TOOL_OUTPUT_SPILL_HEAD_BYTES);
4767            assert_eq!(c.tool_output_spill_tail_bytes, super::DEFAULT_TOOL_OUTPUT_SPILL_TAIL_BYTES);
4768            assert!(
4769                c.warnings.iter().any(|w| w.contains(key)),
4770                "{key}: warnings: {:?}",
4771                c.warnings
4772            );
4773            assert!(super::KNOWN_CONFIG_KEYS.contains(&key));
4774        }
4775    }
4776
4777    #[test]
4778    #[serial]
4779    fn test_load_config_tools_activation_confirm_modes() {
4780        for (raw, expected, warns) in [
4781            ("prompt", ActivationConfirm::Prompt, false),
4782            ("deny", ActivationConfirm::Deny, false),
4783            ("AUTO", ActivationConfirm::Auto, false),
4784            ("garbage", ActivationConfirm::Auto, true),
4785        ] {
4786            let home = make_test_home(&format!("activation-confirm-{raw}"));
4787            let cfg = home.join(".synaps-cli/config");
4788            std::fs::write(&cfg, format!("tools.activation_confirm = {raw}\n")).unwrap();
4789            with_home(&home, || {
4790                let config = load_config();
4791                assert_eq!(config.tools_activation_confirm, expected, "{raw}");
4792                assert_eq!(
4793                    config.warnings.iter().any(|w| w.contains("tools.activation_confirm")),
4794                    warns,
4795                    "{raw}: {:?}",
4796                    config.warnings
4797                );
4798            });
4799            let _ = std::fs::remove_dir_all(&home);
4800        }
4801    }
4802
4803    #[test]
4804    #[serial]
4805    fn test_load_config_progressive_tool_disclosure_is_opt_in() {
4806        let home = make_test_home("progressive-tool-disclosure");
4807        let cfg = home.join(".synaps-cli/config");
4808        std::fs::write(&cfg, "progressive_tool_disclosure = true\n").unwrap();
4809
4810        with_home(&home, || {
4811            let config = load_config();
4812            assert!(config.progressive_tool_disclosure);
4813            assert!(config.warnings.is_empty());
4814        });
4815
4816        let _ = std::fs::remove_dir_all(&home);
4817    }
4818
4819    #[test]
4820    #[serial]
4821    fn test_load_config_bridge_keys() {
4822        let home = make_test_home("bridge-keys");
4823        let cfg = home.join(".synaps-cli/config");
4824        std::fs::write(
4825            &cfg,
4826            "\
4827bridge.uds_path = /tmp/some/control.sock\n\
4828bridge.heartbeat_mirror = true\n\
4829bridge.heartbeat_timeout_ms = 750\n\
4830",
4831        )
4832        .unwrap();
4833
4834        with_home(&home, || {
4835            let config = load_config();
4836            assert_eq!(
4837                config.bridge.uds_path,
4838                Some(std::path::PathBuf::from("/tmp/some/control.sock")),
4839            );
4840            assert!(config.bridge.heartbeat_mirror);
4841            assert_eq!(config.bridge.heartbeat_timeout_ms, 750);
4842            assert_eq!(
4843                config.bridge.resolved_uds_path(),
4844                std::path::PathBuf::from("/tmp/some/control.sock"),
4845            );
4846        });
4847
4848        let _ = std::fs::remove_dir_all(&home);
4849    }
4850
4851    #[test]
4852    fn test_bridge_config_defaults() {
4853        let cfg = BridgeConfig::default();
4854        assert!(cfg.uds_path.is_none());
4855        assert!(!cfg.heartbeat_mirror);
4856        assert_eq!(cfg.heartbeat_timeout_ms, 250);
4857        // resolved path falls under base_dir()/bridge/control.sock
4858        let resolved = cfg.resolved_uds_path();
4859        assert!(resolved.ends_with("bridge/control.sock"));
4860    }
4861
4862    #[test]
4863    #[serial]
4864    fn test_bridge_heartbeat_mirror_defaults_off_when_unset() {
4865        let home = make_test_home("bridge-default-off");
4866        let cfg = home.join(".synaps-cli/config");
4867        std::fs::write(&cfg, "model = claude-sonnet-4-6\n").unwrap();
4868
4869        with_home(&home, || {
4870            let config = load_config();
4871            assert!(!config.bridge.heartbeat_mirror);
4872            assert!(config.bridge.uds_path.is_none());
4873            assert_eq!(config.bridge.heartbeat_timeout_ms, 250);
4874        });
4875
4876        let _ = std::fs::remove_dir_all(&home);
4877    }
4878
4879    #[test]
4880    fn compaction_disclosure_keys_parse_with_typed_warnings() {
4881        let config = load_config_from_str(
4882            "compaction_mode = local\ncompaction_exclude = thinking, tool_results, bogus\n",
4883        );
4884        assert_eq!(
4885            config.compaction_mode,
4886            crate::core::compaction::CompactionMode::LocalOnly
4887        );
4888        assert_eq!(
4889            config.compaction_exclude,
4890            vec![
4891                crate::core::compaction::ContentClass::Thinking,
4892                crate::core::compaction::ContentClass::ToolResults,
4893            ]
4894        );
4895        assert!(
4896            config.warnings.iter().any(|w| w.contains("bogus")),
4897            "unknown class must warn: {:?}",
4898            config.warnings
4899        );
4900
4901        let defaults = load_config_from_str("");
4902        assert_eq!(
4903            defaults.compaction_mode,
4904            crate::core::compaction::CompactionMode::Remote
4905        );
4906        assert!(defaults.compaction_exclude.is_empty());
4907
4908        let bad_mode = load_config_from_str("compaction_mode = cloud\n");
4909        assert_eq!(
4910            bad_mode.compaction_mode,
4911            crate::core::compaction::CompactionMode::Remote
4912        );
4913        assert!(bad_mode
4914            .warnings
4915            .iter()
4916            .any(|w| w.contains("compaction_mode")));
4917    }
4918
4919    #[test]
4920    #[serial]
4921    fn test_load_config_server_keys() {
4922        let home = make_test_home("server-keys");
4923        let cfg = home.join(".synaps-cli/config");
4924        std::fs::write(
4925            &cfg,
4926            "\
4927server.allowed_origins = http://localhost:3000, http://localhost:5193\n\
4928server.token = my-secret-token\n\
4929server.auto_approve_confirms = true\n\
4930server.max_message_size = 65536\n\
4931context_window = 200k\n\
4932",
4933        )
4934        .unwrap();
4935
4936        with_home(&home, || {
4937            let config = load_config();
4938            assert_eq!(
4939                config.server.allowed_origins,
4940                vec![
4941                    "http://localhost:3000".to_string(),
4942                    "http://localhost:5193".to_string(),
4943                ]
4944            );
4945            assert_eq!(config.server.token, Some("my-secret-token".to_string()));
4946            assert!(config.server.auto_approve_confirms);
4947            // Explicit max_message_size takes precedence over context_window derivation
4948            assert_eq!(config.server.max_message_size, Some(65536));
4949        });
4950
4951        let _ = std::fs::remove_dir_all(&home);
4952    }
4953
4954    #[test]
4955    #[serial]
4956    fn test_server_max_message_size_derived_from_context_window() {
4957        let home = make_test_home("server-derive");
4958        let cfg = home.join(".synaps-cli/config");
4959        std::fs::write(&cfg, "context_window = 200k\n").unwrap();
4960
4961        with_home(&home, || {
4962            let config = load_config();
4963            // 200_000 tokens * 4 bytes/token = 800_000 bytes
4964            assert_eq!(config.server.max_message_size, Some(800_000));
4965        });
4966
4967        let _ = std::fs::remove_dir_all(&home);
4968    }
4969
4970    fn make_test_home(subdir: &str) -> std::path::PathBuf {
4971        let dir = std::path::PathBuf::from(format!("/tmp/synaps-write-test-{}", subdir));
4972        let _ = std::fs::remove_dir_all(&dir);
4973        std::fs::create_dir_all(dir.join(".synaps-cli")).unwrap();
4974        dir
4975    }
4976
4977    fn with_home<F: FnOnce()>(home: &std::path::Path, f: F) {
4978        // `base_dir()` reads SYNAPS_BASE_DIR before HOME: pin it too, or a value
4979        // another test left set redirects the write away from `home`.
4980        let original = std::env::var("HOME").ok();
4981        let original_base = std::env::var("SYNAPS_BASE_DIR").ok();
4982        std::env::set_var("HOME", home);
4983        std::env::set_var("SYNAPS_BASE_DIR", home.join(".synaps-cli"));
4984        f();
4985        if let Some(h) = original {
4986            std::env::set_var("HOME", h);
4987        } else {
4988            std::env::remove_var("HOME");
4989        }
4990        if let Some(b) = original_base {
4991            std::env::set_var("SYNAPS_BASE_DIR", b);
4992        } else {
4993            std::env::remove_var("SYNAPS_BASE_DIR");
4994        }
4995    }
4996
4997    #[test]
4998    #[serial]
4999    fn write_config_value_replaces_existing_key() {
5000        let home = make_test_home("replace");
5001        let cfg = home.join(".synaps-cli/config");
5002        std::fs::write(&cfg, "model = claude-opus-4-6\nthinking = low\n").unwrap();
5003
5004        with_home(&home, || {
5005            write_config_value("model", "claude-sonnet-4-6").unwrap();
5006        });
5007
5008        let contents = std::fs::read_to_string(&cfg).unwrap();
5009        assert!(contents.contains("model = claude-sonnet-4-6"));
5010        assert!(contents.contains("thinking = low"));
5011        let _ = std::fs::remove_dir_all(&home);
5012    }
5013
5014    #[test]
5015    #[serial]
5016    fn write_config_value_appends_when_missing() {
5017        let home = make_test_home("append");
5018        let cfg = home.join(".synaps-cli/config");
5019        std::fs::write(&cfg, "model = claude-opus-4-6\n").unwrap();
5020
5021        with_home(&home, || {
5022            write_config_value("theme", "dracula").unwrap();
5023        });
5024
5025        let contents = std::fs::read_to_string(&cfg).unwrap();
5026        assert!(contents.contains("model = claude-opus-4-6"));
5027        assert!(contents.contains("theme = dracula"));
5028        let _ = std::fs::remove_dir_all(&home);
5029    }
5030
5031    #[test]
5032    #[serial]
5033    fn write_config_value_concurrent_writers_lose_nothing() {
5034        let home = make_test_home("concurrent");
5035        let cfg = home.join(".synaps-cli/config");
5036        std::fs::write(&cfg, "# keep\n").unwrap();
5037
5038        // HOME is process-global; set it for the whole test, not per thread.
5039        with_home(&home, || {
5040            let a = std::thread::spawn(|| {
5041                for i in 0..200 {
5042                    write_config_value("a_key", &i.to_string()).unwrap();
5043                }
5044            });
5045            let b = std::thread::spawn(|| {
5046                for i in 0..200 {
5047                    write_config_value("b_key", &i.to_string()).unwrap();
5048                }
5049            });
5050            a.join().unwrap();
5051            b.join().unwrap();
5052        });
5053
5054        let contents = std::fs::read_to_string(&cfg).unwrap();
5055        assert!(contents.contains("# keep"), "{contents}");
5056        assert!(contents.contains("a_key = 199"), "{contents}");
5057        assert!(contents.contains("b_key = 199"), "{contents}");
5058        assert_eq!(contents.matches("a_key").count(), 1);
5059        assert_eq!(contents.matches("b_key").count(), 1);
5060
5061        #[cfg(unix)]
5062        {
5063            use std::os::unix::fs::PermissionsExt;
5064            let lock = home.join(".synaps-cli/config.lock");
5065            let mode = std::fs::metadata(&lock).unwrap().permissions().mode() & 0o777;
5066            assert_eq!(mode, 0o600);
5067        }
5068        let _ = std::fs::remove_dir_all(&home);
5069    }
5070
5071    #[test]
5072    #[serial]
5073    fn config_lock_kill_switch_still_writes() {
5074        let home = make_test_home("lock-off");
5075        let cfg = home.join(".synaps-cli/config");
5076        std::env::set_var("SYNAPS_CONFIG_LOCK", "0");
5077        with_home(&home, || {
5078            write_config_value("model", "claude-sonnet-4-6").unwrap();
5079        });
5080        std::env::remove_var("SYNAPS_CONFIG_LOCK");
5081        let contents = std::fs::read_to_string(&cfg).unwrap();
5082        assert!(contents.contains("model = claude-sonnet-4-6"));
5083        assert!(!home.join(".synaps-cli/config.lock").exists());
5084        let _ = std::fs::remove_dir_all(&home);
5085    }
5086
5087    #[test]
5088    #[serial]
5089    fn add_favorite_model_end_to_end_does_not_deadlock() {
5090        let home = make_test_home("favorite-lock");
5091        let cfg = home.join(".synaps-cli/config");
5092        with_home(&home, || {
5093            add_favorite_model("anthropic/claude-sonnet-4-6").unwrap();
5094            add_favorite_model("openai/gpt-5").unwrap();
5095            remove_favorite_model("anthropic/claude-sonnet-4-6").unwrap();
5096        });
5097        let contents = std::fs::read_to_string(&cfg).unwrap();
5098        assert!(contents.contains("favorite_models = openai/gpt-5"), "{contents}");
5099        let _ = std::fs::remove_dir_all(&home);
5100    }
5101
5102    #[test]
5103    #[serial]
5104    fn write_config_value_preserves_comments() {
5105        let home = make_test_home("comments");
5106        let cfg = home.join(".synaps-cli/config");
5107        std::fs::write(&cfg, "# user comment\nmodel = claude-opus-4-6\n# another\n").unwrap();
5108
5109        with_home(&home, || {
5110            write_config_value("model", "claude-sonnet-4-6").unwrap();
5111        });
5112
5113        let contents = std::fs::read_to_string(&cfg).unwrap();
5114        assert!(contents.contains("# user comment"));
5115        assert!(contents.contains("# another"));
5116        assert!(contents.contains("model = claude-sonnet-4-6"));
5117        let _ = std::fs::remove_dir_all(&home);
5118    }
5119
5120    #[test]
5121    #[serial]
5122    fn write_config_value_preserves_unknown_keys() {
5123        let home = make_test_home("unknown");
5124        let cfg = home.join(".synaps-cli/config");
5125        std::fs::write(&cfg, "custom_thing = 42\nmodel = claude-opus-4-6\n").unwrap();
5126
5127        with_home(&home, || {
5128            write_config_value("model", "claude-sonnet-4-6").unwrap();
5129        });
5130
5131        let contents = std::fs::read_to_string(&cfg).unwrap();
5132        assert!(contents.contains("custom_thing = 42"));
5133        let _ = std::fs::remove_dir_all(&home);
5134    }
5135
5136    #[test]
5137    #[serial]
5138    fn write_config_value_creates_file_if_absent() {
5139        let home = make_test_home("create");
5140        let cfg = home.join(".synaps-cli/config");
5141        assert!(!cfg.exists());
5142
5143        with_home(&home, || {
5144            write_config_value("model", "claude-sonnet-4-6").unwrap();
5145        });
5146
5147        let contents = std::fs::read_to_string(&cfg).unwrap();
5148        assert!(contents.contains("model = claude-sonnet-4-6"));
5149        let _ = std::fs::remove_dir_all(&home);
5150    }
5151
5152    #[test]
5153    #[serial]
5154    fn load_config_parses_theme_key() {
5155        let dir = std::path::PathBuf::from("/tmp/synaps-config-test-theme/.synaps-cli");
5156        let _ = std::fs::create_dir_all(&dir);
5157        std::fs::write(dir.join("config"), "theme = dracula\n").unwrap();
5158
5159        let original_home = std::env::var("HOME").ok();
5160        std::env::set_var("HOME", "/tmp/synaps-config-test-theme");
5161
5162        let config = load_config();
5163
5164        if let Some(home) = original_home {
5165            std::env::set_var("HOME", home);
5166        } else {
5167            std::env::remove_var("HOME");
5168        }
5169        let _ = std::fs::remove_dir_all("/tmp/synaps-config-test-theme");
5170
5171        assert_eq!(config.theme.as_deref(), Some("dracula"));
5172    }
5173
5174    #[test]
5175    #[serial]
5176    fn test_load_config_disable_lists() {
5177        let test_dir =
5178            std::path::PathBuf::from("/tmp/synaps-config-test-disable-lists/.synaps-cli");
5179        let _ = std::fs::create_dir_all(&test_dir);
5180        let config_path = test_dir.join("config");
5181
5182        let config_content = r#"
5183# Test config with disable lists
5184favorite_models = claude/claude-opus-4-7, groq/llama-3.3-70b-versatile
5185
5186disabled_plugins = foo, bar
5187disabled_skills = baz, plug:qual
5188"#;
5189        std::fs::write(&config_path, config_content).unwrap();
5190
5191        let original_home = std::env::var("HOME").ok();
5192        std::env::set_var("HOME", "/tmp/synaps-config-test-disable-lists");
5193
5194        let config = load_config();
5195
5196        if let Some(home) = original_home {
5197            std::env::set_var("HOME", home);
5198        } else {
5199            std::env::remove_var("HOME");
5200        }
5201
5202        let _ = std::fs::remove_dir_all("/tmp/synaps-config-test-disable-lists");
5203
5204        assert_eq!(
5205            config.disabled_plugins,
5206            vec!["foo".to_string(), "bar".to_string()]
5207        );
5208        assert_eq!(
5209            config.favorite_models,
5210            vec![
5211                "claude/claude-opus-4-7".to_string(),
5212                "groq/llama-3.3-70b-versatile".to_string(),
5213            ]
5214        );
5215        assert_eq!(
5216            config.disabled_skills,
5217            vec!["baz".to_string(), "plug:qual".to_string()]
5218        );
5219    }
5220
5221    #[test]
5222    #[serial]
5223    fn favorite_model_helpers_round_trip_through_config_file() {
5224        let home = make_test_home("favorite-models");
5225        let cfg = home.join(".synaps-cli/config");
5226        std::fs::write(&cfg, "model = claude-opus-4-7\n").unwrap();
5227
5228        with_home(&home, || {
5229            add_favorite_model("groq/llama-3.3-70b-versatile").unwrap();
5230            add_favorite_model("claude/claude-opus-4-7").unwrap();
5231            add_favorite_model("groq/llama-3.3-70b-versatile").unwrap();
5232            assert!(is_favorite_model("groq/llama-3.3-70b-versatile"));
5233            remove_favorite_model("groq/llama-3.3-70b-versatile").unwrap();
5234            assert!(!is_favorite_model("groq/llama-3.3-70b-versatile"));
5235            assert!(is_favorite_model("claude/claude-opus-4-7"));
5236        });
5237
5238        let contents = std::fs::read_to_string(&cfg).unwrap();
5239        assert!(contents.contains("model = claude-opus-4-7"));
5240        assert!(contents.contains("favorite_models = claude/claude-opus-4-7"));
5241        let _ = std::fs::remove_dir_all(&home);
5242    }
5243
5244    #[test]
5245    #[serial]
5246    fn test_load_config_new_keys() {
5247        // Create a temporary config directory with the new keys
5248        let test_dir = std::path::PathBuf::from("/tmp/synaps-config-test-new-keys/.synaps-cli");
5249        let _ = std::fs::create_dir_all(&test_dir);
5250        let config_path = test_dir.join("config");
5251
5252        let config_content = r#"
5253# Test config with new keys
5254model = claude-haiku
5255thinking = medium
5256max_tool_output = 50000
5257bash_timeout = 45
5258bash_max_timeout = 600
5259subagent_timeout = 120
5260api_retries = 5
5261"#;
5262        std::fs::write(&config_path, config_content).unwrap();
5263
5264        // Temporarily override the config path for this test
5265        let original_home = std::env::var("HOME").ok();
5266        std::env::set_var("HOME", "/tmp/synaps-config-test-new-keys");
5267
5268        let config = load_config();
5269
5270        // Restore original HOME
5271        if let Some(home) = original_home {
5272            std::env::set_var("HOME", home);
5273        } else {
5274            std::env::remove_var("HOME");
5275        }
5276
5277        // Cleanup
5278        let _ = std::fs::remove_dir_all("/tmp/synaps-config-test-new-keys");
5279
5280        assert_eq!(config.model, Some("claude-haiku".to_string()));
5281        assert_eq!(config.thinking_budget, Some(4096)); // medium = 4096
5282        assert_eq!(config.max_tool_output, 50000);
5283        assert_eq!(config.bash_timeout, 45);
5284        assert_eq!(config.bash_max_timeout, 600);
5285        assert_eq!(config.subagent_timeout, 120);
5286        assert_eq!(config.api_retries, 5);
5287    }
5288
5289    // ── auth.* config + credential source (#157) ──
5290
5291    // ── events.auto_turn parser ──────────────────────────────────────────────
5292
5293    #[test]
5294    fn events_auto_turn_explicit_true_values() {
5295        for val in &["true", "TRUE", "1", "yes", "YES", "on", "ON"] {
5296            let cfg = load_config_from_str(&format!("events.auto_turn = {val}"));
5297            assert!(
5298                cfg.events.auto_turn,
5299                "expected true for events.auto_turn = {val}"
5300            );
5301        }
5302    }
5303
5304    #[test]
5305    fn events_auto_turn_explicit_false_values() {
5306        for val in &["false", "FALSE", "0", "no", "NO", "off", "OFF"] {
5307            let cfg = load_config_from_str(&format!("events.auto_turn = {val}"));
5308            assert!(
5309                !cfg.events.auto_turn,
5310                "expected false for events.auto_turn = {val}"
5311            );
5312        }
5313    }
5314
5315    #[test]
5316    fn events_auto_turn_typo_fails_safe_false() {
5317        // Unrecognised value should fail safe to false (with a warning on stderr).
5318        let cfg = load_config_from_str("events.auto_turn = fales");
5319        assert!(
5320            !cfg.events.auto_turn,
5321            "typo 'fales' must fail safe to false"
5322        );
5323    }
5324
5325    // ── events.auto_turn_cap parser ──────────────────────────────────────────
5326
5327    #[test]
5328    fn events_auto_turn_cap_default_is_five() {
5329        let cfg = load_config_from_str("");
5330        assert_eq!(cfg.events.auto_turn_cap, 5);
5331        assert_eq!(DEFAULT_AUTO_TURN_CAP, 5);
5332    }
5333
5334    #[test]
5335    fn events_auto_turn_cap_zero_is_unlimited() {
5336        let cfg = load_config_from_str("events.auto_turn_cap = 0");
5337        assert_eq!(cfg.events.auto_turn_cap, 0);
5338    }
5339
5340    #[test]
5341    fn events_auto_turn_cap_unlimited_aliases() {
5342        for val in ["unlimited", "inf", "infinite", "Infinity", "UNLIMITED", " inf "] {
5343            let cfg = load_config_from_str(&format!("events.auto_turn_cap = {val}"));
5344            assert_eq!(
5345                cfg.events.auto_turn_cap, 0,
5346                "expected 0 for events.auto_turn_cap = {val:?}"
5347            );
5348        }
5349    }
5350
5351    #[test]
5352    fn events_auto_turn_cap_explicit_number() {
5353        let cfg = load_config_from_str("events.auto_turn_cap = 12");
5354        assert_eq!(cfg.events.auto_turn_cap, 12);
5355    }
5356
5357    #[test]
5358    fn events_auto_turn_cap_garbage_keeps_default() {
5359        for val in ["banana", "-3", "5.5", "", "true"] {
5360            let cfg = load_config_from_str(&format!("events.auto_turn_cap = {val}"));
5361            assert_eq!(
5362                cfg.events.auto_turn_cap, 5,
5363                "garbage {val:?} must keep default 5"
5364            );
5365        }
5366    }
5367
5368    #[test]
5369    fn events_auto_turn_cap_independent_of_auto_turn() {
5370        let cfg = load_config_from_str("events.auto_turn = false\nevents.auto_turn_cap = 0");
5371        assert!(!cfg.events.auto_turn);
5372        assert_eq!(cfg.events.auto_turn_cap, 0);
5373    }
5374
5375    #[test]
5376    fn events_auto_turn_default_is_true() {
5377        let cfg = load_config_from_str("model = claude-haiku");
5378        assert!(cfg.events.auto_turn, "default must be true when key absent");
5379    }
5380
5381    #[test]
5382    #[serial]
5383    fn test_auth_config_parses_endpoint_and_token() {
5384        let home = std::env::temp_dir().join(format!("synaps-auth-test-{}", std::process::id()));
5385        let dir = home.join(".synaps-cli");
5386        std::fs::create_dir_all(&dir).unwrap();
5387        std::fs::write(
5388            dir.join("config"),
5389            "auth.remote_endpoint = https://jade.jade:8181\nauth.machine_token = machine-abc\n",
5390        )
5391        .unwrap();
5392        with_home(&home, || {
5393            let config = load_config();
5394            assert_eq!(
5395                config.auth.remote_endpoint.as_deref(),
5396                Some("https://jade.jade:8181")
5397            );
5398            assert_eq!(config.auth.machine_token.as_deref(), Some("machine-abc"));
5399            // auth.* are namespaced -> must NOT warn as unknown keys
5400            assert!(
5401                !config.warnings.iter().any(|w| w.contains("auth.")),
5402                "{:?}",
5403                config.warnings
5404            );
5405        });
5406        let _ = std::fs::remove_dir_all(&home);
5407    }
5408
5409    #[test]
5410    #[serial]
5411    fn test_credential_source_local_by_default() {
5412        std::env::remove_var("SYNAPS_AUTH_ENDPOINT");
5413        std::env::remove_var("SYNAPS_MACHINE_TOKEN");
5414        assert!(!AuthConfig::default().credential_source().is_remote());
5415    }
5416
5417    #[test]
5418    #[serial]
5419    fn test_credential_source_remote_from_config() {
5420        std::env::remove_var("SYNAPS_AUTH_ENDPOINT");
5421        std::env::remove_var("SYNAPS_MACHINE_TOKEN");
5422        let auth = AuthConfig {
5423            remote_endpoint: Some("https://b".into()),
5424            machine_token: Some("m".into()),
5425            accounts: BTreeMap::new(),
5426            auto: BTreeMap::new(),
5427        };
5428        assert_eq!(
5429            auth.credential_source(),
5430            crate::core::auth::CredentialSource::Remote {
5431                endpoint: "https://b".into(),
5432                machine_token: "m".into()
5433            }
5434        );
5435    }
5436
5437    #[test]
5438    fn auth_account_keys_parse_and_invalid_fail_closed_with_warning() {
5439        let config = load_config_from_str(
5440            "auth.account.openai-codex = astra2\nauth.account.anthropic = auto\nauth.account.kimi-code = Bad Label\nauth.account.groq = x\n",
5441        );
5442        assert_eq!(
5443            config.auth.accounts.get("openai-codex").map(String::as_str),
5444            Some("astra2")
5445        );
5446        assert_eq!(
5447            config.auth.accounts.get("anthropic").map(String::as_str),
5448            Some("auto")
5449        );
5450        let policy = config.auth.account_policy();
5451        use crate::core::auth::{AccountSelector, OAuthProviderId};
5452        assert!(matches!(
5453            policy.selector(OAuthProviderId::OpenAiCodex),
5454            AccountSelector::Account(_)
5455        ));
5456        assert_eq!(
5457            policy.selector(OAuthProviderId::Anthropic),
5458            AccountSelector::Auto
5459        );
5460        assert!(
5461            matches!(
5462                policy.selector(OAuthProviderId::KimiCode),
5463                AccountSelector::Invalid { .. }
5464            ),
5465            "malformed label must fail closed"
5466        );
5467        assert!(
5468            config
5469                .warnings
5470                .iter()
5471                .any(|w| w.contains("auth.account.kimi-code")),
5472            "{:?}",
5473            config.warnings
5474        );
5475        assert!(
5476            config
5477                .warnings
5478                .iter()
5479                .any(|w| w.contains("auth.account.groq")),
5480            "{:?}",
5481            config.warnings
5482        );
5483        assert!(
5484            !config
5485                .warnings
5486                .iter()
5487                .any(|w| w.contains("auth.account.openai-codex")),
5488            "{:?}",
5489            config.warnings
5490        );
5491    }
5492
5493    #[test]
5494    #[serial]
5495    fn test_credential_source_env_overrides_config() {
5496        let auth = AuthConfig {
5497            remote_endpoint: Some("https://config-host".into()),
5498            machine_token: Some("config-tok".into()),
5499            accounts: BTreeMap::new(),
5500            auto: BTreeMap::new(),
5501        };
5502        std::env::set_var("SYNAPS_AUTH_ENDPOINT", "https://env-host");
5503        std::env::set_var("SYNAPS_MACHINE_TOKEN", "env-tok");
5504        let src = auth.credential_source();
5505        std::env::remove_var("SYNAPS_AUTH_ENDPOINT");
5506        std::env::remove_var("SYNAPS_MACHINE_TOKEN");
5507        assert_eq!(
5508            src,
5509            crate::core::auth::CredentialSource::Remote {
5510                endpoint: "https://env-host".into(),
5511                machine_token: "env-tok".into()
5512            }
5513        );
5514    }
5515    /// #147 C1 / prA N1: only an explicit token travels in the source; the
5516    /// local file is resolved by the client, for loopback + none configured.
5517    #[test]
5518    #[serial]
5519    fn test_credential_source_records_token_provenance_and_file_is_resolved_lazily() {
5520        use crate::core::auth::MachineToken;
5521        let dir = tempfile::tempdir().unwrap();
5522        let prev = std::env::var_os("SYNAPS_BASE_DIR");
5523        std::env::set_var("SYNAPS_BASE_DIR", dir.path());
5524        std::env::remove_var("SYNAPS_AUTH_ENDPOINT");
5525        std::env::remove_var("SYNAPS_MACHINE_TOKEN");
5526        std::fs::write(dir.path().join("auth-broker.token"), "local-file-tok\n").unwrap();
5527        let source = |endpoint: &str, token: Option<&str>| {
5528            AuthConfig {
5529                remote_endpoint: Some(endpoint.into()),
5530                machine_token: token.map(str::to_string),
5531                accounts: BTreeMap::new(),
5532                auto: BTreeMap::new(),
5533            }
5534            .credential_source()
5535        };
5536        let remote_tok = |s: crate::core::auth::CredentialSource| match s {
5537            crate::core::auth::CredentialSource::Remote { machine_token, .. } => machine_token,
5538            other => panic!("{other:?}"),
5539        };
5540        // The source carries only the explicit token: empty = "none configured".
5541        let loopback = remote_tok(source("http://127.0.0.1:8181", None));
5542        let explicit = remote_tok(source("http://127.0.0.1:8181", Some("cfg-tok")));
5543        let remote = remote_tok(source("https://jade.jade:8181", None));
5544        assert_eq!(loopback, "");
5545        assert_eq!(explicit, "cfg-tok");
5546        assert_eq!(remote, "");
5547        // The client resolves the file only for "none configured" + loopback.
5548        let auto = MachineToken::for_endpoint("http://localhost:8181", &loopback);
5549        let fixed = MachineToken::for_endpoint("http://127.0.0.1:8181", &explicit);
5550        let far = MachineToken::for_endpoint("https://jade.jade:8181", &remote);
5551        match prev {
5552            Some(v) => std::env::set_var("SYNAPS_BASE_DIR", v),
5553            None => std::env::remove_var("SYNAPS_BASE_DIR"),
5554        }
5555        assert_eq!(auto.get(), "local-file-tok");
5556        assert_eq!(fixed.get(), "cfg-tok", "explicit token is never replaced");
5557        assert_eq!(far.get(), "", "never read for a non-loopback broker");
5558    }
5559
5560    #[test]
5561    fn config_parses_ultracode_as_distinct_canonical_level() {
5562        let cfg = load_config_from_str("thinking = ultracode\n");
5563        assert_eq!(
5564            cfg.thinking_level,
5565            Some(crate::reasoning::ReasoningLevel::UltraCode)
5566        );
5567        assert_eq!(cfg.thinking_budget, None);
5568        for other in [
5569            crate::reasoning::ReasoningLevel::Ultra,
5570            crate::reasoning::ReasoningLevel::Max,
5571            crate::reasoning::ReasoningLevel::XHigh,
5572        ] {
5573            assert_ne!(cfg.thinking_level, Some(other));
5574        }
5575    }
5576
5577    // ── /settings coverage support ────────────────────────────────────────
5578
5579    /// `context_window = auto` is what the /settings cycler and `/context
5580    /// auto` write: it means "model default" (no override) and must not warn.
5581    #[test]
5582    fn context_window_auto_is_model_default_without_warning() {
5583        for spelling in ["auto", "AUTO", "Auto"] {
5584            let cfg = load_config_from_str(&format!("context_window = {spelling}\n"));
5585            assert_eq!(cfg.context_window, None, "{spelling}");
5586            assert!(cfg.warnings.is_empty(), "{spelling}: {:?}", cfg.warnings);
5587        }
5588        // `auto` after an explicit window clears it.
5589        let cfg = load_config_from_str("context_window = 1m\ncontext_window = auto\n");
5590        assert_eq!(cfg.context_window, None);
5591        assert!(cfg.warnings.is_empty(), "{:?}", cfg.warnings);
5592        // Existing spellings are unchanged; garbage still warns.
5593        assert_eq!(
5594            load_config_from_str("context_window = 200k\n").context_window,
5595            Some(200_000)
5596        );
5597        let bad = load_config_from_str("context_window = huge\n");
5598        assert_eq!(bad.context_window, None);
5599        assert!(bad.warnings.iter().any(|w| w.contains("context_window")));
5600    }
5601
5602    /// The /settings sidecar row writes `sidecar_toggle_key`; it is read by
5603    /// the keybind registry and must not trip the unknown-key warning.
5604    #[test]
5605    fn sidecar_toggle_key_is_a_known_key() {
5606        let cfg = load_config_from_str("sidecar_toggle_key = F2\n");
5607        assert!(cfg.warnings.is_empty(), "{:?}", cfg.warnings);
5608        assert!(KNOWN_CONFIG_KEYS.contains(&"sidecar_toggle_key"));
5609        assert!(KNOWN_CONFIG_KEYS.contains(&"compaction_mode"));
5610        assert!(KNOWN_CONFIG_KEYS.contains(&"compaction_exclude"));
5611    }
5612
5613    /// `config_content_problems` sees the stderr-only family diagnostics
5614    /// (shell/events/turn_budget/memory) as well as `warnings`, and nothing
5615    /// for valid values.
5616    #[test]
5617    fn config_content_problems_covers_family_diagnostics() {
5618        for bad in [
5619            "shell.max_sessions = lots",
5620            "shell.readiness_strategy = eventually",
5621            "events.auto_turn = maybe",
5622            "events.auto_turn_cap = many",
5623            "turn_budget.worker.max_cost_usd = cheap",
5624            "turn_budget.nobody.max_tool_calls = 3",
5625            "memory.capture_tools = everything",
5626            "memory.recall_max_records = -1",
5627            "memory.user_scope = yes",
5628            "cache_ttl = forever",
5629        ] {
5630            let problems = config_content_problems(bad);
5631            assert!(!problems.is_empty(), "{bad} should be reported");
5632        }
5633        for good in [
5634            "shell.max_sessions = 8",
5635            "shell.readiness_strategy = prompt",
5636            "events.auto_turn = off",
5637            "events.auto_turn_cap = unlimited",
5638            "turn_budget.worker.max_cost_usd = 2.5",
5639            "memory.capture_tools = off",
5640            "memory.recall_max_records = 4",
5641            "memory.user_scope = true",
5642            "cache_ttl = hybrid",
5643            "cache_ttl = auto",
5644        ] {
5645            let problems = config_content_problems(good);
5646            assert!(problems.is_empty(), "{good}: {problems:?}");
5647        }
5648        // The family values still land exactly as before.
5649        let cfg = load_config_from_str("shell.max_sessions = 8\nevents.auto_turn = maybe\n");
5650        assert_eq!(cfg.shell.max_sessions, 8);
5651        assert!(!cfg.events.auto_turn, "unrecognised auto_turn still fails safe to false");
5652    }
5653
5654    #[test]
5655    fn bare_or_empty_auth_family_keys_warn() {
5656        let cfg = load_config_from_str(
5657            "auth.account = work\nauth.account. = work\nauth.auto = on\nauth.auto. = 1\n",
5658        );
5659        let w = &cfg.warnings;
5660        for key in ["auth.account", "auth.account."] {
5661            assert!(
5662                w.contains(&format!("unknown key '{key}' (did you mean 'auth.account.<provider>'?)")),
5663                "{key}: {w:?}"
5664            );
5665        }
5666        for key in ["auth.auto", "auth.auto."] {
5667            assert!(
5668                w.contains(&format!("unknown key '{key}' (did you mean 'auth.auto.<key>'?)")),
5669                "{key}: {w:?}"
5670            );
5671        }
5672        assert!(cfg.auth.accounts.is_empty() && cfg.auth.auto.is_empty());
5673    }
5674
5675    #[test]
5676    fn unknown_auth_keys_warn_with_a_suggestion() {
5677        let cfg = load_config_from_str(
5678            "auth.acount.anthropic = work\nauth.remote_endpont = x\nauth.bogus = 1\n\
5679             auth.account.anthropic = work\nauth.machine_token = t\n",
5680        );
5681        let w = &cfg.warnings;
5682        assert!(
5683            w.contains(&"unknown key 'auth.acount.anthropic' (did you mean 'auth.account.anthropic'?)".into()),
5684            "{w:?}"
5685        );
5686        assert!(
5687            w.contains(&"unknown key 'auth.remote_endpont' (did you mean 'auth.remote_endpoint'?)".into()),
5688            "{w:?}"
5689        );
5690        assert!(w.contains(&"unknown key 'auth.bogus' — ignored".into()), "{w:?}");
5691        assert_eq!(w.iter().filter(|w| w.contains("auth.")).count(), 3, "{w:?}");
5692        assert_eq!(cfg.auth.accounts.get("anthropic").map(String::as_str), Some("work"));
5693        assert!(!cfg.auth.accounts.contains_key("acount.anthropic"));
5694    }
5695
5696    /// An absolute path on this platform: `/opt/axel` has no drive on Windows.
5697    #[cfg(windows)]
5698    const ABSOLUTE_SAMPLE_PATH: &str = r"C:\opt\axel";
5699    #[cfg(not(windows))]
5700    const ABSOLUTE_SAMPLE_PATH: &str = "/opt/axel";
5701
5702    /// Every listed family key is accepted by its parser for some value
5703    /// (catches a typo in `FAMILY_CONFIG_KEYS`).
5704    #[test]
5705    fn family_config_keys_are_real_parser_keys() {
5706        let sample = |key: &str| -> &'static str {
5707            match key {
5708                "shell.readiness_strategy" => "hybrid",
5709                "memory.backend" => "legacy",
5710                "memory.user_scope" | "memory.default_mode_confirmed" => "true",
5711                "memory.default_mode" => "off",
5712                "memory.capture_tools" => "summary_only",
5713                "memory.capture_assistant" | "memory.capture_user" => "on",
5714                "memory.auto_consolidate" | "memory.local_embeddings" | "memory.gliner" => "off",
5715                "memory.axel.executable" | "memory.axel.brain" => ABSOLUTE_SAMPLE_PATH,
5716                "events.auto_turn" => "on",
5717                "server.allowed_origins" | "server.token" => "x",
5718                "server.auto_approve_confirms" | "bridge.heartbeat_mirror" => "false",
5719                "bridge.uds_path" => "/tmp/x.sock",
5720                "auth.remote_endpoint" | "auth.machine_token" => "x",
5721                _ => "7",
5722            }
5723        };
5724        for key in FAMILY_CONFIG_KEYS.iter().filter(|k| !k.ends_with(".*")) {
5725            assert!(!KNOWN_CONFIG_KEYS.contains(key), "{key} is listed twice");
5726            let content = format!("{key} = {}\n", sample(key));
5727            let problems = config_content_problems(&content);
5728            assert!(problems.is_empty(), "{key}: {problems:?}");
5729        }
5730    }
5731
5732    #[test]
5733    #[serial]
5734    fn validate_config_edit_uses_the_real_parser_and_whole_file() {
5735        let home = make_test_home("validate-edit");
5736        let cfg = home.join(".synaps-cli/config");
5737        with_home(&home, || {
5738            // Single-key value checks.
5739            assert!(validate_config_edit("cache_ttl", "1h").is_ok());
5740            assert!(validate_config_edit("cache_ttl", "auto").is_ok());
5741            assert!(validate_config_edit("cache_ttl", "2h").is_err());
5742            assert!(validate_config_edit("shell.max_output", "big").is_err());
5743            assert!(validate_config_edit("memory.axel.brain", "relative/brain").is_err());
5744            assert!(validate_config_edit("memory.axel.brain", ABSOLUTE_SAMPLE_PATH).is_ok());
5745            assert!(validate_config_edit("model", "a\nb").is_err());
5746
5747            // Cross-key rules see the rest of the file.
5748            std::fs::write(&cfg, "context_management.rollover_tokens = 100000\n").unwrap();
5749            assert!(validate_config_edit("context_management.pressure_tokens", "90000").is_ok());
5750            assert!(validate_config_edit("context_management.pressure_tokens", "150000").is_err());
5751            std::fs::write(&cfg, "subagent.max_total = 8\n").unwrap();
5752            assert!(validate_config_edit("subagent.max_concurrent", "8").is_ok());
5753            assert!(validate_config_edit("subagent.max_concurrent", "9").is_err());
5754            std::fs::write(&cfg, "").unwrap();
5755            assert!(validate_config_edit("memory.default_mode", "recall_once").is_err());
5756            assert!(validate_config_edit("memory.default_mode", "off").is_ok());
5757            std::fs::write(&cfg, "memory.default_mode_confirmed = true\n").unwrap();
5758            assert!(validate_config_edit("memory.default_mode", "recall_once").is_ok());
5759
5760            // A problem already in the file is not blamed on an unrelated edit.
5761            std::fs::write(&cfg, "cache_ttl = forever\n").unwrap();
5762            assert!(validate_config_edit("max_fps", "120").is_ok());
5763            // Validation never writes.
5764            assert_eq!(std::fs::read_to_string(&cfg).unwrap(), "cache_ttl = forever\n");
5765        });
5766        let _ = std::fs::remove_dir_all(&home);
5767    }
5768}