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 static IDENTITY_OVERRIDE: std::cell::RefCell<Option<String>> = const { std::cell::RefCell::new(None) };
16}
17
18pub 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#[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
54pub fn get_provider_keys() -> BTreeMap<String, String> {
58 PROVIDER_KEYS.get().cloned().unwrap_or_default()
59}
60
61pub fn get_profile() -> Option<String> {
64 PROFILE_NAME
65 .get_or_init(|| std::env::var("SYNAPS_PROFILE").ok())
66 .clone()
67}
68
69pub 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#[doc(hidden)]
88pub fn set_base_dir_for_tests(path: PathBuf) {
89 std::env::set_var("SYNAPS_BASE_DIR", path);
90}
91
92pub 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
106pub 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
120pub 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
132pub 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#[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
173pub struct ContextManagementConfig {
174 pub mode: ContextManagementMode,
175 pub pressure_tokens: Option<u64>,
177 pub rollover_tokens: Option<u64>,
179 pub reserve_tokens: u64,
181 pub finish_rounds: u32,
184 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 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 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
268fn 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
315pub struct StartupConfig {
316 pub quick_start: bool,
324 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
344pub struct DaemonConfig {
345 pub idle_exit_secs: u64,
349 pub prompt_abandon_secs: u64,
353 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
374pub struct SubagentLimits {
375 pub max_concurrent: usize,
377 pub max_total: usize,
380 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
395fn 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
418fn 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
441fn 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#[derive(Debug, Clone, Default)]
459pub struct ServerConfig {
460 pub allowed_origins: Vec<String>,
462 pub token: Option<String>,
465 pub auto_approve_confirms: bool,
467 pub max_message_size: Option<usize>,
470}
471
472#[derive(Debug, Clone)]
478pub struct BridgeConfig {
479 pub uds_path: Option<PathBuf>,
482 pub heartbeat_mirror: bool,
485 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 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#[derive(Debug, Clone, Default)]
512pub struct AuthConfig {
513 pub remote_endpoint: Option<String>,
517 pub machine_token: Option<String>,
520 pub accounts: BTreeMap<String, String>,
525 pub auto: BTreeMap<String, String>,
527}
528
529impl AuthConfig {
530 pub fn account_policy(&self) -> crate::core::auth::AccountPolicy {
533 crate::core::auth::AccountPolicy::from_config_map(&self.accounts).0
534 }
535
536 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 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
568pub const MAX_CACHE_KEEPWARM_HOURS: u32 = 48;
572
573pub const DEFAULT_CACHE_KEEPWARM_AUTO_MAX_HOURS: u32 = 24;
581
582pub const DEFAULT_SUBAGENT_COLD_RESUME_COMPACT_TOKENS: u64 = 100_000;
587
588pub const DEFAULT_SUBAGENT_ROLLOVER_TOKENS: u64 = 200_000;
594
595pub const MIN_SUBAGENT_ROLLOVER_TOKENS: u64 = 20_000;
599
600#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
612pub enum CacheKeepWarm {
613 #[default]
614 Off,
615 Auto,
616 Hours(u32),
617}
618
619impl CacheKeepWarm {
620 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#[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 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 pub fn wire_default() -> ClientInput {
684 ClientInput::Exclusive
685 }
686}
687
688#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
702pub enum ThinkingDisplay {
703 #[default]
704 Summarized,
705 Omitted,
706}
707
708impl ThinkingDisplay {
709 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
752pub enum CacheTtl {
753 #[default]
754 FiveMinutes,
755 OneHour,
756 Hybrid,
757}
758
759impl CacheTtl {
760 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
802pub enum CacheTtlSetting {
803 #[default]
804 Auto,
805 Fixed(CacheTtl),
806}
807
808impl CacheTtlSetting {
809 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 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 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
870pub enum RequestCompressionMode {
871 #[default]
872 Auto,
873 On,
874 Off,
875}
876
877impl RequestCompressionMode {
878 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
899pub const DEFAULT_REQUEST_COMPRESSION_LEVEL: u32 = 1;
902pub const MAX_REQUEST_COMPRESSION_LEVEL: u32 = 9;
906
907pub const DEFAULT_TOOL_OUTPUT_SPILL_BYTES: usize = 12 * 1024;
915pub const DEFAULT_TOOL_OUTPUT_SPILL_HEAD_BYTES: usize = 4 * 1024;
918pub const DEFAULT_TOOL_OUTPUT_SPILL_TAIL_BYTES: usize = 3 * 1024;
921
922#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
925pub enum ActivationConfirm {
926 #[default]
928 Auto,
929 Prompt,
932 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
964pub enum ThemeTransitionMode {
965 #[default]
966 On,
967 Off,
968 Ms(u64),
969}
970
971impl ThemeTransitionMode {
972 pub const DEFAULT_MS: u64 = 350;
975 pub const MAX_MS: u64 = 2000;
977
978 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 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#[derive(Debug, Clone)]
1006pub struct EventsConfig {
1007 pub auto_turn: bool,
1013 pub auto_turn_cap: u32,
1020}
1021
1022pub 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
1034pub 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
1046fn 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 "true" | "1" | "yes" | "on" => cfg.auto_turn = true,
1055 "false" | "0" | "no" | "off" => cfg.auto_turn = false,
1057 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 } Ok(())
1082}
1083
1084#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1091pub enum TurnLimit<T> {
1092 Max(T),
1093 Unlimited,
1094}
1095
1096impl<T> TurnLimit<T> {
1097 pub fn limit(self) -> Option<T> {
1099 match self {
1100 TurnLimit::Max(v) => Some(v),
1101 TurnLimit::Unlimited => None,
1102 }
1103 }
1104
1105 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#[derive(Debug, Clone, Default, PartialEq)]
1118pub struct TurnBudgetOverrides {
1119 pub max_provider_rounds: Option<TurnLimit<u32>>,
1123 pub max_tool_calls: Option<TurnLimit<u32>>,
1126 pub max_elapsed_secs: Option<u64>,
1129 pub max_accumulated_tool_result_bytes: Option<TurnLimit<usize>>,
1133 pub max_context_tokens: Option<u64>,
1134 pub max_cost_usd: Option<f64>,
1135 pub max_round_renewals: Option<u32>,
1140}
1141
1142#[derive(Debug, Clone, Default, PartialEq)]
1144pub struct TurnBudgetsConfig {
1145 pub foreground: TurnBudgetOverrides,
1146 pub autonomous: TurnBudgetOverrides,
1147 pub event: TurnBudgetOverrides,
1149 pub worker: TurnBudgetOverrides,
1150}
1151
1152fn 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 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#[derive(Debug, Clone, PartialEq)]
1246pub struct MemoryConfig {
1247 pub default_mode: String,
1253 pub default_mode_confirmed: bool,
1257 pub recall_max_records: u32,
1259 pub recall_max_tokens: u32,
1261 pub recall_timeout_ms: u64,
1263 pub capture_tools: String,
1265 pub capture_assistant: bool,
1267 pub capture_user: bool,
1269 pub auto_consolidate: String,
1271 pub local_embeddings: String,
1273 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
1295const KNOWN_MEMORY_MODES: &[&str] = &[
1300 "off",
1301 "recall_once",
1302 "recall_each_prompt",
1303 "capture_only",
1304 "capture_and_recall",
1305];
1306
1307fn 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 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1375pub enum MemoryBackendKind {
1376 #[default]
1377 Legacy,
1378 Axel,
1379 Unavailable,
1381}
1382
1383#[derive(Debug, Clone, PartialEq, Eq, Default)]
1384pub struct MemoryBackendConfig {
1385 pub kind: MemoryBackendKind,
1386 pub executable: Option<PathBuf>,
1388 pub brain: Option<PathBuf>,
1390 pub user_scope: bool,
1392}
1393
1394#[derive(Debug, Clone)]
1396pub struct SynapsConfig {
1397 pub model: Option<String>,
1398 pub thinking_budget: Option<u32>,
1399 pub thinking_level: Option<crate::core::reasoning::ReasoningLevel>,
1403 pub context_window: Option<u64>, pub context_management: ContextManagementConfig,
1405 pub startup: StartupConfig,
1407 pub daemon: DaemonConfig,
1409 pub subagent: SubagentLimits,
1411 pub compaction_model: Option<String>, pub compaction_mode: crate::core::compaction::CompactionMode,
1415 pub compaction_exclude: Vec<crate::core::compaction::ContentClass>,
1417 pub max_tool_output: usize, pub tool_output_spill_bytes: usize,
1425 pub tool_output_spill_head_bytes: usize,
1429 pub tool_output_spill_tail_bytes: usize,
1433 pub read_dedupe: bool,
1439 pub bash_timeout: u64, pub bash_max_timeout: u64, pub subagent_timeout: u64, pub subagent_cold_resume_compact_tokens: u64,
1449 pub subagent_context_management: ContextManagementMode,
1455 pub subagent_rollover_tokens: u64,
1461 pub subagent_transform_hooks: bool,
1467 pub subagent_thinking: crate::core::reasoning::ReasoningLevel,
1469 pub api_retries: u32, pub refusal_retries: u32, pub telemetry: String, pub telemetry_log_generations: u32,
1476 pub cache_diagnostics: bool, pub cache_ttl: CacheTtlSetting,
1480 pub cache_keepwarm: CacheKeepWarm,
1484 pub cache_keepwarm_auto_max_hours: u32,
1489 pub cache_keepwarm_headless: bool,
1494 pub cache_keepwarm_tools: bool,
1501 pub request_compression: RequestCompressionMode,
1504 pub request_compression_level: u32,
1508 pub client_input: ClientInput,
1513 pub thinking_display: ThinkingDisplay,
1516 pub max_fps: u32,
1520 pub scroll_lines: Option<u16>,
1524 pub theme: Option<String>,
1525 pub theme_transition: ThemeTransitionMode,
1528 pub tui_background_opaque: bool,
1530 pub tui_streaming_glow: bool,
1532 pub tui_smooth_streaming: bool,
1535 pub process_scopes: bool,
1541 pub tool_scope_slice: Option<String>,
1550 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 pub progressive_tool_disclosure: bool,
1565 pub tools_activation_confirm: ActivationConfirm,
1571 pub tools_deferred_loading: bool,
1577 pub session_persistence: crate::core::session_journal::SessionPersistence,
1582 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 pub turn_budgets: TurnBudgetsConfig,
1594 pub memory: MemoryConfig,
1596 pub memory_backend: MemoryBackendConfig,
1598 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
1679pub 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
1717pub const MAX_TELEMETRY_LOG_GENERATIONS: u32 = 100;
1720
1721pub 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 "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
1798pub const FAMILY_CONFIG_KEYS: &[&str] = &[
1805 "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 "events.auto_turn",
1816 "events.auto_turn_cap",
1817 "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",
1848 "memory.user_scope",
1849 "memory.axel.executable",
1850 "memory.axel.brain",
1851 "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 "server.allowed_origins",
1865 "server.token",
1866 "server.auto_approve_confirms",
1867 "server.max_message_size",
1868 "bridge.uds_path",
1870 "bridge.heartbeat_mirror",
1871 "bridge.heartbeat_timeout_ms",
1872 "auth.remote_endpoint",
1874 "auth.machine_token",
1875 "provider.*",
1877 "keybind.*",
1878];
1879
1880fn 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
1897fn 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
1907fn 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
1920fn 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 }
1959 }
1960 Ok(())
1961}
1962
1963#[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 }
1991 }
1992}
1993
1994fn 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 }
2020 }
2021}
2022
2023fn 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 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 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
2067fn 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 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 }
2100 }
2101}
2102
2103pub 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
2111pub 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
2123pub 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 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
2151pub 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 let _ = PROVIDER_KEYS.set(config.provider_keys.clone());
2166
2167 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
2177fn apply_config_content(config: &mut SynapsConfig, content: &str) {
2180 apply_config_content_with(config, content, &mut |diagnostic: String| eprintln!("{diagnostic}"));
2183}
2184
2185fn 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 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 "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 "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 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 "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 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 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 } else {
2697 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 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 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 if invalid_memory_backend {
2743 config.memory_backend.kind = MemoryBackendKind::Unavailable;
2744 }
2745
2746 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 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
2768pub 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
2787struct 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
2825pub 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
2833fn 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 #[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
2851fn 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
2890pub 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
2905pub 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
2913pub fn is_favorite_model(id: &str) -> bool {
2915 load_config().favorite_models.iter().any(|v| v == id.trim())
2916}
2917
2918pub 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
2934pub 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 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
2964pub fn is_secret_env(key: &str, value: &str) -> bool {
2967 is_secret_key(key) || is_secret_env_value(value)
2968}
2969
2970macro_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
2986pub 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
2998pub 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#[cfg(windows)]
3008pub const DEFAULT_PROMPT: &str = DEFAULT_PROMPT_WINDOWS;
3009#[cfg(not(windows))]
3011pub const DEFAULT_PROMPT: &str = DEFAULT_PROMPT_UNIX;
3012
3013pub 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 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 assert!(v("https://abc123def456@o1.ingest.sentry.io/42"));
3063 assert!(v("-Dspring.datasource.url=jdbc:postgresql://u:p@h/db"));
3065 assert!(v("redis://h1:6379,redis://u:p@h2:6379"));
3066 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 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 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 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 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 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 let mut accepted: Vec<String> = Vec::new();
3377 for bad in [
3378 "worker", "worker.service", "a/b.slice", "work er.slice", "work@er.slice", "-.slice", "-worker.slice", "--worker.slice",
3386 ".slice", "worker--agent.slice",
3388 "worker-.slice",
3389 too_long.as_str(), "wörker.slice", "worker\0.slice", "\"worker-agent.slice\"", "'worker-agent.slice'",
3394 "5", "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 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 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 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 assert_eq!(config.turn_budgets.worker.max_tool_calls, None);
3798 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 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 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 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 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 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 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 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 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 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 #[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 #[test]
4320 fn test_cache_ttl_parse_table() {
4321 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 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 assert_eq!(CacheTtl::parse("hybrid"), Some(CacheTtl::Hybrid));
4331 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 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 assert_eq!(CacheTtl::default(), CacheTtl::FiveMinutes);
4346 assert_eq!(CacheTtlSetting::default(), CacheTtlSetting::Auto);
4347 assert_eq!(SynapsConfig::default().cache_ttl, CacheTtlSetting::Auto);
4348 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 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 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 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 #[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 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 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 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 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 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 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 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 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 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 #[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 #[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 #[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 assert_eq!(resolve_system_prompt(Some("explicit")), "explicit");
4679 });
4680 let _ = std::fs::remove_dir_all(&home);
4681 }
4682
4683 #[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 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 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 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 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 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 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 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 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 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 if let Some(home) = original_home {
5272 std::env::set_var("HOME", home);
5273 } else {
5274 std::env::remove_var("HOME");
5275 }
5276
5277 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)); 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 #[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 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 #[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 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 #[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 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 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 #[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 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 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 #[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 #[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 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 #[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 #[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 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 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 std::fs::write(&cfg, "cache_ttl = forever\n").unwrap();
5762 assert!(validate_config_edit("max_fps", "120").is_ok());
5763 assert_eq!(std::fs::read_to_string(&cfg).unwrap(), "cache_ttl = forever\n");
5765 });
5766 let _ = std::fs::remove_dir_all(&home);
5767 }
5768}