1use std::collections::BTreeMap;
2use std::path::PathBuf;
3use std::sync::OnceLock;
4
5static PROFILE_NAME: OnceLock<Option<String>> = OnceLock::new();
6static PROVIDER_KEYS: OnceLock<BTreeMap<String, String>> = OnceLock::new();
7static IDENTITY: OnceLock<String> = OnceLock::new();
8
9pub const DEFAULT_IDENTITY: &str =
10 "You are an AI assistant running in SynapsCLI, an open-source agent runtime.";
11
12thread_local! {
13 static IDENTITY_OVERRIDE: std::cell::RefCell<Option<String>> = const { std::cell::RefCell::new(None) };
15}
16
17pub fn get_identity() -> String {
21 if let Some(pinned) = IDENTITY_OVERRIDE.with(|o| o.borrow().clone()) {
22 return pinned;
23 }
24 IDENTITY
25 .get()
26 .cloned()
27 .unwrap_or_else(|| DEFAULT_IDENTITY.to_string())
28}
29
30#[doc(hidden)]
40pub fn with_identity_for_tests<R>(identity: &str, f: impl FnOnce() -> R) -> R {
41 struct Restore(Option<String>);
42 impl Drop for Restore {
43 fn drop(&mut self) {
44 let prev = self.0.take();
45 IDENTITY_OVERRIDE.with(|o| *o.borrow_mut() = prev);
46 }
47 }
48 let prev = IDENTITY_OVERRIDE.with(|o| o.borrow_mut().replace(identity.to_string()));
49 let _restore = Restore(prev);
50 f()
51}
52
53pub fn get_provider_keys() -> BTreeMap<String, String> {
57 PROVIDER_KEYS.get().cloned().unwrap_or_default()
58}
59
60pub fn get_profile() -> Option<String> {
63 PROFILE_NAME
64 .get_or_init(|| std::env::var("SYNAPS_PROFILE").ok())
65 .clone()
66}
67
68pub fn set_profile(name: Option<String>) {
72 let _ = PROFILE_NAME.set(name);
73}
74
75pub fn base_dir() -> PathBuf {
76 if let Ok(path) = std::env::var("SYNAPS_BASE_DIR") {
77 return PathBuf::from(path);
78 }
79 let home = std::env::var("HOME")
80 .or_else(|_| std::env::var("USERPROFILE"))
81 .unwrap_or_else(|_| ".".to_string());
82 PathBuf::from(home).join(".synaps-cli")
83}
84
85#[doc(hidden)]
87pub fn set_base_dir_for_tests(path: PathBuf) {
88 std::env::set_var("SYNAPS_BASE_DIR", path);
89}
90
91pub fn resolve_read_path(filename: &str) -> PathBuf {
93 let base = base_dir();
94
95 if let Some(profile) = get_profile() {
96 let profile_path = base.join(&profile).join(filename);
97 if profile_path.exists() {
98 return profile_path;
99 }
100 }
101
102 base.join(filename)
103}
104
105pub fn resolve_read_path_extended(path: &str) -> PathBuf {
107 let base = base_dir();
108
109 if let Some(profile) = get_profile() {
110 let profile_path = base.join(&profile).join(path);
111 if profile_path.exists() {
112 return profile_path;
113 }
114 }
115
116 base.join(path)
117}
118
119pub fn resolve_write_path(filename: &str) -> PathBuf {
121 let mut base = base_dir();
122
123 if let Some(profile) = get_profile() {
124 base.push(profile);
125 }
126
127 let _ = std::fs::create_dir_all(&base);
128 base.join(filename)
129}
130
131pub fn get_active_config_dir() -> PathBuf {
133 let mut base = base_dir();
134 if let Some(profile) = get_profile() {
135 base.push(profile);
136 }
137 base
138}
139
140#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
142pub enum ContextManagementMode {
143 #[default]
144 Off,
145 Auto,
146}
147
148impl ContextManagementMode {
149 pub fn parse(value: &str) -> Option<Self> {
150 match value.trim().to_ascii_lowercase().as_str() {
151 "off" => Some(Self::Off),
152 "auto" => Some(Self::Auto),
153 _ => None,
154 }
155 }
156
157 pub fn as_str(self) -> &'static str {
158 match self {
159 Self::Off => "off",
160 Self::Auto => "auto",
161 }
162 }
163}
164
165#[derive(Debug, Clone, Copy, PartialEq, Eq)]
172pub struct ContextManagementConfig {
173 pub mode: ContextManagementMode,
174 pub pressure_tokens: Option<u64>,
176 pub rollover_tokens: Option<u64>,
178 pub reserve_tokens: u64,
180 pub finish_rounds: u32,
183 pub target_tokens: Option<u64>,
187}
188
189impl Default for ContextManagementConfig {
190 fn default() -> Self {
191 Self {
192 mode: ContextManagementMode::Off,
193 pressure_tokens: None,
194 rollover_tokens: None,
195 reserve_tokens: 16_000,
196 finish_rounds: 2,
197 target_tokens: None,
198 }
199 }
200}
201
202impl ContextManagementConfig {
203 pub const MAX_FINISH_ROUNDS: u32 = 8;
204 pub const MAX_RESERVE_TOKENS: u64 = 1_000_000;
205
206 pub fn validate(&self) -> Result<(), &'static str> {
209 if self.pressure_tokens == Some(0) {
210 return Err("pressure_tokens must be positive");
211 }
212 if self.rollover_tokens.is_some_and(|v| v < 2) {
213 return Err("rollover_tokens must be at least 2");
214 }
215 if let (Some(pressure), Some(rollover)) = (self.pressure_tokens, self.rollover_tokens) {
216 if pressure >= rollover {
217 return Err("pressure_tokens must be below rollover_tokens");
218 }
219 }
220 if self.target_tokens == Some(0) {
221 return Err("target_tokens must be positive");
222 }
223 if let (Some(target), Some(pressure)) = (self.target_tokens, self.pressure_tokens) {
224 if target >= pressure {
225 return Err("target_tokens must be below pressure_tokens");
226 }
227 }
228 if !(1..=Self::MAX_RESERVE_TOKENS).contains(&self.reserve_tokens) {
229 return Err("reserve_tokens must be between 1 and 1000000");
230 }
231 if self.finish_rounds > Self::MAX_FINISH_ROUNDS {
232 return Err("finish_rounds must be between 0 and 8");
233 }
234 Ok(())
235 }
236
237 pub fn validate_for_window(&self, context_window_tokens: u64) -> Result<(), &'static str> {
240 self.validate()?;
241 if context_window_tokens < 2 {
242 return Err("context window must contain at least two tokens");
243 }
244 if self
245 .pressure_tokens
246 .is_some_and(|v| v >= context_window_tokens)
247 {
248 return Err("pressure_tokens must be below the context window");
249 }
250 if self
251 .rollover_tokens
252 .is_some_and(|v| v > context_window_tokens)
253 {
254 return Err("rollover_tokens must not exceed the context window");
255 }
256 if let Some(target) = self.target_tokens {
257 let (pressure, _) =
258 crate::core::context_policy::ContextThresholds::bands(self, context_window_tokens);
259 if target >= pressure {
260 return Err("target_tokens must be below pressure_tokens");
261 }
262 }
263 Ok(())
264 }
265}
266
267fn parse_context_management_config_key(
270 config: &mut ContextManagementConfig,
271 key: &str,
272 value: &str,
273) -> Result<(), &'static str> {
274 match key {
275 "context_management.mode" => {
276 config.mode = ContextManagementMode::parse(value).ok_or("expected off or auto")?;
277 }
278 "context_management.pressure_tokens"
279 | "context_management.rollover_tokens"
280 | "context_management.target_tokens" => {
281 let tokens = if value.eq_ignore_ascii_case("auto") {
282 None
283 } else {
284 Some(
285 value
286 .parse::<u64>()
287 .map_err(|_| "expected auto or an unsigned token count")?,
288 )
289 };
290 match key {
291 "context_management.pressure_tokens" => config.pressure_tokens = tokens,
292 "context_management.rollover_tokens" => config.rollover_tokens = tokens,
293 _ => config.target_tokens = tokens,
294 }
295 }
296 "context_management.reserve_tokens" => {
297 config.reserve_tokens = value
298 .parse()
299 .map_err(|_| "expected an unsigned token count")?;
300 }
301 "context_management.finish_rounds" => {
302 config.finish_rounds = value
303 .parse()
304 .map_err(|_| "expected an unsigned round count")?;
305 }
306 _ => return Err("unknown context management key"),
307 }
308 Ok(())
309}
310
311#[derive(Debug, Clone, Copy, PartialEq, Eq)]
314pub struct StartupConfig {
315 pub quick_start: bool,
323 pub extensions_ready_timeout_secs: u64,
329}
330
331impl Default for StartupConfig {
332 fn default() -> Self {
333 Self {
334 quick_start: true,
335 extensions_ready_timeout_secs: 30,
336 }
337 }
338}
339
340#[derive(Debug, Clone, Copy, PartialEq, Eq)]
343pub struct DaemonConfig {
344 pub idle_exit_secs: u64,
348 pub prompt_abandon_secs: u64,
352 pub parked_evict_secs: u64,
355}
356
357impl Default for DaemonConfig {
358 fn default() -> Self {
359 Self {
360 idle_exit_secs: 10,
361 prompt_abandon_secs: 3600,
362 parked_evict_secs: 3600,
363 }
364 }
365}
366
367#[derive(Debug, Clone, Copy, PartialEq, Eq)]
373pub struct SubagentLimits {
374 pub max_concurrent: usize,
376 pub max_total: usize,
379 pub max_depth: u16,
382}
383
384impl Default for SubagentLimits {
385 fn default() -> Self {
386 Self {
387 max_concurrent: 16,
388 max_total: 64,
389 max_depth: 4,
390 }
391 }
392}
393
394fn parse_subagent_config_key(
397 config: &mut SubagentLimits,
398 key: &str,
399 value: &str,
400) -> Result<(), &'static str> {
401 let positive = || match value.trim().parse::<usize>() {
402 Ok(n) if n > 0 => Ok(n),
403 _ => Err("expected a whole number of at least 1"),
404 };
405 match key {
406 "subagent.max_concurrent" => config.max_concurrent = positive()?,
407 "subagent.max_total" => config.max_total = positive()?,
408 "subagent.max_depth" => {
409 config.max_depth =
410 u16::try_from(positive()?).map_err(|_| "expected a whole number from 1 to 65535")?
411 }
412 _ => return Err("unknown subagent key"),
413 }
414 Ok(())
415}
416
417fn parse_startup_config_key(
419 config: &mut StartupConfig,
420 key: &str,
421 value: &str,
422) -> Result<(), &'static str> {
423 match key {
424 "startup.quick_start" => {
425 config.quick_start = match value.trim() {
426 "true" | "1" | "on" | "yes" => true,
427 "false" | "0" | "off" | "no" => false,
428 _ => return Err("expected on or off"),
429 };
430 }
431 "startup.extensions_ready_timeout_secs" => {
432 config.extensions_ready_timeout_secs =
433 value.trim().parse().map_err(|_| "expected an unsigned second count")?;
434 }
435 _ => return Err("unknown startup key"),
436 }
437 Ok(())
438}
439
440fn parse_daemon_config_key(
442 config: &mut DaemonConfig,
443 key: &str,
444 value: &str,
445) -> Result<(), &'static str> {
446 let secs = || value.trim().parse::<u64>().map_err(|_| "expected an unsigned second count");
447 match key {
448 "daemon.idle_exit_secs" => config.idle_exit_secs = secs()?,
449 "daemon.prompt_abandon_secs" => config.prompt_abandon_secs = secs()?,
450 "daemon.parked_evict_secs" => config.parked_evict_secs = secs()?,
451 _ => return Err("unknown daemon key"),
452 }
453 Ok(())
454}
455
456#[derive(Debug, Clone, Default)]
459pub struct ServerConfig {
460 pub auto_approve_confirms: bool,
462}
463
464const REMOVED_SERVER_KEYS: [&str; 3] =
467 ["server.allowed_origins", "server.token", "server.max_message_size"];
468fn removed_server_warning() -> String {
469 format!(
470 "server.allowed_origins, server.token and server.max_message_size are ignored: they \
471 configured `synaps server`, removed in {}. Remove these lines",
472 crate::REMOVED_IN
473 )
474}
475
476#[derive(Debug, Clone, Default)]
480pub struct AuthConfig {
481 pub remote_endpoint: Option<String>,
485 pub machine_token: Option<String>,
488 pub accounts: BTreeMap<String, String>,
493 pub auto: BTreeMap<String, String>,
495}
496
497impl AuthConfig {
498 pub fn account_policy(&self) -> crate::core::auth::AccountPolicy {
501 crate::core::auth::AccountPolicy::from_config_map(&self.accounts).0
502 }
503
504 pub fn account_selector(
506 &self,
507 provider: crate::core::auth::OAuthProviderId,
508 ) -> crate::core::auth::AccountSelector {
509 self.account_policy().with_env_overlay().selector(provider)
510 }
511
512 pub fn credential_source(&self) -> crate::core::auth::CredentialSource {
524 let endpoint = std::env::var("SYNAPS_AUTH_ENDPOINT")
525 .ok()
526 .filter(|s| !s.trim().is_empty())
527 .or_else(|| self.remote_endpoint.clone());
528 let machine_token = std::env::var("SYNAPS_MACHINE_TOKEN")
529 .ok()
530 .filter(|s| !s.trim().is_empty())
531 .or_else(|| self.machine_token.clone().filter(|s| !s.trim().is_empty()));
532 crate::core::auth::CredentialSource::from_parts(endpoint, machine_token)
533 }
534}
535
536pub const MAX_CACHE_KEEPWARM_HOURS: u32 = 48;
540
541pub const DEFAULT_CACHE_KEEPWARM_AUTO_MAX_HOURS: u32 = 24;
549
550pub const DEFAULT_SUBAGENT_COLD_RESUME_COMPACT_TOKENS: u64 = 100_000;
555
556pub const DEFAULT_SUBAGENT_ROLLOVER_TOKENS: u64 = 200_000;
562
563pub const MIN_SUBAGENT_ROLLOVER_TOKENS: u64 = 20_000;
567
568#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
580pub enum CacheKeepWarm {
581 #[default]
582 Off,
583 Auto,
584 Hours(u32),
585}
586
587impl CacheKeepWarm {
588 pub fn parse(val: &str) -> Option<CacheKeepWarm> {
592 let v = val.trim().to_ascii_lowercase();
593 match v.as_str() {
594 "off" | "false" | "no" | "0" => return Some(CacheKeepWarm::Off),
595 "auto" | "on" => return Some(CacheKeepWarm::Auto),
596 _ => {}
597 }
598 let digits = v.strip_suffix('h')?;
599 match digits.parse::<u32>().ok()? {
600 0 => Some(CacheKeepWarm::Off),
601 h if h <= MAX_CACHE_KEEPWARM_HOURS => Some(CacheKeepWarm::Hours(h)),
602 _ => None,
603 }
604 }
605
606 pub fn is_off(self) -> bool {
607 self == CacheKeepWarm::Off
608 }
609}
610
611impl std::fmt::Display for CacheKeepWarm {
612 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
613 match self {
614 CacheKeepWarm::Off => f.write_str("off"),
615 CacheKeepWarm::Auto => f.write_str("auto"),
616 CacheKeepWarm::Hours(h) => write!(f, "{h}h"),
617 }
618 }
619}
620
621#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
633#[serde(rename_all = "snake_case")]
634pub enum ClientInput {
635 #[default]
636 Shared,
637 Exclusive,
638}
639
640impl ClientInput {
641 pub fn parse(val: &str) -> Option<ClientInput> {
643 match val.trim().to_ascii_lowercase().as_str() {
644 "shared" => Some(ClientInput::Shared),
645 "exclusive" => Some(ClientInput::Exclusive),
646 _ => None,
647 }
648 }
649
650 pub fn wire_default() -> ClientInput {
652 ClientInput::Exclusive
653 }
654}
655
656#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
670pub enum ThinkingDisplay {
671 #[default]
672 Summarized,
673 Omitted,
674}
675
676impl ThinkingDisplay {
677 pub fn parse(val: &str) -> Option<ThinkingDisplay> {
679 match val.trim().to_ascii_lowercase().as_str() {
680 "summarized" => Some(ThinkingDisplay::Summarized),
681 "omitted" => Some(ThinkingDisplay::Omitted),
682 _ => None,
683 }
684 }
685
686 pub fn as_str(self) -> &'static str {
688 match self {
689 ThinkingDisplay::Summarized => "summarized",
690 ThinkingDisplay::Omitted => "omitted",
691 }
692 }
693}
694
695impl std::fmt::Display for ThinkingDisplay {
696 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
697 f.write_str(self.as_str())
698 }
699}
700
701impl std::fmt::Display for ClientInput {
702 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
703 f.write_str(match self {
704 ClientInput::Shared => "shared",
705 ClientInput::Exclusive => "exclusive",
706 })
707 }
708}
709
710#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
720pub enum CacheTtl {
721 #[default]
722 FiveMinutes,
723 OneHour,
724 Hybrid,
725}
726
727impl CacheTtl {
728 pub fn parse(val: &str) -> Option<CacheTtl> {
731 match val.to_ascii_lowercase().as_str() {
732 "5m" | "5min" | "default" => Some(CacheTtl::FiveMinutes),
733 "1h" | "60m" | "1hr" => Some(CacheTtl::OneHour),
734 "hybrid" => Some(CacheTtl::Hybrid),
735 _ => None,
736 }
737 }
738
739 pub fn as_str(self) -> &'static str {
741 match self {
742 CacheTtl::FiveMinutes => "5m",
743 CacheTtl::OneHour => "1h",
744 CacheTtl::Hybrid => "hybrid",
745 }
746 }
747}
748
749impl std::fmt::Display for CacheTtl {
750 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
751 f.write_str(self.as_str())
752 }
753}
754
755#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
770pub enum CacheTtlSetting {
771 #[default]
772 Auto,
773 Fixed(CacheTtl),
774}
775
776impl CacheTtlSetting {
777 pub fn parse(val: &str) -> Option<CacheTtlSetting> {
780 let v = val.trim();
781 if v.eq_ignore_ascii_case("auto") {
782 return Some(CacheTtlSetting::Auto);
783 }
784 CacheTtl::parse(v).map(CacheTtlSetting::Fixed)
785 }
786
787 pub fn as_str(self) -> &'static str {
789 match self {
790 CacheTtlSetting::Auto => "auto",
791 CacheTtlSetting::Fixed(ttl) => ttl.as_str(),
792 }
793 }
794
795 pub fn resolve(self, oauth: bool) -> CacheTtl {
799 match self {
800 CacheTtlSetting::Fixed(ttl) => ttl,
801 CacheTtlSetting::Auto if oauth => CacheTtl::OneHour,
802 CacheTtlSetting::Auto => CacheTtl::FiveMinutes,
803 }
804 }
805
806 pub fn resolve_for_auth_type(self, auth_type: &str) -> CacheTtl {
809 self.resolve(auth_type == "oauth")
810 }
811}
812
813impl From<CacheTtl> for CacheTtlSetting {
814 fn from(ttl: CacheTtl) -> Self {
815 CacheTtlSetting::Fixed(ttl)
816 }
817}
818
819impl std::fmt::Display for CacheTtlSetting {
820 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
821 f.write_str(self.as_str())
822 }
823}
824
825#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
838pub enum RequestCompressionMode {
839 #[default]
840 Auto,
841 On,
842 Off,
843}
844
845impl RequestCompressionMode {
846 pub fn parse(val: &str) -> Option<RequestCompressionMode> {
848 match val.trim().to_ascii_lowercase().as_str() {
849 "auto" => Some(RequestCompressionMode::Auto),
850 "on" => Some(RequestCompressionMode::On),
851 "off" => Some(RequestCompressionMode::Off),
852 _ => None,
853 }
854 }
855}
856
857impl std::fmt::Display for RequestCompressionMode {
858 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
859 f.write_str(match self {
860 RequestCompressionMode::Auto => "auto",
861 RequestCompressionMode::On => "on",
862 RequestCompressionMode::Off => "off",
863 })
864 }
865}
866
867pub const DEFAULT_REQUEST_COMPRESSION_LEVEL: u32 = 1;
870pub const MAX_REQUEST_COMPRESSION_LEVEL: u32 = 9;
874
875pub const DEFAULT_TOOL_OUTPUT_SPILL_BYTES: usize = 12 * 1024;
883pub const DEFAULT_TOOL_OUTPUT_SPILL_HEAD_BYTES: usize = 4 * 1024;
886pub const DEFAULT_TOOL_OUTPUT_SPILL_TAIL_BYTES: usize = 3 * 1024;
889
890#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
893pub enum ActivationConfirm {
894 #[default]
896 Auto,
897 Prompt,
900 Deny,
902}
903
904impl ActivationConfirm {
905 pub fn parse(val: &str) -> Option<Self> {
906 match val.trim().to_ascii_lowercase().as_str() {
907 "auto" => Some(Self::Auto),
908 "prompt" => Some(Self::Prompt),
909 "deny" => Some(Self::Deny),
910 _ => None,
911 }
912 }
913
914 pub fn as_str(self) -> &'static str {
915 match self {
916 Self::Auto => "auto",
917 Self::Prompt => "prompt",
918 Self::Deny => "deny",
919 }
920 }
921}
922
923#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
932pub enum ThemeTransitionMode {
933 #[default]
934 On,
935 Off,
936 Ms(u64),
937}
938
939impl ThemeTransitionMode {
940 pub const DEFAULT_MS: u64 = 350;
943 pub const MAX_MS: u64 = 2000;
945
946 pub fn parse(val: &str) -> Option<ThemeTransitionMode> {
949 match val.to_ascii_lowercase().as_str() {
950 "on" | "true" | "yes" => Some(ThemeTransitionMode::On),
951 "off" | "false" | "no" => Some(ThemeTransitionMode::Off),
952 other => other
953 .parse::<u64>()
954 .ok()
955 .map(|ms| ThemeTransitionMode::Ms(ms.min(Self::MAX_MS))),
956 }
957 }
958
959 pub fn duration_ms(self) -> u64 {
961 match self {
962 ThemeTransitionMode::On => Self::DEFAULT_MS,
963 ThemeTransitionMode::Off => 0,
964 ThemeTransitionMode::Ms(ms) => ms.min(Self::MAX_MS),
965 }
966 }
967}
968
969#[derive(Debug, Clone)]
974pub struct EventsConfig {
975 pub auto_turn: bool,
981 pub auto_turn_cap: u32,
988}
989
990pub const DEFAULT_AUTO_TURN_CAP: u32 = 5;
992
993impl Default for EventsConfig {
994 fn default() -> Self {
995 Self {
996 auto_turn: true,
997 auto_turn_cap: DEFAULT_AUTO_TURN_CAP,
998 }
999 }
1000}
1001
1002pub fn parse_auto_turn_cap(val: &str) -> Option<u32> {
1007 let normalised = val.trim().to_lowercase();
1008 match normalised.as_str() {
1009 "unlimited" | "inf" | "infinite" | "infinity" | "none" => Some(0),
1010 n => n.parse::<u32>().ok(),
1011 }
1012}
1013
1014fn parse_events_config_key(cfg: &mut EventsConfig, key: &str, val: &str) -> Result<(), String> {
1018 if key == "events.auto_turn" {
1019 let normalised = val.trim().to_lowercase();
1020 match normalised.as_str() {
1021 "true" | "1" | "yes" | "on" => cfg.auto_turn = true,
1023 "false" | "0" | "no" | "off" => cfg.auto_turn = false,
1025 other => {
1027 cfg.auto_turn = false;
1028 return Err(format!(
1029 "warning: config: unrecognised value for events.auto_turn = {:?}; \
1030 expected true/false/yes/no/on/off/1/0 — defaulting to false",
1031 other
1032 ));
1033 }
1034 }
1035 } else if key == "events.auto_turn_cap" {
1036 match parse_auto_turn_cap(val) {
1037 Some(cap) => cfg.auto_turn_cap = cap,
1038 None => {
1039 return Err(format!(
1040 "warning: config: unrecognised value for events.auto_turn_cap = {:?}; \
1041 expected a non-negative integer or unlimited/inf/infinite (0 = unlimited) \
1042 — keeping default {}",
1043 val.trim(),
1044 DEFAULT_AUTO_TURN_CAP
1045 ));
1046 }
1047 }
1048 } Ok(())
1050}
1051
1052#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1059pub enum TurnLimit<T> {
1060 Max(T),
1061 Unlimited,
1062}
1063
1064impl<T> TurnLimit<T> {
1065 pub fn limit(self) -> Option<T> {
1067 match self {
1068 TurnLimit::Max(v) => Some(v),
1069 TurnLimit::Unlimited => None,
1070 }
1071 }
1072
1073 pub fn map<U>(self, f: impl FnOnce(T) -> U) -> TurnLimit<U> {
1075 match self {
1076 TurnLimit::Max(v) => TurnLimit::Max(f(v)),
1077 TurnLimit::Unlimited => TurnLimit::Unlimited,
1078 }
1079 }
1080}
1081
1082#[derive(Debug, Clone, Default, PartialEq)]
1086pub struct TurnBudgetOverrides {
1087 pub max_provider_rounds: Option<TurnLimit<u32>>,
1091 pub max_tool_calls: Option<TurnLimit<u32>>,
1094 pub max_elapsed_secs: Option<u64>,
1097 pub max_accumulated_tool_result_bytes: Option<TurnLimit<usize>>,
1101 pub max_context_tokens: Option<u64>,
1102 pub max_cost_usd: Option<f64>,
1103 pub max_round_renewals: Option<u32>,
1108}
1109
1110#[derive(Debug, Clone, Default, PartialEq)]
1112pub struct TurnBudgetsConfig {
1113 pub foreground: TurnBudgetOverrides,
1114 pub event: TurnBudgetOverrides,
1116 pub worker: TurnBudgetOverrides,
1117}
1118
1119fn removed_autonomous_budget_warning() -> String {
1121 format!(
1122 "turn_budget.autonomous.* is ignored: `synaps agent` was removed in {}. Remove these \
1123 lines (turns started by events use turn_budget.event.*)",
1124 crate::REMOVED_IN
1125 )
1126}
1127
1128const REMOVED_BRIDGE_KEYS: [&str; 3] =
1131 ["bridge.uds_path", "bridge.heartbeat_mirror", "bridge.heartbeat_timeout_ms"];
1132fn removed_bridge_warning() -> String {
1133 format!(
1134 "bridge.uds_path, bridge.heartbeat_mirror and bridge.heartbeat_timeout_ms are ignored: \
1135 they configured `synaps watcher`, removed in {}. Remove these lines",
1136 crate::REMOVED_IN
1137 )
1138}
1139
1140fn parse_turn_budget_config_key(
1146 budgets: &mut TurnBudgetsConfig,
1147 key: &str,
1148 val: &str,
1149) -> Result<Option<String>, String> {
1150 let Some(rest) = key.strip_prefix("turn_budget.") else {
1151 return Ok(None);
1152 };
1153 let Some((role, field)) = rest.split_once('.') else {
1154 return Err(format!(
1155 "Warning: invalid turn_budget key '{key}' (expected turn_budget.<role>.<field>)"
1156 ));
1157 };
1158 let overrides = match role {
1159 "foreground" => &mut budgets.foreground,
1160 "autonomous" => return Ok(Some(removed_autonomous_budget_warning())),
1162 "event" => &mut budgets.event,
1163 "worker" => &mut budgets.worker,
1164 other => {
1165 return Err(format!(
1166 "Warning: unknown turn_budget role '{other}' (expected foreground|event|worker)"
1167 ));
1168 }
1169 };
1170 macro_rules! set {
1171 ($slot:expr, $ty:ty) => {
1172 match val.parse::<$ty>() {
1173 Ok(parsed) => $slot = Some(parsed),
1174 Err(_) => {
1175 return Err(format!(
1176 "Warning: invalid value for {key}: '{val}', using default"
1177 ))
1178 }
1179 }
1180 };
1181 }
1182 let mut deprecation = None;
1185 macro_rules! set_limit {
1186 ($slot:expr, $ty:ty) => {
1187 if val.trim_matches('"').eq_ignore_ascii_case("unlimited") {
1188 $slot = Some(TurnLimit::Unlimited);
1189 } else {
1190 match val.parse::<$ty>() {
1191 Ok(0) => {
1192 $slot = Some(TurnLimit::Max(0));
1193 deprecation = Some(format!(
1194 "{key} = 0 means \"none allowed\". `0` will be rejected in a \
1195 future release; use `unlimited` for no limit, or a positive number"
1196 ));
1197 }
1198 Ok(parsed) => $slot = Some(TurnLimit::Max(parsed)),
1199 Err(_) => {
1200 return Err(format!(
1201 "Warning: invalid value for {key}: '{val}' (expected a positive \
1202 number or unlimited), using default"
1203 ))
1204 }
1205 }
1206 }
1207 };
1208 }
1209 match field {
1210 "max_provider_rounds" => set_limit!(overrides.max_provider_rounds, u32),
1211 "max_tool_calls" => set_limit!(overrides.max_tool_calls, u32),
1212 "max_elapsed_secs" => set!(overrides.max_elapsed_secs, u64),
1213 "max_accumulated_tool_result_bytes" => {
1214 set_limit!(overrides.max_accumulated_tool_result_bytes, usize)
1215 }
1216 "max_context_tokens" => set!(overrides.max_context_tokens, u64),
1217 "max_cost_usd" => set!(overrides.max_cost_usd, f64),
1218 "max_round_renewals" => set!(overrides.max_round_renewals, u32),
1219 other => {
1220 return Err(format!("Warning: unknown turn_budget field '{other}'"))
1221 }
1222 }
1223 Ok(deprecation)
1224}
1225
1226const REMOVED_AUTOMATIC_MEMORY_KEYS: [&str; 11] = [
1231 "memory.default_mode",
1232 "memory.default_mode_confirmed",
1233 "memory.recall_max_records",
1234 "memory.recall_max_tokens",
1235 "memory.recall_timeout_ms",
1236 "memory.capture_tools",
1237 "memory.capture_assistant",
1238 "memory.capture_user",
1239 "memory.auto_consolidate",
1240 "memory.local_embeddings",
1241 "memory.gliner",
1242];
1243fn removed_automatic_memory_warning() -> String {
1244 format!(
1245 "memory.default_mode, memory.recall_* and memory.capture_* (and memory.auto_consolidate, \
1246 memory.local_embeddings, memory.gliner) are ignored: built-in automatic memory was \
1247 removed in {}; an extension provides capture and recall through hooks. Remove these lines",
1248 crate::AXEL_BACKEND_REMOVED_IN
1249 )
1250}
1251
1252fn removed_axel_backend_warning() -> String {
1258 format!(
1259 "The Axel memory backend (memory.backend = axel, memory.user_scope, memory.axel.*) is no \
1260 longer supported: since {v} Synaps keeps context archives and notes in plain files under \
1261 its base directory, always, and Axel is an extension. This session uses plain files. \
1262 Archives and notes saved to the Axel brain are not read any more: move them once with \
1263 \"axel synaps-export\" (Axel 0.1.0) and \"synaps memory import-archives\" / \
1264 \"import-notes\", install the Axel extension for recall and the forum, then remove the \
1265 memory.backend, memory.user_scope and memory.axel.* lines. See \
1266 https://github.com/HaseebKhalid1507/SynapsCLI/blob/dev/docs/upgrading/{v}.md",
1267 v = crate::AXEL_BACKEND_REMOVED_IN
1268 )
1269}
1270fn removed_memory_backend_warning() -> String {
1273 format!(
1274 "memory.backend is ignored: plain files are the only memory storage since {}. Remove \
1275 this line",
1276 crate::AXEL_BACKEND_REMOVED_IN
1277 )
1278}
1279
1280fn removed_sidecar_toggle_key_warning() -> String {
1283 format!(
1284 "sidecar_toggle_key is ignored: the plugin sidecar system was removed in {}, so core \
1285 binds no toggle key any more. Plugin keybinds and the keybinds setting still work. \
1286 Remove this line",
1287 crate::SIDECAR_REMOVED_IN
1288 )
1289}
1290
1291#[derive(Debug, Clone)]
1293pub struct SynapsConfig {
1294 pub model: Option<String>,
1295 pub thinking_budget: Option<u32>,
1296 pub thinking_level: Option<crate::core::reasoning::ReasoningLevel>,
1300 pub context_window: Option<u64>, pub context_management: ContextManagementConfig,
1302 pub startup: StartupConfig,
1304 pub daemon: DaemonConfig,
1306 pub subagent: SubagentLimits,
1308 pub compaction_model: Option<String>, pub compaction_mode: crate::core::compaction::CompactionMode,
1312 pub compaction_exclude: Vec<crate::core::compaction::ContentClass>,
1314 pub max_tool_output: usize, pub tool_output_spill_bytes: usize,
1322 pub tool_output_spill_head_bytes: usize,
1326 pub tool_output_spill_tail_bytes: usize,
1330 pub read_dedupe: bool,
1336 pub bash_timeout: u64, pub bash_max_timeout: u64, pub subagent_timeout: u64, pub subagent_cold_resume_compact_tokens: u64,
1346 pub subagent_context_management: ContextManagementMode,
1352 pub subagent_rollover_tokens: u64,
1358 pub subagent_transform_hooks: bool,
1364 pub subagent_thinking: crate::core::reasoning::ReasoningLevel,
1366 pub api_retries: u32, pub refusal_retries: u32, pub telemetry: String, pub telemetry_log_generations: u32,
1373 pub cache_diagnostics: bool, pub cache_ttl: CacheTtlSetting,
1377 pub cache_keepwarm: CacheKeepWarm,
1381 pub cache_keepwarm_auto_max_hours: u32,
1386 pub cache_keepwarm_headless: bool,
1391 pub cache_keepwarm_tools: bool,
1399 pub request_compression: RequestCompressionMode,
1402 pub request_compression_level: u32,
1406 pub client_input: ClientInput,
1411 pub thinking_display: ThinkingDisplay,
1414 pub max_fps: u32,
1418 pub scroll_lines: Option<u16>,
1422 pub theme: Option<String>,
1423 pub theme_transition: ThemeTransitionMode,
1426 pub tui_background_opaque: bool,
1428 pub tui_streaming_glow: bool,
1430 pub tui_smooth_streaming: bool,
1433 pub process_scopes: bool,
1439 pub tool_scope_slice: Option<String>,
1448 pub daemon_scope_slice: Option<String>,
1453 pub agent_name: Option<String>,
1454 pub identity: Option<String>,
1455 pub disabled_plugins: Vec<String>,
1456 pub favorite_models: Vec<String>,
1457 pub disabled_skills: Vec<String>,
1458 pub progressive_tool_disclosure: bool,
1463 pub tools_activation_confirm: ActivationConfirm,
1469 pub tools_deferred_loading: bool,
1475 pub tools_always_loaded: Vec<crate::orchestration::capability::ToolId>,
1482 pub session_persistence: crate::core::session_journal::SessionPersistence,
1487 pub disabled_tools: Vec<String>,
1490 pub server: ServerConfig,
1491 pub auth: AuthConfig,
1492 pub events: EventsConfig,
1493 pub provider_keys: BTreeMap<String, String>,
1494 pub keybinds: std::collections::HashMap<String, String>,
1495 pub turn_budgets: TurnBudgetsConfig,
1497 pub warnings: Vec<String>,
1500}
1501
1502impl Default for SynapsConfig {
1503 fn default() -> Self {
1504 Self {
1505 model: None,
1506 thinking_budget: None,
1507 thinking_level: None,
1508 context_window: None,
1509 context_management: ContextManagementConfig::default(),
1510 startup: StartupConfig::default(),
1511 daemon: DaemonConfig::default(),
1512 subagent: SubagentLimits::default(),
1513 compaction_model: None,
1514 compaction_mode: crate::core::compaction::CompactionMode::default(),
1515 compaction_exclude: Vec::new(),
1516 max_tool_output: 30000,
1517 tool_output_spill_bytes: DEFAULT_TOOL_OUTPUT_SPILL_BYTES,
1518 tool_output_spill_head_bytes: DEFAULT_TOOL_OUTPUT_SPILL_HEAD_BYTES,
1519 tool_output_spill_tail_bytes: DEFAULT_TOOL_OUTPUT_SPILL_TAIL_BYTES,
1520 read_dedupe: true,
1521 bash_timeout: 30,
1522 bash_max_timeout: 300,
1523 subagent_timeout: 300,
1524 subagent_cold_resume_compact_tokens: DEFAULT_SUBAGENT_COLD_RESUME_COMPACT_TOKENS,
1525 subagent_context_management: ContextManagementMode::Auto,
1526 subagent_rollover_tokens: DEFAULT_SUBAGENT_ROLLOVER_TOKENS,
1527 subagent_transform_hooks: true,
1528 subagent_thinking: crate::core::reasoning::ReasoningLevel::XHigh,
1529 api_retries: 3,
1530 refusal_retries: 2,
1531 telemetry: "off".to_string(),
1532 telemetry_log_generations: 1,
1533 cache_diagnostics: false,
1534 cache_ttl: CacheTtlSetting::default(),
1535 cache_keepwarm: CacheKeepWarm::Off,
1536 cache_keepwarm_auto_max_hours: DEFAULT_CACHE_KEEPWARM_AUTO_MAX_HOURS,
1537 cache_keepwarm_headless: false,
1538 cache_keepwarm_tools: true,
1539 request_compression: RequestCompressionMode::Auto,
1540 request_compression_level: DEFAULT_REQUEST_COMPRESSION_LEVEL,
1541 client_input: ClientInput::Shared,
1542 thinking_display: ThinkingDisplay::Summarized,
1543 max_fps: 60,
1544 scroll_lines: None,
1545 theme: None,
1546 theme_transition: ThemeTransitionMode::default(),
1547 tui_background_opaque: true,
1548 tui_streaming_glow: false,
1549 tui_smooth_streaming: true,
1550 process_scopes: true,
1551 tool_scope_slice: None,
1552 daemon_scope_slice: None,
1553 agent_name: None,
1554 identity: None,
1555 disabled_plugins: Vec::new(),
1556 favorite_models: Vec::new(),
1557 disabled_skills: Vec::new(),
1558 progressive_tool_disclosure: false,
1559 tools_activation_confirm: ActivationConfirm::default(),
1560 tools_deferred_loading: true,
1561 tools_always_loaded: Vec::new(),
1562 session_persistence: crate::core::session_journal::SessionPersistence::default(),
1563 disabled_tools: Vec::new(),
1564 server: ServerConfig::default(),
1565 auth: AuthConfig::default(),
1566 events: EventsConfig::default(),
1567 provider_keys: BTreeMap::new(),
1568 keybinds: std::collections::HashMap::new(),
1569 turn_budgets: TurnBudgetsConfig::default(),
1570 warnings: Vec::new(),
1571 }
1572 }
1573}
1574
1575pub fn scope_slice_problem(name: &str) -> Option<&'static str> {
1585 if name.len() > 255 {
1586 return Some("longer than 255 bytes");
1587 }
1588 if name.starts_with(['"', '\'']) || name.ends_with(['"', '\'']) {
1589 return Some("write the name without quotes (the config file is not TOML)");
1590 }
1591 if !name.ends_with(".slice") {
1592 return Some("expected a slice unit name ending in .slice");
1593 }
1594 if !name.bytes().all(|b| b.is_ascii_alphanumeric() || matches!(b, b':' | b'_' | b'.' | b'-')) {
1595 return Some("only letters, digits, ':', '_', '.' and '-' are allowed");
1596 }
1597 if name == "-.slice" {
1598 return Some("the root slice -.slice is not allowed");
1599 }
1600 if name.starts_with('-') {
1601 return Some("must not start with '-'");
1602 }
1603 let stem = &name[..name.len() - ".slice".len()];
1604 if stem.is_empty() {
1605 return Some("needs a name before .slice");
1606 }
1607 if stem.contains("--") || stem.ends_with('-') {
1608 return Some("'-' separates parent slices: no '--' and no trailing '-'");
1609 }
1610 None
1611}
1612
1613pub const MAX_TELEMETRY_LOG_GENERATIONS: u32 = 100;
1616
1617pub const KNOWN_CONFIG_KEYS: &[&str] = &[
1621 "model",
1622 "thinking",
1623 "compaction_model",
1624 "compaction_mode",
1625 "compaction_exclude",
1626 "context_window",
1627 "context_management.mode",
1628 "context_management.pressure_tokens",
1629 "context_management.rollover_tokens",
1630 "context_management.reserve_tokens",
1631 "context_management.finish_rounds",
1632 "context_management.target_tokens",
1633 "startup.quick_start",
1634 "startup.extensions_ready_timeout_secs",
1635 "daemon.idle_exit_secs",
1636 "daemon.prompt_abandon_secs",
1637 "daemon.parked_evict_secs",
1638 "max_tool_output",
1639 "tool_output_spill_bytes",
1640 "tool_output_spill_head_bytes",
1641 "tool_output_spill_tail_bytes",
1642 "read_dedupe",
1643 "bash_timeout",
1644 "bash_max_timeout",
1645 "subagent_timeout",
1646 "subagent_cold_resume_compact_tokens",
1647 "subagent_context_management",
1648 "subagent_rollover_tokens",
1649 "subagent_transform_hooks",
1650 "subagent_thinking",
1651 "subagent.max_concurrent",
1652 "subagent.max_total",
1653 "subagent.max_depth",
1654 "subagent_max_concurrent",
1656 "subagent_max_total",
1657 "api_retries",
1658 "refusal_retries",
1659 "telemetry",
1660 "telemetry_log_generations",
1661 "cache_diagnostics",
1662 "cache_ttl",
1663 "cache_keepwarm",
1664 "cache_keepwarm_auto_max_hours",
1665 "cache_keepwarm_headless",
1666 "cache_keepwarm_tools",
1667 "request_compression",
1668 "request_compression_level",
1669 "client_input",
1670 "thinking_display",
1671 "max_fps",
1672 "scroll_lines",
1673 "theme",
1674 "theme_transition",
1675 "tui_background_opaque",
1676 "tui_streaming_glow",
1677 "tui_smooth_streaming",
1678 "process_scopes",
1679 "tool_scope_slice",
1680 "daemon_scope_slice",
1681 "agent_name",
1682 "identity",
1683 "disabled_plugins",
1684 "favorite_models",
1685 "disabled_skills",
1686 "disabled_tools",
1687 "progressive_tool_disclosure",
1688 "tools.activation_confirm",
1689 "tools.deferred_loading",
1690 "tools.always_loaded",
1691 "session_persistence",
1692];
1693
1694pub const FAMILY_CONFIG_KEYS: &[&str] = &[
1701 "events.auto_turn",
1703 "events.auto_turn_cap",
1704 "turn_budget.foreground.max_provider_rounds",
1706 "turn_budget.foreground.max_tool_calls",
1707 "turn_budget.foreground.max_elapsed_secs",
1708 "turn_budget.foreground.max_accumulated_tool_result_bytes",
1709 "turn_budget.foreground.max_context_tokens",
1710 "turn_budget.foreground.max_cost_usd",
1711 "turn_budget.foreground.max_round_renewals",
1712 "turn_budget.event.max_provider_rounds",
1713 "turn_budget.event.max_tool_calls",
1714 "turn_budget.event.max_elapsed_secs",
1715 "turn_budget.event.max_accumulated_tool_result_bytes",
1716 "turn_budget.event.max_context_tokens",
1717 "turn_budget.event.max_cost_usd",
1718 "turn_budget.event.max_round_renewals",
1719 "turn_budget.worker.max_provider_rounds",
1720 "turn_budget.worker.max_tool_calls",
1721 "turn_budget.worker.max_elapsed_secs",
1722 "turn_budget.worker.max_accumulated_tool_result_bytes",
1723 "turn_budget.worker.max_context_tokens",
1724 "turn_budget.worker.max_cost_usd",
1725 "turn_budget.worker.max_round_renewals",
1726 "server.auto_approve_confirms",
1728 "auth.remote_endpoint",
1730 "auth.machine_token",
1731 "provider.*",
1733 "keybind.*",
1734];
1735
1736fn levenshtein(a: &str, b: &str) -> usize {
1738 let a: Vec<char> = a.chars().collect();
1739 let b: Vec<char> = b.chars().collect();
1740 let mut prev: Vec<usize> = (0..=b.len()).collect();
1741 let mut cur = vec![0; b.len() + 1];
1742 for (i, ca) in a.iter().enumerate() {
1743 cur[0] = i + 1;
1744 for (j, cb) in b.iter().enumerate() {
1745 let cost = if ca == cb { 0 } else { 1 };
1746 cur[j + 1] = (prev[j + 1] + 1).min(cur[j] + 1).min(prev[j] + cost);
1747 }
1748 std::mem::swap(&mut prev, &mut cur);
1749 }
1750 prev[b.len()]
1751}
1752
1753fn did_you_mean(key: &str) -> Option<&'static str> {
1755 KNOWN_CONFIG_KEYS
1756 .iter()
1757 .map(|k| (*k, levenshtein(key, k)))
1758 .filter(|(_, d)| *d <= 2)
1759 .min_by_key(|(_, d)| *d)
1760 .map(|(k, _)| k)
1761}
1762
1763fn parse_comma_list(val: &str) -> Vec<String> {
1766 val.split(',')
1767 .map(|s| s.trim().to_string())
1768 .filter(|s| !s.is_empty())
1769 .collect()
1770}
1771
1772fn parse_tool_id_list(val: &str) -> (Vec<crate::orchestration::capability::ToolId>, Vec<String>) {
1776 let mut ids = Vec::new();
1777 let mut bad = Vec::new();
1778 for raw in parse_comma_list(val) {
1779 match crate::orchestration::capability::ToolId::parse(&raw) {
1780 Ok(id) if !ids.contains(&id) => ids.push(id),
1781 Ok(_) => {}
1782 Err(_) => bad.push(raw),
1783 }
1784 }
1785 (ids, bad)
1786}
1787
1788fn write_comma_list_locked(key: &str, values: &[String]) -> std::io::Result<()> {
1789 write_config_value_locked(key, &values.join(", "))
1790}
1791
1792pub const REMOVED_SHELL_KEYS: &[&str] = &[
1796 "shell.max_sessions",
1797 "shell.idle_timeout",
1798 "shell.readiness_timeout_ms",
1799 "shell.max_readiness_timeout_ms",
1800 "shell.default_rows",
1801 "shell.default_cols",
1802 "shell.readiness_strategy",
1803 "shell.max_output",
1804];
1805
1806fn removed_shell_keys_warning(set: &[&str]) -> String {
1809 format!(
1810 "{} no longer {} anything (removed in {}; {} never read): delete {}",
1811 set.join(", "),
1812 if set.len() == 1 { "does" } else { "do" },
1813 crate::REMOVED_IN,
1814 if set.len() == 1 { "it was" } else { "they were" },
1815 if set.len() == 1 { "this line" } else { "these lines" },
1816 )
1817}
1818
1819#[must_use]
1823fn parse_server_config_key(
1824 server_config: &mut ServerConfig,
1825 key: &str,
1826 val: &str,
1827) -> Option<String> {
1828 if REMOVED_SERVER_KEYS.contains(&key) {
1829 return Some(removed_server_warning());
1830 }
1831 if key == "server.auto_approve_confirms" {
1832 server_config.auto_approve_confirms = matches!(val, "true" | "1" | "yes");
1833 }
1834 None
1836}
1837
1838fn unknown_auth_key_warning(key: &str) -> Option<String> {
1841 const EXACT: [&str; 2] = ["auth.remote_endpoint", "auth.machine_token"];
1842 const FAMILIES: [&str; 2] = ["auth.account", "auth.auto"];
1843 const PLACEHOLDERS: [&str; 2] = ["<provider>", "<key>"];
1844 if EXACT.contains(&key) {
1845 return None;
1846 }
1847 for (family, placeholder) in FAMILIES.iter().zip(PLACEHOLDERS) {
1848 match key.strip_prefix(family) {
1849 Some("") | Some(".") => {
1851 return Some(format!(
1852 "unknown key '{key}' (did you mean '{family}.{placeholder}'?)"
1853 ))
1854 }
1855 Some(rest) if rest.starts_with('.') && !rest[1..].trim().is_empty() => return None,
1856 _ => {}
1857 }
1858 }
1859 let (head, rest) = match key.match_indices('.').nth(1) {
1861 Some((i, _)) => key.split_at(i),
1862 None => (key, ""),
1863 };
1864 let suggestion = EXACT
1865 .iter()
1866 .map(|k| (levenshtein(key, k), k.to_string()))
1867 .chain(
1868 FAMILIES
1869 .iter()
1870 .filter(|_| !rest.is_empty())
1871 .map(|f| (levenshtein(head, f), format!("{f}{rest}"))),
1872 )
1873 .filter(|(d, _)| *d <= 2)
1874 .min_by_key(|(d, _)| *d)
1875 .map(|(_, k)| k);
1876 Some(match suggestion {
1877 Some(s) => format!("unknown key '{key}' (did you mean '{s}'?)"),
1878 None => format!("unknown key '{key}' — ignored"),
1879 })
1880}
1881
1882fn parse_auth_config_key(auth_config: &mut AuthConfig, key: &str, val: &str) {
1884 let v = val.trim();
1885 match key {
1886 "auth.remote_endpoint" => {
1887 auth_config.remote_endpoint = if v.is_empty() {
1888 None
1889 } else {
1890 Some(v.to_string())
1891 };
1892 }
1893 "auth.machine_token" => {
1894 auth_config.machine_token = if v.is_empty() {
1895 None
1896 } else {
1897 Some(v.to_string())
1898 };
1899 }
1900 _ => {
1901 if let Some(provider) = key.strip_prefix("auth.account.") {
1902 let provider = provider.trim();
1903 if !provider.is_empty() {
1904 auth_config
1906 .accounts
1907 .insert(provider.to_string(), v.to_string());
1908 }
1909 }
1910 if let Some(key) = key.strip_prefix("auth.auto.").filter(|k| !k.trim().is_empty()) {
1911 auth_config.auto.insert(key.to_string(), v.to_string());
1912 }
1913 }
1915 }
1916}
1917
1918pub fn load_config_from_str(content: &str) -> SynapsConfig {
1921 let mut config = SynapsConfig::default();
1922 apply_config_content(&mut config, content);
1923 config
1924}
1925
1926pub fn config_content_problems(content: &str) -> Vec<String> {
1931 let mut config = SynapsConfig::default();
1932 let mut problems = Vec::new();
1933 apply_config_content_with(&mut config, content, &mut |diagnostic: String| problems.push(diagnostic));
1934 problems.extend(config.warnings);
1935 problems
1936}
1937
1938pub fn validate_config_edit(key: &str, value: &str) -> Result<(), String> {
1947 if key.contains(['=', '\n', '\r'])
1948 || value.contains(['\n', '\r'])
1949 {
1950 return Err("value must be a single line".to_string());
1951 }
1952 let target = get_active_config_dir().join("config");
1955 let existing = std::fs::read_to_string(&target).unwrap_or_default();
1956 let before = config_content_problems(&existing);
1957 let after = config_content_problems(&replace_config_line(&existing, key, value));
1958 let introduced: Vec<String> = after.into_iter().filter(|p| !before.contains(p)).collect();
1959 if introduced.is_empty() {
1960 Ok(())
1961 } else {
1962 Err(introduced.join("; "))
1963 }
1964}
1965
1966pub fn load_config() -> SynapsConfig {
1969 let path = resolve_read_path("config");
1970 let mut config = SynapsConfig::default();
1971
1972 let Ok(content) = std::fs::read_to_string(&path) else {
1973 return config;
1974 };
1975
1976 apply_config_content(&mut config, &content);
1977
1978 let _ = PROVIDER_KEYS.set(config.provider_keys.clone());
1981
1982 let identity_val = config
1984 .identity
1985 .clone()
1986 .unwrap_or_else(|| DEFAULT_IDENTITY.to_string());
1987 let _ = IDENTITY.set(identity_val);
1988
1989 config
1990}
1991
1992fn apply_config_content(config: &mut SynapsConfig, content: &str) {
1995 apply_config_content_with(config, content, &mut |diagnostic: String| eprintln!("{diagnostic}"));
1998}
1999
2000fn apply_config_content_with(
2004 config: &mut SynapsConfig,
2005 content: &str,
2006 family_diagnostic: &mut dyn FnMut(String),
2007) {
2008 let mut invalid_context_management = false;
2009 let (mut removed_axel_backend, mut removed_other_backend) = (false, false);
2011 let mut removed_sidecar_toggle_key = false;
2012 let mut removed_shell_keys = [false; REMOVED_SHELL_KEYS.len()];
2013 for line in content.lines() {
2014 let line = line.trim();
2015 if line.is_empty() || line.starts_with('#') {
2016 continue;
2017 }
2018 let Some((key, val)) = line.split_once('=') else {
2019 continue;
2020 };
2021 let key = key.trim();
2022 let val = val.trim();
2023 match key {
2024 "model" => config.model = Some(val.to_string()),
2025 "thinking" => {
2026 use crate::core::reasoning::ThinkingSpec;
2027 match ThinkingSpec::parse(val) {
2028 Some(ThinkingSpec::Named(level)) => {
2029 config.thinking_level = Some(level);
2030 config.thinking_budget = level.to_legacy_budget();
2031 }
2032 Some(ThinkingSpec::Budget(budget)) => {
2033 config.thinking_level = None;
2036 config.thinking_budget = Some(budget);
2037 }
2038 None => {
2039 config.warnings.push(format!("thinking = {val} — expected off|adaptive|low|medium|high|xhigh|max|ultra|ultracode or a token count; thinking disabled"));
2040 }
2041 }
2042 }
2043 "compaction_model" => config.compaction_model = Some(val.to_string()),
2044 "compaction_mode" => match val {
2045 "remote" => {
2046 config.compaction_mode = crate::core::compaction::CompactionMode::Remote
2047 }
2048 "local" | "local_only" | "local-only" => {
2049 config.compaction_mode = crate::core::compaction::CompactionMode::LocalOnly
2050 }
2051 _ => {
2052 config.warnings.push(format!(
2053 "compaction_mode = {val} — expected remote or local; ignored"
2054 ));
2055 }
2056 },
2057 "compaction_exclude" => {
2058 let mut classes = Vec::new();
2059 for part in val.split(',').map(str::trim).filter(|p| !p.is_empty()) {
2060 match crate::core::compaction::ContentClass::parse(part) {
2061 Some(class) => {
2062 if !classes.contains(&class) {
2063 classes.push(class);
2064 }
2065 }
2066 None => config.warnings.push(format!(
2067 "compaction_exclude — unknown content class '{part}'; \
2068 expected one of: user_text, assistant_text, thinking, \
2069 tool_calls, tool_results, file_paths, event_data"
2070 )),
2071 }
2072 }
2073 config.compaction_exclude = classes;
2074 }
2075 "context_window" if val.eq_ignore_ascii_case("auto") => config.context_window = None,
2078 "context_window" => {
2079 let parsed = match val {
2080 "200k" | "200K" => Some(200_000),
2081 "1m" | "1M" => Some(1_000_000),
2082 _ => val.parse::<u64>().ok(),
2083 };
2084 if parsed.is_none() {
2085 config.warnings.push(format!(
2086 "context_window = {val} — expected 200k, 1m, auto, or a token count; ignored"
2087 ));
2088 }
2089 config.context_window = parsed;
2090 }
2091 "max_tool_output" => match val.parse::<usize>() {
2092 Ok(size) => config.max_tool_output = size,
2093 Err(_) => config.warnings.push(format!(
2094 "max_tool_output = {val} — not a number; using {}",
2095 config.max_tool_output
2096 )),
2097 },
2098 "tool_output_spill_bytes" => match val.parse::<usize>() {
2099 Ok(bytes) => config.tool_output_spill_bytes = bytes,
2100 Err(_) => config.warnings.push(format!(
2101 "tool_output_spill_bytes = {val} — expected a byte count (0 turns virtualisation off); using {DEFAULT_TOOL_OUTPUT_SPILL_BYTES}"
2102 )),
2103 },
2104 "tool_output_spill_head_bytes" => match val.parse::<usize>() {
2105 Ok(bytes) => config.tool_output_spill_head_bytes = bytes,
2106 Err(_) => config.warnings.push(format!(
2107 "tool_output_spill_head_bytes = {val} — expected a byte count; using {DEFAULT_TOOL_OUTPUT_SPILL_HEAD_BYTES}"
2108 )),
2109 },
2110 "tool_output_spill_tail_bytes" => match val.parse::<usize>() {
2111 Ok(bytes) => config.tool_output_spill_tail_bytes = bytes,
2112 Err(_) => config.warnings.push(format!(
2113 "tool_output_spill_tail_bytes = {val} — expected a byte count; using {DEFAULT_TOOL_OUTPUT_SPILL_TAIL_BYTES}"
2114 )),
2115 },
2116 "bash_timeout" => match val.parse::<u64>() {
2117 Ok(t) if t >= 1 => config.bash_timeout = t,
2118 Ok(_) => config.warnings.push(format!(
2119 "bash_timeout = {val} — below minimum (1s); using {}",
2120 config.bash_timeout
2121 )),
2122 Err(_) => config.warnings.push(format!(
2123 "bash_timeout = {val} — not a number; using {}",
2124 config.bash_timeout
2125 )),
2126 },
2127 "bash_max_timeout" => {
2128 if let Ok(timeout) = val.parse::<u64>() {
2129 config.bash_max_timeout = timeout;
2130 }
2131 }
2132 "subagent_thinking" => {
2133 match crate::core::reasoning::parse_subagent_thinking(val) {
2134 Some(level) => config.subagent_thinking = level,
2135 None => config.warnings.push(format!(
2136 "subagent_thinking = {val} — expected low|medium|high|xhigh|max; keeping {}",
2137 config.subagent_thinking
2138 )),
2139 }
2140 }
2141 "subagent_timeout" => {
2142 if let Ok(timeout) = val.parse::<u64>() {
2143 config.subagent_timeout = timeout;
2144 }
2145 }
2146 "subagent_cold_resume_compact_tokens" => match val.parse::<u64>() {
2147 Ok(tokens) => config.subagent_cold_resume_compact_tokens = tokens,
2148 Err(_) => config.warnings.push(format!(
2149 "subagent_cold_resume_compact_tokens = {val} — expected a token count (0 disables); using {DEFAULT_SUBAGENT_COLD_RESUME_COMPACT_TOKENS}"
2150 )),
2151 },
2152 "subagent_context_management" => match ContextManagementMode::parse(val) {
2153 Some(mode) => config.subagent_context_management = mode,
2154 None => config.warnings.push(format!(
2155 "subagent_context_management = {val} — expected auto or off; using auto"
2156 )),
2157 },
2158 "subagent_rollover_tokens" => match val.parse::<u64>() {
2159 Ok(tokens) if tokens >= MIN_SUBAGENT_ROLLOVER_TOKENS => {
2160 config.subagent_rollover_tokens = tokens
2161 }
2162 _ => config.warnings.push(format!(
2163 "subagent_rollover_tokens = {val} — expected a token count of at least {MIN_SUBAGENT_ROLLOVER_TOKENS}; using {DEFAULT_SUBAGENT_ROLLOVER_TOKENS}"
2164 )),
2165 },
2166 "subagent_transform_hooks" => match val {
2167 "true" | "1" | "on" | "yes" => config.subagent_transform_hooks = true,
2168 "false" | "0" | "off" | "no" => config.subagent_transform_hooks = false,
2169 _ => config.warnings.push(format!(
2170 "subagent_transform_hooks = {val} — expected on or off; using on"
2171 )),
2172 },
2173 "subagent_max_concurrent" | "subagent_max_total" => {
2175 let dotted = key.replacen("subagent_", "subagent.", 1);
2176 if let Err(reason) =
2177 parse_subagent_config_key(&mut config.subagent, &dotted, val)
2178 {
2179 config.warnings.push(format!("{key} — {reason}"));
2180 }
2181 }
2182 "api_retries" => {
2183 if let Ok(retries) = val.parse::<u32>() {
2184 config.api_retries = retries;
2185 }
2186 }
2187 "refusal_retries" => {
2188 if let Ok(retries) = val.parse::<u32>() {
2189 config.refusal_retries = retries;
2190 }
2191 }
2192 "telemetry" => config.telemetry = val.to_string(),
2193 "telemetry_log_generations" => match val.parse::<u32>() {
2194 Ok(n) if (1..=MAX_TELEMETRY_LOG_GENERATIONS).contains(&n) => {
2195 config.telemetry_log_generations = n;
2196 }
2197 _ => config.warnings.push(format!(
2198 "telemetry_log_generations = {val} — expected 1–{MAX_TELEMETRY_LOG_GENERATIONS}; line ignored"
2199 )),
2200 },
2201 "cache_diagnostics" => {
2202 config.cache_diagnostics = matches!(val, "true" | "1" | "on" | "yes");
2203 }
2204 "cache_ttl" => match CacheTtlSetting::parse(val) {
2205 Some(ttl) => config.cache_ttl = ttl,
2206 None => config.warnings.push(format!(
2207 "cache_ttl = {val} — expected auto, 5m, 1h, or hybrid; using auto"
2208 )),
2209 },
2210 "cache_keepwarm" => match CacheKeepWarm::parse(val) {
2211 Some(mode) => config.cache_keepwarm = mode,
2212 None => config.warnings.push(format!(
2213 "cache_keepwarm = {val} — expected off, auto, or 1h–{MAX_CACHE_KEEPWARM_HOURS}h; keep-warm stays off"
2214 )),
2215 },
2216 "thinking_display" => match ThinkingDisplay::parse(val) {
2217 Some(display) => config.thinking_display = display,
2218 None => config.warnings.push(format!(
2219 "thinking_display = {val} — expected summarized or omitted; using summarized"
2220 )),
2221 },
2222 "client_input" => match ClientInput::parse(val) {
2223 Some(mode) => config.client_input = mode,
2224 None => config.warnings.push(format!(
2225 "client_input = {val} — expected shared or exclusive; using shared"
2226 )),
2227 },
2228 "cache_keepwarm_auto_max_hours" => {
2229 match val.trim().trim_end_matches(['h', 'H']).parse::<u32>() {
2230 Ok(h) if (1..=MAX_CACHE_KEEPWARM_HOURS).contains(&h) => {
2231 config.cache_keepwarm_auto_max_hours = h
2232 }
2233 _ => config.warnings.push(format!(
2234 "cache_keepwarm_auto_max_hours = {val} — expected 1–{MAX_CACHE_KEEPWARM_HOURS}; using {DEFAULT_CACHE_KEEPWARM_AUTO_MAX_HOURS}"
2235 )),
2236 }
2237 }
2238 "cache_keepwarm_headless" => match val.trim().to_ascii_lowercase().as_str() {
2239 "true" | "1" | "on" | "yes" => config.cache_keepwarm_headless = true,
2240 "false" | "0" | "off" | "no" => config.cache_keepwarm_headless = false,
2241 _ => config.warnings.push(format!(
2242 "cache_keepwarm_headless = {val} — expected true or false; keeping false"
2243 )),
2244 },
2245 "cache_keepwarm_tools" => match val.trim().to_ascii_lowercase().as_str() {
2246 "true" | "1" | "on" | "yes" => config.cache_keepwarm_tools = true,
2247 "false" | "0" | "off" | "no" => config.cache_keepwarm_tools = false,
2248 _ => config.warnings.push(format!(
2249 "cache_keepwarm_tools = {val} — expected on or off; keeping on"
2250 )),
2251 },
2252 "request_compression" => match RequestCompressionMode::parse(val) {
2253 Some(mode) => config.request_compression = mode,
2254 None => config.warnings.push(format!(
2255 "request_compression = {val} — expected auto, on, or off; using auto"
2256 )),
2257 },
2258 "request_compression_level" => match val.trim().parse::<u32>() {
2259 Ok(n) if (1..=MAX_REQUEST_COMPRESSION_LEVEL).contains(&n) => {
2260 config.request_compression_level = n
2261 }
2262 _ => config.warnings.push(format!(
2263 "request_compression_level = {val} — expected 1–{MAX_REQUEST_COMPRESSION_LEVEL}; using {DEFAULT_REQUEST_COMPRESSION_LEVEL}"
2264 )),
2265 },
2266 "max_fps" => match val.parse::<u32>() {
2267 Ok(fps) if (1..=1000).contains(&fps) => config.max_fps = fps,
2268 Ok(_) => config.warnings.push(format!(
2269 "max_fps = {val} — expected 1–1000; using {}",
2270 config.max_fps
2271 )),
2272 Err(_) => config.warnings.push(format!(
2273 "max_fps = {val} — not a number; using {}",
2274 config.max_fps
2275 )),
2276 },
2277 "scroll_lines" => match val.parse::<u16>() {
2278 Ok(n) if (1..=20).contains(&n) => config.scroll_lines = Some(n),
2279 Ok(_) => config
2280 .warnings
2281 .push(format!("scroll_lines = {val} — expected 1–20; ignoring")),
2282 Err(_) => config
2283 .warnings
2284 .push(format!("scroll_lines = {val} — not a number; ignoring")),
2285 },
2286 "theme" => config.theme = Some(val.to_string()),
2287 "theme_transition" => match ThemeTransitionMode::parse(val) {
2288 Some(mode) => config.theme_transition = mode,
2289 None => config.warnings.push(format!(
2290 "theme_transition = {val} — expected on, off, or milliseconds (0–2000); using on"
2291 )),
2292 },
2293 "tui_background_opaque" => match val {
2294 "true" | "1" | "on" | "yes" | "opaque" => config.tui_background_opaque = true,
2295 "false" | "0" | "off" | "no" | "invisible" => config.tui_background_opaque = false,
2296 _ => config.warnings.push(format!(
2297 "tui_background_opaque = {val} — expected opaque or invisible; using {}",
2298 if config.tui_background_opaque {
2299 "opaque"
2300 } else {
2301 "invisible"
2302 }
2303 )),
2304 },
2305 "read_dedupe" => match val {
2306 "true" | "1" | "on" | "yes" => config.read_dedupe = true,
2307 "false" | "0" | "off" | "no" => config.read_dedupe = false,
2308 _ => config.warnings.push(format!(
2309 "read_dedupe = {val} — expected on or off; using on"
2310 )),
2311 },
2312 "tui_streaming_glow" => match val {
2313 "true" | "1" | "on" | "yes" => config.tui_streaming_glow = true,
2314 "false" | "0" | "off" | "no" => config.tui_streaming_glow = false,
2315 _ => config.warnings.push(format!(
2316 "tui_streaming_glow = {val} — expected on or off; using off"
2317 )),
2318 },
2319 "tui_smooth_streaming" => match val {
2320 "true" | "1" | "on" | "yes" => config.tui_smooth_streaming = true,
2321 "false" | "0" | "off" | "no" => config.tui_smooth_streaming = false,
2322 _ => config.warnings.push(format!(
2323 "tui_smooth_streaming = {val} — expected on or off; using on"
2324 )),
2325 },
2326 "process_scopes" => match val {
2327 "true" | "1" | "on" | "yes" | "auto" => config.process_scopes = true,
2328 "false" | "0" | "off" | "no" => config.process_scopes = false,
2329 _ => config.warnings.push(format!(
2330 "process_scopes = {val} — expected auto or off; keeping the previous value (default auto)"
2331 )),
2332 },
2333 "tool_scope_slice" | "daemon_scope_slice" => {
2334 let field = if key == "tool_scope_slice" {
2335 &mut config.tool_scope_slice
2336 } else {
2337 &mut config.daemon_scope_slice
2338 };
2339 if val.is_empty() {
2342 *field = None;
2343 } else if let Some(why) = scope_slice_problem(val) {
2344 config.warnings.push(format!(
2345 "{key} = {val} — {why}; keeping the previous value (default: no slice, systemd's default placement)"
2346 ));
2347 } else {
2348 *field = Some(val.to_string());
2349 }
2350 }
2351 "sidecar_toggle_key" => removed_sidecar_toggle_key = true,
2354 "agent_name" => config.agent_name = Some(val.to_string()),
2355 "identity" => config.identity = Some(val.to_string()),
2356 "disabled_plugins" => {
2357 config.disabled_plugins = parse_comma_list(val);
2358 }
2359 "favorite_models" => {
2360 config.favorite_models = parse_comma_list(val);
2361 }
2362 "disabled_skills" => {
2363 config.disabled_skills = parse_comma_list(val);
2364 }
2365 "progressive_tool_disclosure" => {
2366 config.progressive_tool_disclosure = matches!(val, "true" | "1" | "on" | "yes");
2367 }
2368 "tools.deferred_loading" => match val {
2369 "on" | "true" | "1" | "yes" => config.tools_deferred_loading = true,
2370 "off" | "false" | "0" | "no" => config.tools_deferred_loading = false,
2371 _ => config.warnings.push(format!(
2372 "tools.deferred_loading = {val} — expected on or off; keeping the default (on)"
2373 )),
2374 },
2375 "tools.activation_confirm" => match ActivationConfirm::parse(val) {
2376 Some(mode) => config.tools_activation_confirm = mode,
2377 None => config.warnings.push(format!(
2378 "tools.activation_confirm = {val} — expected auto, prompt or deny; \
2379 keeping the default (auto)"
2380 )),
2381 },
2382 "tools.always_loaded" => {
2383 let (ids, bad) = parse_tool_id_list(val);
2384 config.tools_always_loaded = ids;
2385 if !bad.is_empty() {
2386 config.warnings.push(format!(
2387 "tools.always_loaded: ignoring malformed tool id(s) {} — expected \
2388 namespace:name, e.g. ext.axel:search or builtin:subagent",
2389 bad.join(", ")
2390 ));
2391 }
2392 }
2393 "session_persistence" => {
2394 match crate::core::session_journal::SessionPersistence::parse(val) {
2395 Some(mode) => config.session_persistence = mode,
2396 None => config.warnings.push(format!(
2397 "session_persistence = {val} — expected json or journal; \
2398 keeping the default (json)"
2399 )),
2400 }
2401 }
2402 "disabled_tools" => {
2403 config.disabled_tools = parse_comma_list(val);
2404 }
2405 "memory.backend" if val != "axel" => removed_other_backend = true,
2409 "memory.backend" | "memory.user_scope" | "memory.axel.executable"
2410 | "memory.axel.brain" => removed_axel_backend = true,
2411 key if REMOVED_SHELL_KEYS.contains(&key) => {
2413 if let Some(i) = REMOVED_SHELL_KEYS.iter().position(|k| *k == key) {
2414 removed_shell_keys[i] = true;
2415 }
2416 }
2417 _ => {
2418 if key.starts_with("server.") {
2420 if let Some(warning) = parse_server_config_key(&mut config.server, key, val) {
2421 if !config.warnings.contains(&warning) {
2422 config.warnings.push(warning);
2423 }
2424 }
2425 } else if REMOVED_BRIDGE_KEYS.contains(&key) {
2426 let warning = removed_bridge_warning();
2427 if !config.warnings.contains(&warning) {
2428 config.warnings.push(warning);
2429 }
2430 } else if key.starts_with("auth.") {
2431 parse_auth_config_key(&mut config.auth, key, val);
2432 if let Some(key) = key.strip_prefix("auth.auto.").filter(|k| !k.trim().is_empty()) {
2433 let (_, warnings) = crate::auth::AutoSelectionPolicy::from_config_map(
2434 &BTreeMap::from([(key.to_string(), val.trim().to_string())]));
2435 config.warnings.extend(warnings);
2436 }
2437 if let Some(provider) =
2438 key.strip_prefix("auth.account.").filter(|p| !p.trim().is_empty())
2439 {
2440 let mut one = BTreeMap::new();
2441 one.insert(provider.trim().to_string(), val.trim().to_string());
2442 let (_, warnings) =
2443 crate::core::auth::AccountPolicy::from_config_map(&one);
2444 config.warnings.extend(warnings);
2445 }
2446 if let Some(warning) = unknown_auth_key_warning(key) {
2447 config.warnings.push(warning);
2448 }
2449 } else if key.starts_with("events.") {
2450 if let Err(diagnostic) = parse_events_config_key(&mut config.events, key, val)
2451 {
2452 family_diagnostic(diagnostic);
2453 }
2454 } else if key.starts_with("turn_budget.") {
2455 match parse_turn_budget_config_key(&mut config.turn_budgets, key, val) {
2456 Ok(None) => {}
2457 Ok(Some(warning)) => {
2458 if !config.warnings.contains(&warning) {
2459 config.warnings.push(warning);
2460 }
2461 }
2462 Err(diagnostic) => family_diagnostic(diagnostic),
2463 }
2464 } else if REMOVED_AUTOMATIC_MEMORY_KEYS.contains(&key) {
2465 let warning = removed_automatic_memory_warning();
2467 if !config.warnings.contains(&warning) {
2468 config.warnings.push(warning);
2469 }
2470 } else if let Some(field) = key.strip_prefix("memory.") {
2471 family_diagnostic(format!("Warning: unknown memory field '{field}'"));
2472 } else if key.starts_with("startup.") {
2473 if let Err(reason) =
2474 parse_startup_config_key(&mut config.startup, key, val)
2475 {
2476 config.warnings.push(format!("{key} — {reason}"));
2477 }
2478 } else if key.starts_with("daemon.") {
2479 if let Err(reason) =
2480 parse_daemon_config_key(&mut config.daemon, key, val)
2481 {
2482 config.warnings.push(format!("{key} — {reason}"));
2483 }
2484 } else if key.starts_with("subagent.") {
2485 if let Err(reason) =
2486 parse_subagent_config_key(&mut config.subagent, key, val)
2487 {
2488 config.warnings.push(format!("{key} — {reason}"));
2489 }
2490 } else if key.starts_with("context_management.") {
2491 if let Err(reason) = parse_context_management_config_key(
2492 &mut config.context_management, key, val,
2493 ) {
2494 invalid_context_management = true;
2495 config.warnings.push(format!("{key} — {reason}"));
2496 }
2497 } else if let Some(provider_key) = key.strip_prefix("provider.") {
2498 config
2499 .provider_keys
2500 .insert(provider_key.to_string(), val.to_string());
2501 } else if let Some(keybind_key) = key.strip_prefix("keybind.") {
2502 config
2503 .keybinds
2504 .insert(keybind_key.to_string(), val.to_string());
2505 } else if key.contains('.') {
2506 } else {
2510 match did_you_mean(key) {
2512 Some(suggestion) => config.warnings.push(format!(
2513 "unknown key '{key}' (did you mean '{suggestion}'?)"
2514 )),
2515 None => config
2516 .warnings
2517 .push(format!("unknown key '{key}' — ignored")),
2518 }
2519 }
2520 }
2521 }
2522 }
2523
2524 let context_validation = match config.context_window {
2527 Some(window) => config.context_management.validate_for_window(window),
2528 None => config.context_management.validate(),
2529 };
2530 if let Err(reason) = context_validation {
2531 invalid_context_management = true;
2532 config
2533 .warnings
2534 .push(format!("context_management — {reason}"));
2535 }
2536 if config.subagent.max_concurrent > config.subagent.max_total {
2540 config.warnings.push(format!(
2541 "subagent.max_concurrent ({}) is above subagent.max_total ({}); using {} for both",
2542 config.subagent.max_concurrent, config.subagent.max_total, config.subagent.max_total
2543 ));
2544 config.subagent.max_concurrent = config.subagent.max_total;
2545 }
2546 if invalid_context_management {
2547 config.context_management = ContextManagementConfig::default();
2548 config.warnings.push(
2549 "invalid context_management configuration — using defaults (mode off)".to_string(),
2550 );
2551 }
2552
2553 if removed_axel_backend {
2557 config.warnings.push(removed_axel_backend_warning());
2558 } else if removed_other_backend {
2559 config.warnings.push(removed_memory_backend_warning());
2560 }
2561 if removed_sidecar_toggle_key {
2562 config.warnings.push(removed_sidecar_toggle_key_warning());
2563 }
2564 let set_shell_keys: Vec<&str> = REMOVED_SHELL_KEYS
2565 .iter()
2566 .zip(removed_shell_keys)
2567 .filter_map(|(k, set)| set.then_some(*k))
2568 .collect();
2569 if !set_shell_keys.is_empty() {
2570 config.warnings.push(removed_shell_keys_warning(&set_shell_keys));
2571 }
2572}
2573
2574pub fn read_config_value(key: &str) -> Option<String> {
2576 let path = resolve_read_path("config");
2577 let content = std::fs::read_to_string(&path).ok()?;
2578 for line in content.lines() {
2579 let line = line.trim();
2580 if line.is_empty() || line.starts_with('#') {
2581 continue;
2582 }
2583 let Some((k, v)) = line.split_once('=') else {
2584 continue;
2585 };
2586 if k.trim() == key.trim() {
2587 return Some(v.trim().to_string());
2588 }
2589 }
2590 None
2591}
2592
2593struct ConfigLock {
2605 _file: Option<std::fs::File>,
2606}
2607
2608impl ConfigLock {
2609 fn acquire() -> std::io::Result<Self> {
2610 if std::env::var("SYNAPS_CONFIG_LOCK").as_deref() == Ok("0") {
2611 return Ok(Self { _file: None });
2612 }
2613 use fs4::fs_std::FileExt;
2614 let lock_path = resolve_write_path("config").with_extension("lock");
2615 if let Some(parent) = lock_path.parent() {
2616 std::fs::create_dir_all(parent)?;
2617 }
2618 let mut open = std::fs::OpenOptions::new();
2619 open.create(true).write(true).truncate(false);
2620 #[cfg(unix)]
2621 {
2622 use std::os::unix::fs::OpenOptionsExt;
2623 open.mode(0o600);
2624 }
2625 let file = open.open(&lock_path)?;
2626 FileExt::lock_exclusive(&file)?;
2627 Ok(Self { _file: Some(file) })
2628 }
2629}
2630
2631pub fn write_config_value(key: &str, value: &str) -> std::io::Result<()> {
2635 let _guard = ConfigLock::acquire()?;
2636 write_config_value_locked(key, value)
2637}
2638
2639fn write_config_value_locked(key: &str, value: &str) -> std::io::Result<()> {
2641 let path = resolve_write_path("config");
2642 let existing = std::fs::read_to_string(&path).unwrap_or_default();
2643 let out = replace_config_line(&existing, key, value);
2644
2645 let tmp = path.with_extension("tmp");
2646 std::fs::write(&tmp, out)?;
2647 #[cfg(unix)]
2649 {
2650 use std::os::unix::fs::PermissionsExt;
2651 std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o600))?;
2652 }
2653 std::fs::rename(&tmp, &path)?;
2654 Ok(())
2655}
2656
2657fn replace_config_line(existing: &str, key: &str, value: &str) -> String {
2661 let key_trimmed = key.trim();
2662 let replacement = format!("{} = {}", key_trimmed, value);
2663
2664 let mut found = false;
2665 let mut new_lines: Vec<String> = existing
2666 .lines()
2667 .map(|line| {
2668 if found {
2669 return line.to_string();
2670 }
2671 let t = line.trim_start();
2672 if t.starts_with('#') || t.is_empty() {
2673 return line.to_string();
2674 }
2675 if let Some((k, _)) = t.split_once('=') {
2676 if k.trim() == key_trimmed {
2677 found = true;
2678 return replacement.clone();
2679 }
2680 }
2681 line.to_string()
2682 })
2683 .collect();
2684
2685 if !found {
2686 new_lines.push(replacement);
2687 }
2688
2689 let mut out = new_lines.join("\n");
2690 if !out.ends_with('\n') {
2691 out.push('\n');
2692 }
2693 out
2694}
2695
2696pub fn add_favorite_model(id: &str) -> std::io::Result<()> {
2698 let trimmed = id.trim();
2699 if trimmed.is_empty() {
2700 return Ok(());
2701 }
2702 let _guard = ConfigLock::acquire()?;
2703 let mut values = load_config().favorite_models;
2704 if !values.iter().any(|v| v == trimmed) {
2705 values.push(trimmed.to_string());
2706 values.sort();
2707 }
2708 write_comma_list_locked("favorite_models", &values)
2709}
2710
2711pub fn remove_favorite_model(id: &str) -> std::io::Result<()> {
2713 let _guard = ConfigLock::acquire()?;
2714 let mut values = load_config().favorite_models;
2715 values.retain(|v| v != id.trim());
2716 write_comma_list_locked("favorite_models", &values)
2717}
2718
2719pub fn is_favorite_model(id: &str) -> bool {
2721 load_config().favorite_models.iter().any(|v| v == id.trim())
2722}
2723
2724pub fn is_secret_key(key: &str) -> bool {
2728 let upper = key.to_ascii_uppercase();
2729 upper.ends_with("_KEY")
2730 || upper.ends_with("_TOKEN")
2731 || upper.ends_with("_CREDENTIALS")
2732 || upper.ends_with("_API_KEY")
2733 || upper.contains("SECRET")
2734 || upper.contains("PASSWORD")
2735 || upper.contains("PASSWD")
2736 || upper.ends_with("_PAT")
2737 || upper.ends_with("_DSN")
2738}
2739
2740pub fn is_secret_env_value(value: &str) -> bool {
2748 let mut rest = value;
2749 while let Some(i) = rest.find("://") {
2750 let scheme_ok = rest[..i]
2751 .chars()
2752 .next_back()
2753 .is_some_and(|c| c.is_ascii_alphanumeric());
2754 let after = &rest[i + 3..];
2755 let end = after
2758 .find(|c: char| {
2759 matches!(c, '/' | '?' | '#' | ',' | ';' | '"' | '\'') || c.is_whitespace()
2760 })
2761 .unwrap_or(after.len());
2762 if scheme_ok && after[..end].rfind('@').is_some_and(|at| at > 0) {
2763 return true;
2764 }
2765 rest = after;
2766 }
2767 false
2768}
2769
2770pub fn is_secret_env(key: &str, value: &str) -> bool {
2773 is_secret_key(key) || is_secret_env_value(value)
2774}
2775
2776macro_rules! default_prompt_steering {
2780 ($output_filters:literal) => {
2781 concat!(
2782 " Change existing files with targeted edits rather than rewriting them whole. \
2783 A file you just read or wrote is current in your context; re-read it only if it may have changed. \
2784 Keep files, docs and comments as lean as the task allows. \
2785 Keep command output small (quiet flags, ",
2786 $output_filters,
2787 ") and read large outputs selectively."
2788 )
2789 };
2790}
2791
2792pub const DEFAULT_PROMPT_WINDOWS: &str = concat!(
2795 "You are a helpful AI agent running in a terminal on Windows. \
2796 You have access to powershell, bash, read, and write tools. \
2797 Prefer the powershell tool for system commands and filesystem operations — it is native \
2798 to Windows and avoids quoting/path translation issues. \
2799 Use bash only when the task specifically requires WSL or a Unix environment. \
2800 Be concise and direct. Use tools when the user asks you to interact with the filesystem or run commands.",
2801 default_prompt_steering!("`Select-Object -Last N`, `Select-String`")
2802);
2803
2804pub const DEFAULT_PROMPT_UNIX: &str = concat!(
2806 "You are a helpful AI agent running in a terminal. \
2807 You have access to bash, read, and write tools. \
2808 Be concise and direct. Use tools when the user asks you to interact with the filesystem or run commands.",
2809 default_prompt_steering!("`| tail -n`, `grep`, `head`")
2810);
2811
2812#[cfg(windows)]
2814pub const DEFAULT_PROMPT: &str = DEFAULT_PROMPT_WINDOWS;
2815#[cfg(not(windows))]
2817pub const DEFAULT_PROMPT: &str = DEFAULT_PROMPT_UNIX;
2818
2819pub fn resolve_system_prompt(explicit: Option<&str>) -> String {
2822 if let Some(val) = explicit {
2823 let path = std::path::Path::new(val);
2824 if path.exists() && path.is_file() {
2825 return std::fs::read_to_string(path).unwrap_or_else(|_| val.to_string());
2826 }
2827 return val.to_string();
2828 }
2829
2830 let system_path = resolve_read_path("system.md");
2831 if system_path.exists() {
2832 return std::fs::read_to_string(&system_path).unwrap_or_default();
2833 }
2834
2835 DEFAULT_PROMPT.to_string()
2836}
2837
2838#[cfg(test)]
2839mod tests {
2840 #[test]
2841 fn tools_deferred_loading_defaults_on_and_parses() {
2842 assert!(super::load_config_from_str("").tools_deferred_loading);
2843 assert!(
2844 !super::load_config_from_str("tools.deferred_loading = off\n").tools_deferred_loading
2845 );
2846 let bad = super::load_config_from_str("tools.deferred_loading = maybe\n");
2847 assert!(
2848 bad.tools_deferred_loading,
2849 "invalid value keeps the default"
2850 );
2851 assert!(bad
2852 .warnings
2853 .iter()
2854 .any(|w| w.contains("tools.deferred_loading")));
2855 }
2856
2857 #[test]
2858 fn tools_always_loaded_parses_ids_and_warns_on_malformed() {
2859 use crate::orchestration::capability::ToolId;
2860 assert!(super::load_config_from_str("").tools_always_loaded.is_empty());
2861 let good = super::load_config_from_str(
2862 "tools.always_loaded = ext.axel:search ,builtin:subagent,, ext.axel:search \n",
2863 );
2864 assert_eq!(
2865 good.tools_always_loaded,
2866 vec![
2867 ToolId::parse("ext.axel:search").unwrap(),
2868 ToolId::parse("builtin:subagent").unwrap(),
2869 ]
2870 );
2871 assert!(good.warnings.is_empty(), "{:?}", good.warnings);
2872
2873 let bad = super::load_config_from_str(
2874 "tools.always_loaded = ext.axel:search, Axel Search, subagent\n",
2875 );
2876 assert_eq!(
2877 bad.tools_always_loaded,
2878 vec![ToolId::parse("ext.axel:search").unwrap()],
2879 "a malformed id is dropped, the good ones are kept"
2880 );
2881 let warning = bad
2882 .warnings
2883 .iter()
2884 .find(|w| w.contains("tools.always_loaded"))
2885 .expect("malformed ids warn");
2886 assert!(warning.contains("Axel Search") && warning.contains("subagent"), "{warning}");
2887 }
2888
2889 #[test]
2890 fn secret_env_value_detects_credential_urls() {
2891 use super::is_secret_env_value as v;
2892 assert!(v("postgres://app:hunter2@db.internal:5432/prod"));
2894 assert!(v("redis://:pw@cache:6379/0"));
2895 assert!(v("http://user:pass@proxy.corp:3128"));
2896 assert!(v(
2897 "mongodb+srv://u:p@cluster0.example.net/db?retryWrites=true"
2898 ));
2899 assert!(v("https://abc123def456@o1.ingest.sentry.io/42"));
2901 assert!(v("-Dspring.datasource.url=jdbc:postgresql://u:p@h/db"));
2903 assert!(v("redis://h1:6379,redis://u:p@h2:6379"));
2904 assert!(!v("https://api.example.com/v1/items?q=a@b"));
2906 assert!(!v("http://host/path/with@sign"));
2907 assert!(!v("https://@host/empty-userinfo"));
2908 assert!(!v("git@github.com:org/repo.git"));
2909 assert!(!v("mailto:someone@example.com"));
2910 assert!(!v("someone@example.com"));
2911 assert!(!v("/usr/local/bin:/usr/bin"));
2912 assert!(!v(""));
2913 }
2914
2915 #[test]
2916 fn secret_key_covers_pat_and_dsn() {
2917 assert!(super::is_secret_key("GITHUB_PAT"));
2918 assert!(super::is_secret_key("gh_pat"));
2919 assert!(super::is_secret_key("SENTRY_DSN"));
2920 assert!(!super::is_secret_key("PATH"));
2921 assert!(!super::is_secret_key("PATTERN_DIR"));
2922 }
2923
2924 #[test]
2925 fn secret_env_is_key_or_value() {
2926 assert!(super::is_secret_env(
2927 "DATABASE_URL",
2928 "postgres://app:hunter2@db/prod"
2929 ));
2930 assert!(super::is_secret_env("GH_TOKEN", "anything"));
2931 assert!(!super::is_secret_env("DATABASE_URL", "postgres://db/prod"));
2932 assert!(!super::is_secret_env("HOME", "/home/u"));
2933 }
2934 #[test]
2935 fn startup_and_daemon_defaults() {
2936 let c = super::load_config_from_str("");
2937 assert!(c.startup.quick_start, "quick_start defaults ON");
2938 assert_eq!(c.startup.extensions_ready_timeout_secs, 30);
2939 assert_eq!(c.daemon.idle_exit_secs, 10);
2940 assert_eq!(c.daemon.prompt_abandon_secs, 3600);
2941 assert_eq!(c.daemon.parked_evict_secs, 3600);
2942 assert!(c.warnings.is_empty(), "warnings: {:?}", c.warnings);
2944 }
2945
2946 #[test]
2947 fn startup_and_daemon_parse_all_keys() {
2948 let c = super::load_config_from_str(concat!(
2949 "startup.quick_start = off\n",
2950 "startup.extensions_ready_timeout_secs = 45\n",
2951 "daemon.idle_exit_secs = 20\n",
2952 "daemon.prompt_abandon_secs = 0\n",
2953 "daemon.parked_evict_secs = 120\n",
2954 ));
2955 assert!(!c.startup.quick_start);
2956 assert_eq!(c.startup.extensions_ready_timeout_secs, 45);
2957 assert_eq!(c.daemon.idle_exit_secs, 20);
2958 assert_eq!(c.daemon.prompt_abandon_secs, 0);
2959 assert_eq!(c.daemon.parked_evict_secs, 120);
2960 assert!(
2961 !c.warnings.iter().any(|w| w.contains("unknown")),
2962 "no unknown-key warning: {:?}",
2963 c.warnings
2964 );
2965 }
2966
2967 #[test]
2968 fn subagent_limits_default_to_16_64_4() {
2969 let c = super::load_config_from_str("");
2970 assert_eq!(c.subagent, super::SubagentLimits::default());
2971 assert_eq!(c.subagent.max_concurrent, 16);
2972 assert_eq!(c.subagent.max_total, 64);
2973 assert_eq!(c.subagent.max_depth, 4);
2974 assert!(c.warnings.is_empty(), "warnings: {:?}", c.warnings);
2975 }
2976
2977 #[test]
2978 fn subagent_limits_parse_and_are_known_keys() {
2979 let c = super::load_config_from_str(concat!(
2980 "subagent.max_concurrent = 24\n",
2981 "subagent.max_total = 100\n",
2982 "subagent.max_depth = 2\n",
2983 "subagent_timeout = 600\n",
2984 ));
2985 assert_eq!(c.subagent.max_concurrent, 24);
2986 assert_eq!(c.subagent.max_total, 100);
2987 assert_eq!(c.subagent.max_depth, 2);
2988 assert_eq!(c.subagent_timeout, 600, "the old flat key is unaffected");
2989 assert!(c.warnings.is_empty(), "warnings: {:?}", c.warnings);
2990 for k in [
2991 "subagent.max_concurrent",
2992 "subagent.max_total",
2993 "subagent.max_depth",
2994 ] {
2995 assert!(
2996 super::KNOWN_CONFIG_KEYS.contains(&k),
2997 "{k} not in KNOWN_CONFIG_KEYS"
2998 );
2999 }
3000 }
3001
3002 #[test]
3003 fn subagent_cold_resume_compact_tokens_parses_defaults_and_warns() {
3004 let c = super::load_config_from_str("");
3005 assert_eq!(c.subagent_cold_resume_compact_tokens, 100_000);
3006 assert_eq!(
3007 super::SynapsConfig::default().subagent_cold_resume_compact_tokens,
3008 super::DEFAULT_SUBAGENT_COLD_RESUME_COMPACT_TOKENS
3009 );
3010 let c = super::load_config_from_str("subagent_cold_resume_compact_tokens = 250000\n");
3011 assert_eq!(c.subagent_cold_resume_compact_tokens, 250_000);
3012 assert!(c.warnings.is_empty(), "warnings: {:?}", c.warnings);
3013 let c = super::load_config_from_str("subagent_cold_resume_compact_tokens = 0\n");
3014 assert_eq!(c.subagent_cold_resume_compact_tokens, 0, "0 disables");
3015 assert!(c.warnings.is_empty(), "warnings: {:?}", c.warnings);
3016 let c = super::load_config_from_str("subagent_cold_resume_compact_tokens = lots\n");
3017 assert_eq!(c.subagent_cold_resume_compact_tokens, 100_000);
3018 assert!(
3019 c.warnings
3020 .iter()
3021 .any(|w| w.contains("subagent_cold_resume_compact_tokens")),
3022 "warnings: {:?}",
3023 c.warnings
3024 );
3025 assert!(super::KNOWN_CONFIG_KEYS.contains(&"subagent_cold_resume_compact_tokens"));
3026 }
3027
3028 #[test]
3029 fn subagent_transform_hooks_key_parses_defaults_on_and_warns() {
3030 assert!(super::load_config_from_str("").subagent_transform_hooks, "default on");
3031 assert!(super::SynapsConfig::default().subagent_transform_hooks);
3032 for (line, want) in [
3033 ("subagent_transform_hooks = off\n", false),
3034 ("subagent_transform_hooks = false\n", false),
3035 ("subagent_transform_hooks = on\n", true),
3036 ("subagent_transform_hooks = true\n", true),
3037 ] {
3038 let c = super::load_config_from_str(line);
3039 assert_eq!(c.subagent_transform_hooks, want, "{line}");
3040 assert!(c.warnings.is_empty(), "{line}: {:?}", c.warnings);
3041 }
3042 let c = super::load_config_from_str("subagent_transform_hooks = maybe\n");
3043 assert!(c.subagent_transform_hooks, "bad value keeps the default");
3044 assert!(c.warnings.iter().any(|w| w.contains("subagent_transform_hooks")));
3045 }
3046
3047 #[test]
3048 fn subagent_rollover_keys_parse_default_and_warn() {
3049 use super::ContextManagementMode;
3050 let c = super::load_config_from_str("");
3051 assert_eq!(c.subagent_context_management, ContextManagementMode::Auto);
3052 assert_eq!(c.subagent_rollover_tokens, 200_000);
3053 let d = super::SynapsConfig::default();
3054 assert_eq!(d.subagent_context_management, ContextManagementMode::Auto);
3055 assert_eq!(d.subagent_rollover_tokens, super::DEFAULT_SUBAGENT_ROLLOVER_TOKENS);
3056
3057 let c = super::load_config_from_str(
3058 "subagent_context_management = OFF\nsubagent_rollover_tokens = 150000\n",
3059 );
3060 assert_eq!(c.subagent_context_management, ContextManagementMode::Off);
3061 assert_eq!(c.subagent_rollover_tokens, 150_000);
3062 assert!(c.warnings.is_empty(), "warnings: {:?}", c.warnings);
3063 let c = super::load_config_from_str("subagent_rollover_tokens = 20000\n");
3064 assert_eq!(c.subagent_rollover_tokens, 20_000, "the floor itself is accepted");
3065 assert!(c.warnings.is_empty(), "warnings: {:?}", c.warnings);
3066
3067 for (line, key) in [
3068 ("subagent_context_management = sometimes", "subagent_context_management"),
3069 ("subagent_rollover_tokens = lots", "subagent_rollover_tokens"),
3070 ("subagent_rollover_tokens = 19999", "subagent_rollover_tokens"),
3071 ("subagent_rollover_tokens = 0", "subagent_rollover_tokens"),
3072 ] {
3073 let c = super::load_config_from_str(line);
3074 assert_eq!(c.subagent_context_management, ContextManagementMode::Auto, "{line}");
3075 assert_eq!(c.subagent_rollover_tokens, 200_000, "{line}");
3076 assert!(
3077 c.warnings.iter().any(|w| w.contains(key)),
3078 "{line}: warnings {:?}",
3079 c.warnings
3080 );
3081 }
3082 for key in ["subagent_context_management", "subagent_rollover_tokens"] {
3083 assert!(super::KNOWN_CONFIG_KEYS.contains(&key), "{key}");
3084 }
3085 }
3086
3087 #[test]
3088 fn legacy_flat_subagent_limit_keys_alias_the_dotted_keys() {
3089 let c = super::load_config_from_str(concat!(
3090 "subagent_max_concurrent = 256\n",
3091 "subagent_max_total = 1024\n",
3092 ));
3093 assert_eq!(c.subagent.max_concurrent, 256);
3094 assert_eq!(c.subagent.max_total, 1024);
3095 assert!(c.warnings.is_empty(), "warnings: {:?}", c.warnings);
3096 let c = super::load_config_from_str("subagent_max_concurrent = 0\n");
3097 assert_eq!(c.subagent, super::SubagentLimits::default());
3098 assert!(c.warnings.iter().any(|w| w.starts_with("subagent_max_concurrent")));
3099 }
3100
3101 #[test]
3102 fn subagent_limits_reject_zero_and_junk_and_keep_defaults() {
3103 let c = super::load_config_from_str(concat!(
3104 "subagent.max_concurrent = 0\n",
3105 "subagent.max_total = lots\n",
3106 "subagent.max_depth = 70000\n",
3107 "subagent.max_children = 4\n",
3108 ));
3109 assert_eq!(c.subagent, super::SubagentLimits::default());
3110 for key in [
3111 "subagent.max_concurrent",
3112 "subagent.max_total",
3113 "subagent.max_depth",
3114 "subagent.max_children",
3115 ] {
3116 assert!(
3117 c.warnings.iter().any(|w| w.starts_with(key)),
3118 "expected a warning for {key}: {:?}",
3119 c.warnings
3120 );
3121 }
3122 }
3123
3124 #[test]
3125 fn subagent_concurrency_above_total_uses_the_stricter_number() {
3126 for text in [
3128 "subagent.max_total = 10\nsubagent.max_concurrent = 32\n",
3129 "subagent.max_concurrent = 32\nsubagent.max_total = 10\n",
3130 ] {
3131 let c = super::load_config_from_str(text);
3132 assert_eq!(c.subagent.max_concurrent, 10);
3133 assert_eq!(c.subagent.max_total, 10);
3134 assert!(
3135 c.warnings
3136 .iter()
3137 .any(|w| w.contains("subagent.max_concurrent (32)")),
3138 "{:?}",
3139 c.warnings
3140 );
3141 }
3142 let c = super::load_config_from_str("subagent.max_total = 5\n");
3144 assert_eq!(c.subagent.max_concurrent, 5);
3145 }
3146
3147 #[test]
3148 fn process_scopes_defaults_on_and_parses_auto_and_off() {
3149 assert!(super::load_config_from_str("").process_scopes);
3150 assert!(super::load_config_from_str("process_scopes = auto\n").process_scopes);
3151 let off = super::load_config_from_str("process_scopes = off\n");
3152 assert!(!off.process_scopes);
3153 assert!(off.warnings.is_empty(), "{:?}", off.warnings);
3154 let bad = super::load_config_from_str("process_scopes = sometimes\n");
3155 assert!(bad.process_scopes, "an invalid value keeps the default");
3156 assert!(bad.warnings.iter().any(|w| w.starts_with("process_scopes")));
3157 assert!(super::KNOWN_CONFIG_KEYS.contains(&"process_scopes"));
3158 }
3159
3160 #[test]
3161 fn an_invalid_repeated_process_scopes_keeps_the_previous_value() {
3162 let c = super::load_config_from_str("process_scopes = off\nprocess_scopes = sometimes\n");
3163 assert!(
3164 c.warnings.iter().any(|w| w.starts_with("process_scopes = sometimes") && w.contains("keeping the previous value")),
3165 "{:?}",
3166 c.warnings
3167 );
3168 assert!(!c.process_scopes, "the earlier valid value is kept");
3169 }
3170
3171 #[test]
3172 fn an_invalid_repeated_scope_slice_keeps_the_previous_value() {
3173 for key in ["tool_scope_slice", "daemon_scope_slice"] {
3174 let c = super::load_config_from_str(&format!("{key} = a.slice\n{key} = worker\n"));
3175 assert!(
3176 c.warnings.iter().any(|w| w.starts_with(&format!("{key} = worker")) && w.contains("keeping the previous value")),
3177 "{key}: {:?}",
3178 c.warnings
3179 );
3180 let kept = if key == "tool_scope_slice" { &c.tool_scope_slice } else { &c.daemon_scope_slice };
3181 assert_eq!(kept.as_deref(), Some("a.slice"), "{key}: the earlier valid value is kept");
3182 }
3183 }
3184
3185 #[test]
3186 fn scope_slices_default_unset_and_parse_valid_names() {
3187 let c = super::load_config_from_str("");
3188 assert_eq!((c.tool_scope_slice, c.daemon_scope_slice), (None, None));
3189 let c = super::load_config_from_str(
3190 "tool_scope_slice = synaps_tools.slice\ndaemon_scope_slice = synaps:daemon_1.slice\n",
3191 );
3192 assert!(c.warnings.is_empty(), "{:?}", c.warnings);
3193 assert_eq!(c.tool_scope_slice.as_deref(), Some("synaps_tools.slice"));
3194 assert_eq!(c.daemon_scope_slice.as_deref(), Some("synaps:daemon_1.slice"));
3195 let c = super::load_config_from_str("tool_scope_slice = synaps-tools.slice\n");
3197 assert_eq!((c.warnings.len(), c.tool_scope_slice.as_deref()), (0, Some("synaps-tools.slice")));
3198 let c = super::load_config_from_str("tool_scope_slice = a.slice\n");
3200 assert_eq!((c.tool_scope_slice.as_deref(), c.daemon_scope_slice.as_deref()), (Some("a.slice"), None));
3201 let c = super::load_config_from_str("daemon_scope_slice = d.slice\n");
3202 assert_eq!((c.tool_scope_slice.as_deref(), c.daemon_scope_slice.as_deref()), (None, Some("d.slice")));
3203 let longest = format!("{}.slice", "a".repeat(255 - ".slice".len()));
3204 assert_eq!(super::scope_slice_problem(&longest), None);
3205 for k in ["tool_scope_slice", "daemon_scope_slice"] {
3206 assert!(super::KNOWN_CONFIG_KEYS.contains(&k), "{k} not in KNOWN_CONFIG_KEYS");
3207 }
3208 }
3209
3210 #[test]
3211 fn an_invalid_scope_slice_warns_and_keeps_the_default() {
3212 let too_long = format!("{}.slice", "a".repeat(256 - ".slice".len()));
3213 let mut accepted: Vec<String> = Vec::new();
3215 for bad in [
3216 "worker", "worker.service", "a/b.slice", "work er.slice", "work@er.slice", "-.slice", "-worker.slice", "--worker.slice",
3224 ".slice", "worker--agent.slice",
3226 "worker-.slice",
3227 too_long.as_str(), "wörker.slice", "worker\0.slice", "\"worker-agent.slice\"", "'worker-agent.slice'",
3232 "5", "true",
3234 ] {
3235 if super::scope_slice_problem(bad).is_none() {
3236 accepted.push(bad.to_string());
3237 }
3238 for key in ["tool_scope_slice", "daemon_scope_slice"] {
3239 let c = super::load_config_from_str(&format!("{key} = {bad}\n"));
3240 let unset = (c.tool_scope_slice.as_deref(), c.daemon_scope_slice.as_deref()) == (None, None);
3241 if !unset || !c.warnings.iter().any(|w| w.starts_with(&format!("{key} = "))) {
3242 accepted.push(format!("{key} = {bad}"));
3243 }
3244 }
3245 }
3246 assert!(accepted.is_empty(), "accepted: {accepted:?}");
3247 }
3248
3249 #[test]
3250 fn an_empty_scope_slice_unsets_the_key_without_a_warning() {
3251 for key in ["tool_scope_slice", "daemon_scope_slice"] {
3252 for empty in [format!("{key} ="), format!("{key} = "), format!(" {key}=")] {
3253 let c = super::load_config_from_str(&format!("{empty}\n"));
3255 assert!(c.warnings.is_empty(), "{empty:?}: {:?}", c.warnings);
3256 assert_eq!((c.tool_scope_slice.as_deref(), c.daemon_scope_slice.as_deref()), (None, None), "{empty:?}");
3257 let c = super::load_config_from_str(&format!(
3259 "tool_scope_slice = synaps_tools.slice\ndaemon_scope_slice = synaps_daemon.slice\n{empty}\n"
3260 ));
3261 assert!(c.warnings.is_empty(), "{empty:?}: {:?}", c.warnings);
3262 let (unset, kept) = if key == "tool_scope_slice" {
3263 (&c.tool_scope_slice, c.daemon_scope_slice.as_deref())
3264 } else {
3265 (&c.daemon_scope_slice, c.tool_scope_slice.as_deref())
3266 };
3267 assert_eq!(unset, &None, "{empty:?}");
3268 assert!(kept.is_some_and(|k| k.starts_with("synaps_")), "{empty:?}: {kept:?}");
3269 }
3270 }
3271 }
3272
3273 #[test]
3274 fn a_quoted_scope_slice_says_to_drop_the_quotes() {
3275 for quoted in ["\"worker-agent.slice\"", "'worker-agent.slice'", "\"worker-agent.slice", "worker-agent.slice'"] {
3276 let why = super::scope_slice_problem(quoted);
3277 assert!(why.is_some_and(|w| w.contains("without quotes")), "{quoted}: {why:?}");
3278 }
3279 }
3280
3281 #[test]
3282 fn startup_quick_start_accepts_on() {
3283 let c = super::load_config_from_str("startup.quick_start = on\n");
3284 assert!(c.startup.quick_start);
3285 }
3286
3287 #[test]
3288 fn startup_and_daemon_keys_are_known() {
3289 for k in [
3290 "startup.quick_start",
3291 "startup.extensions_ready_timeout_secs",
3292 "daemon.idle_exit_secs",
3293 "daemon.prompt_abandon_secs",
3294 "daemon.parked_evict_secs",
3295 ] {
3296 assert!(super::KNOWN_CONFIG_KEYS.contains(&k), "{k} not in KNOWN_CONFIG_KEYS");
3297 }
3298 }
3299
3300 #[test]
3301 fn startup_daemon_bad_values_warn_and_keep_defaults() {
3302 let c = super::load_config_from_str(concat!(
3303 "startup.quick_start = maybe\n",
3304 "daemon.idle_exit_secs = soon\n",
3305 ));
3306 assert!(c.startup.quick_start, "invalid bool keeps default");
3307 assert_eq!(c.daemon.idle_exit_secs, 10, "invalid u64 keeps default");
3308 assert!(c.warnings.iter().any(|w| w.contains("startup.quick_start")));
3309 assert!(c.warnings.iter().any(|w| w.contains("daemon.idle_exit_secs")));
3310 }
3311
3312 #[test]
3313 fn context_management_defaults_off_and_parses_all_keys() {
3314 use super::{ContextManagementConfig, ContextManagementMode};
3315 assert_eq!(
3316 super::load_config_from_str("").context_management,
3317 ContextManagementConfig::default()
3318 );
3319 let parsed = super::load_config_from_str(
3320 "context_management.mode = AUTO\n\
3321 context_management.pressure_tokens = 250000\n\
3322 context_management.rollover_tokens = 400000\n\
3323 context_management.reserve_tokens = 24000\n\
3324 context_management.finish_rounds = 3\n\
3325 context_window = 1m\n",
3326 );
3327 assert_eq!(
3328 parsed.context_management,
3329 ContextManagementConfig {
3330 mode: ContextManagementMode::Auto,
3331 pressure_tokens: Some(250_000),
3332 rollover_tokens: Some(400_000),
3333 reserve_tokens: 24_000,
3334 finish_rounds: 3,
3335 target_tokens: None,
3336 }
3337 );
3338 assert!(parsed.warnings.is_empty(), "{:?}", parsed.warnings);
3339 assert!(parsed.provider_keys.is_empty());
3340 assert!(super::load_config_from_str("context_management.mode = off")
3341 .warnings
3342 .is_empty());
3343 }
3344
3345 #[test]
3346 fn context_management_auto_clears_overrides_and_allows_bounded_extremes() {
3347 for rounds in [0, super::ContextManagementConfig::MAX_FINISH_ROUNDS] {
3348 let parsed = super::load_config_from_str(&format!(
3349 "context_management.mode = auto\n\
3350 context_management.pressure_tokens = 300000\n\
3351 context_management.rollover_tokens = 450000\n\
3352 context_management.pressure_tokens = auto\n\
3353 context_management.rollover_tokens = AUTO\n\
3354 context_management.finish_rounds = {rounds}\n"
3355 ));
3356 assert_eq!(parsed.context_management.pressure_tokens, None);
3357 assert_eq!(parsed.context_management.rollover_tokens, None);
3358 assert_eq!(parsed.context_management.finish_rounds, rounds);
3359 assert!(parsed.warnings.is_empty());
3360 }
3361 }
3362
3363 #[test]
3364 fn context_management_invalid_values_reset_entire_policy() {
3365 for (key, value) in [
3366 ("mode", "on"),
3367 ("pressure_tokens", "0"),
3368 ("pressure_tokens", "-1"),
3369 ("pressure_tokens", "250k"),
3370 ("pressure_tokens", "18446744073709551616"),
3371 ("rollover_tokens", "1"),
3372 ("rollover_tokens", "nope"),
3373 ("reserve_tokens", "0"),
3374 ("reserve_tokens", "1000001"),
3375 ("reserve_tokens", "auto"),
3376 ("finish_rounds", "9"),
3377 ("finish_rounds", "-1"),
3378 ("finish_rounds", "4294967296"),
3379 ("finish_rounds", ""),
3380 ("mod", "auto"),
3381 ] {
3382 let parsed = super::load_config_from_str(&format!(
3383 "context_management.mode = auto\ncontext_management.{key} = {value}\n"
3384 ));
3385 assert_eq!(
3386 parsed.context_management,
3387 super::ContextManagementConfig::default(),
3388 "{key}={value}"
3389 );
3390 assert!(!parsed.warnings.is_empty(), "{key}={value}");
3391 assert!(parsed.provider_keys.is_empty());
3392 }
3393 let duplicate = super::load_config_from_str(
3394 "context_management.mode = auto\ncontext_management.finish_rounds = bad\ncontext_management.finish_rounds = 2"
3395 );
3396 assert_eq!(
3397 duplicate.context_management,
3398 super::ContextManagementConfig::default()
3399 );
3400 }
3401
3402 #[test]
3403 fn context_management_target_tokens_parses_and_validates() {
3404 assert_eq!(
3405 super::load_config_from_str("")
3406 .context_management
3407 .target_tokens,
3408 None
3409 );
3410 let parsed = super::load_config_from_str(
3411 "context_management.mode = auto\ncontext_management.target_tokens = 120000\ncontext_window = 1m\n",
3412 );
3413 assert!(parsed.warnings.is_empty(), "{:?}", parsed.warnings);
3414 assert_eq!(parsed.context_management.target_tokens, Some(120_000));
3415 let auto = super::load_config_from_str(
3416 "context_management.target_tokens = 5000\ncontext_management.target_tokens = auto\n",
3417 );
3418 assert_eq!(auto.context_management.target_tokens, None);
3419 for fields in [
3420 "target_tokens = 0",
3421 "target_tokens = nope",
3422 "target_tokens = 250000",
3423 "pressure_tokens = 200000\ncontext_management.target_tokens = 200000",
3424 ] {
3425 let parsed = super::load_config_from_str(&format!(
3426 "context_management.mode = auto\ncontext_management.{fields}\ncontext_window = 1m\n"
3427 ));
3428 assert_eq!(
3429 parsed.context_management,
3430 super::ContextManagementConfig::default(),
3431 "{fields}"
3432 );
3433 assert!(!parsed.warnings.is_empty(), "{fields}");
3434 }
3435 }
3436
3437 #[test]
3438 fn context_management_validates_final_threshold_order_and_window() {
3439 for fields in [
3440 "pressure_tokens = 400000\ncontext_management.rollover_tokens = 400000",
3441 "pressure_tokens = 500000\ncontext_management.rollover_tokens = 400000",
3442 "pressure_tokens = 1000000",
3443 "rollover_tokens = 1000001",
3444 ] {
3445 for window_first in [false, true] {
3446 let policy =
3447 format!("context_management.mode = auto\ncontext_management.{fields}\n");
3448 let content = if window_first {
3449 format!("context_window = 1m\n{policy}")
3450 } else {
3451 format!("{policy}context_window = 1m\n")
3452 };
3453 let parsed = super::load_config_from_str(&content);
3454 assert_eq!(
3455 parsed.context_management,
3456 super::ContextManagementConfig::default()
3457 );
3458 assert!(!parsed.warnings.is_empty());
3459 }
3460 }
3461 let parsed = super::load_config_from_str(
3463 "context_management.mode = auto\n\
3464 context_management.rollover_tokens = 100000\n\
3465 context_management.pressure_tokens = 300000\n\
3466 context_management.rollover_tokens = 450000\ncontext_window = 1m",
3467 );
3468 assert!(parsed.warnings.is_empty());
3469 assert_eq!(parsed.context_management.rollover_tokens, Some(450_000));
3470 }
3471
3472 #[test]
3473 fn context_management_parsed_finish_override_bounds_rollover_extension() {
3474 use crate::core::context_policy::{
3475 assess_context, ContextAction, ContextBudget, ContextState, WorkPhase,
3476 };
3477 let config = super::load_config_from_str(
3478 "context_management.mode = auto\n\
3479 context_management.finish_rounds = 1\ncontext_window = 1m",
3480 );
3481 let budget = ContextBudget {
3482 context_window_tokens: 1_000_000,
3483 used_tokens: 400_000,
3484 hard_remaining_tokens: 600_000,
3485 required_next_round_tokens: 8_000,
3486 };
3487 let mut state = ContextState::default();
3488 state.report_phase(WorkPhase::Plan);
3489 let first = assess_context(&config.context_management, &state, budget);
3490 assert_eq!(
3491 first.action,
3492 ContextAction::FinishBounded {
3493 rounds_remaining: 0
3494 }
3495 );
3496 state = first.next_state;
3497 state.report_phase(WorkPhase::WrapUp);
3498 assert_eq!(
3499 assess_context(&config.context_management, &state, budget).action,
3500 ContextAction::Rollover
3501 );
3502 }
3503
3504 #[test]
3505 fn context_management_keys_are_known_for_suggestions() {
3506 for key in [
3507 "mode",
3508 "pressure_tokens",
3509 "rollover_tokens",
3510 "reserve_tokens",
3511 "finish_rounds",
3512 ] {
3513 let full = format!("context_management.{key}");
3514 assert!(super::KNOWN_CONFIG_KEYS.contains(&full.as_str()));
3515 assert_eq!(
3516 super::did_you_mean(&format!("{full}x")),
3517 Some(full.as_str())
3518 );
3519 }
3520 }
3521
3522 #[test]
3523 fn context_management_parsed_policy_rolls_over_before_executing_350k_plan() {
3524 use crate::core::context_policy::{
3525 assess_context, ContextAction, ContextBudget, ContextState, WorkPhase,
3526 };
3527 let config =
3528 super::load_config_from_str("context_management.mode = auto\ncontext_window = 1m");
3529 let budget = ContextBudget {
3530 context_window_tokens: config.context_window.unwrap(),
3531 used_tokens: 350_000,
3532 hard_remaining_tokens: 650_000,
3533 required_next_round_tokens: 8_000,
3534 };
3535 let mut state = ContextState::default();
3536 state.report_phase(WorkPhase::Plan);
3537 let plan = assess_context(&config.context_management, &state, budget);
3538 assert_eq!(plan.action, ContextAction::Advisory);
3539 state = plan.next_state;
3540 state.report_phase(WorkPhase::Execute);
3541 assert_eq!(
3542 assess_context(&config.context_management, &state, budget).action,
3543 ContextAction::Rollover
3544 );
3545 }
3546
3547 #[test]
3548 fn thinking_display_defaults_summarized_and_parses() {
3549 use super::ThinkingDisplay;
3550 assert_eq!(super::load_config_from_str("").thinking_display, ThinkingDisplay::Summarized);
3551 assert_eq!(
3552 super::load_config_from_str("thinking_display = omitted\n").thinking_display,
3553 ThinkingDisplay::Omitted
3554 );
3555 assert_eq!(
3556 super::load_config_from_str("thinking_display = Summarized\n").thinking_display,
3557 ThinkingDisplay::Summarized
3558 );
3559 let invalid = super::load_config_from_str("thinking_display = raw\n");
3560 assert_eq!(invalid.thinking_display, ThinkingDisplay::Summarized, "invalid keeps the default");
3561 assert!(invalid.warnings.iter().any(|w| w.contains("thinking_display")));
3562 assert_eq!(ThinkingDisplay::Omitted.as_str(), "omitted");
3563 }
3564
3565 #[test]
3566 fn tui_smooth_streaming_defaults_on_and_parses() {
3567 assert!(super::load_config_from_str("").tui_smooth_streaming);
3568 assert!(!super::load_config_from_str("tui_smooth_streaming = off\n").tui_smooth_streaming);
3569 assert!(super::load_config_from_str("tui_smooth_streaming = on\n").tui_smooth_streaming);
3570 let invalid = super::load_config_from_str("tui_smooth_streaming = jerky\n");
3571 assert!(invalid.tui_smooth_streaming, "invalid keeps the default (on)");
3572 assert!(invalid
3573 .warnings
3574 .iter()
3575 .any(|w| w.contains("tui_smooth_streaming")));
3576 }
3577
3578 #[test]
3579 fn tui_streaming_glow_defaults_off_and_parses() {
3580 assert!(!super::load_config_from_str("").tui_streaming_glow);
3581 assert!(super::load_config_from_str("tui_streaming_glow = on\n").tui_streaming_glow);
3582 assert!(!super::load_config_from_str("tui_streaming_glow = off\n").tui_streaming_glow);
3583 let invalid = super::load_config_from_str("tui_streaming_glow = sparkly\n");
3584 assert!(
3585 !invalid.tui_streaming_glow,
3586 "invalid keeps the default (off)"
3587 );
3588 assert!(invalid
3589 .warnings
3590 .iter()
3591 .any(|w| w.contains("tui_streaming_glow")));
3592 }
3593
3594 #[test]
3595 fn tui_background_opaque_defaults_and_parses_supported_values() {
3596 assert!(super::load_config_from_str("").tui_background_opaque);
3597 assert!(
3598 super::load_config_from_str("tui_background_opaque = opaque\n").tui_background_opaque
3599 );
3600 assert!(
3601 !super::load_config_from_str("tui_background_opaque = invisible\n")
3602 .tui_background_opaque
3603 );
3604 let invalid = super::load_config_from_str("tui_background_opaque = translucent\n");
3605 assert!(invalid.tui_background_opaque);
3606 assert!(invalid
3607 .warnings
3608 .iter()
3609 .any(|w| w.contains("tui_background_opaque")));
3610 }
3611
3612 #[test]
3613 fn turn_budget_keys_parse_typed_per_role() {
3614 let config = super::load_config_from_str(
3615 "turn_budget.worker.max_provider_rounds = 3\n\
3616 turn_budget.worker.max_cost_usd = 0.25\n\
3617 turn_budget.foreground.max_tool_calls = 7\n\
3618 turn_budget.foreground.max_round_renewals = 12\n\
3619 turn_budget.worker.max_provider_rounds_bogus = 1\n\
3620 turn_budget.nosuchrole.max_tool_calls = 1\n",
3621 );
3622 assert_eq!(
3623 config.turn_budgets.worker.max_provider_rounds,
3624 Some(super::TurnLimit::Max(3))
3625 );
3626 assert_eq!(config.turn_budgets.worker.max_cost_usd, Some(0.25));
3627 assert_eq!(
3628 config.turn_budgets.foreground.max_tool_calls,
3629 Some(super::TurnLimit::Max(7))
3630 );
3631 assert_eq!(config.turn_budgets.foreground.max_round_renewals, Some(12));
3632 assert_eq!(config.turn_budgets.worker.max_tool_calls, None);
3634 let bad = super::load_config_from_str("turn_budget.worker.max_provider_rounds = nope\n");
3636 assert_eq!(bad.turn_budgets.worker.max_provider_rounds, None);
3637 }
3638
3639 #[test]
3640 fn removed_autonomous_turn_budget_role_still_loads_with_one_warning() {
3641 let content = "turn_budget.autonomous.max_tool_calls = 5\n\
3642 turn_budget.autonomous.max_elapsed_secs = 90\n\
3643 turn_budget.worker.max_tool_calls = 7\n";
3644 let config = super::load_config_from_str(content);
3645 assert_eq!(config.warnings, [super::removed_autonomous_budget_warning()]);
3646 assert_eq!(
3647 config.turn_budgets.worker.max_tool_calls,
3648 Some(super::TurnLimit::Max(7))
3649 );
3650 assert_eq!(
3651 super::config_content_problems(content),
3652 [super::removed_autonomous_budget_warning()]
3653 );
3654 }
3655
3656 #[test]
3657 fn turn_budget_count_limits_accept_unlimited() {
3658 let config = super::load_config_from_str(
3659 "turn_budget.event.max_provider_rounds = unlimited\n\
3660 turn_budget.event.max_tool_calls = \"unlimited\"\n\
3661 turn_budget.worker.max_accumulated_tool_result_bytes = Unlimited\n",
3662 );
3663 assert!(config.warnings.is_empty(), "{:?}", config.warnings);
3664 let unlimited = Some(super::TurnLimit::Unlimited);
3665 assert_eq!(config.turn_budgets.event.max_provider_rounds, unlimited);
3666 assert_eq!(config.turn_budgets.event.max_tool_calls, unlimited);
3667 assert_eq!(
3668 config.turn_budgets.worker.max_accumulated_tool_result_bytes,
3669 Some(super::TurnLimit::Unlimited)
3670 );
3671 let bad = super::load_config_from_str("turn_budget.worker.max_tool_calls = lots\n");
3672 assert_eq!(bad.turn_budgets.worker.max_tool_calls, None);
3673 }
3674
3675 #[test]
3676 fn turn_budget_zero_count_limits_keep_none_allowed_with_a_deprecation_warning() {
3677 for role in ["foreground", "event", "worker"] {
3681 for field in [
3682 "max_provider_rounds",
3683 "max_tool_calls",
3684 "max_accumulated_tool_result_bytes",
3685 ] {
3686 let key = format!("turn_budget.{role}.{field}");
3687 let content = format!("{key} = 0\n");
3688 let config = super::load_config_from_str(&content);
3689 let overrides = match role {
3690 "foreground" => &config.turn_budgets.foreground,
3691 "event" => &config.turn_budgets.event,
3692 _ => &config.turn_budgets.worker,
3693 };
3694 let parsed = match field {
3695 "max_provider_rounds" => overrides.max_provider_rounds.map(|l| l.map(|v| v as usize)),
3696 "max_tool_calls" => overrides.max_tool_calls.map(|l| l.map(|v| v as usize)),
3697 _ => overrides.max_accumulated_tool_result_bytes,
3698 };
3699 assert_eq!(parsed, Some(super::TurnLimit::Max(0)), "{key}");
3700 assert!(
3701 config.warnings.iter().any(|w| w.contains(&key)
3702 && w.contains("none allowed")
3703 && w.contains(
3704 "`0` will be rejected in a future release; use `unlimited` for no limit, or a positive number"
3705 )),
3706 "{key}: {:?}",
3707 config.warnings
3708 );
3709 assert!(!super::config_content_problems(&content).is_empty());
3710 }
3711 }
3712 }
3713
3714 #[test]
3719 fn removed_automatic_memory_keys_still_load_with_one_warning() {
3720 let expected = format!(
3721 "memory.default_mode, memory.recall_* and memory.capture_* (and \
3722 memory.auto_consolidate, memory.local_embeddings, memory.gliner) are ignored: \
3723 built-in automatic memory was removed in {}; an extension provides capture and \
3724 recall through hooks. Remove these lines",
3725 crate::AXEL_BACKEND_REMOVED_IN
3726 );
3727 assert_eq!(super::removed_automatic_memory_warning(), expected);
3728 let valid = "memory.default_mode = capture_and_recall\n\
3729 memory.default_mode_confirmed = true\n\
3730 memory.recall_max_records = 12\n\
3731 memory.recall_max_tokens = 2048\n\
3732 memory.recall_timeout_ms = 250\n\
3733 memory.capture_tools = off\n\
3734 memory.capture_assistant = false\n\
3735 memory.capture_user = false\n\
3736 memory.auto_consolidate = on\n\
3737 memory.local_embeddings = on\n\
3738 memory.gliner = on\n\
3739 model = claude-sonnet-4-6\n";
3740 let garbage = "memory.default_mode = recall_everything_forever\n\
3741 memory.default_mode_confirmed = definitely\n\
3742 memory.recall_max_records = lots\n\
3743 memory.recall_max_tokens = -5\n\
3744 memory.recall_timeout_ms = fast\n\
3745 memory.capture_tools = full\n\
3746 memory.capture_assistant = maybe\n\
3747 memory.capture_user = sometimes\n\
3748 memory.auto_consolidate = aggressive\n\
3749 memory.local_embeddings = auto\n\
3750 memory.gliner = download\n\
3751 model = claude-sonnet-4-6\n";
3752 assert_eq!(valid.lines().filter(|l| l.contains("memory.")).count(), 11);
3753 let plain = super::load_config_from_str("model = claude-sonnet-4-6\n");
3754 for content in [valid, garbage] {
3755 let config = super::load_config_from_str(content);
3756 assert_eq!(config.warnings, std::slice::from_ref(&expected), "{:?}", config.warnings);
3757 assert_eq!(config.model.as_deref(), Some("claude-sonnet-4-6"));
3759 assert_eq!(config.context_management, plain.context_management);
3760 assert_eq!(super::config_content_problems(content), std::slice::from_ref(&expected));
3762 }
3763 for key in super::REMOVED_AUTOMATIC_MEMORY_KEYS {
3765 let config = super::load_config_from_str(&format!("{key} = on\n"));
3766 assert_eq!(config.warnings, std::slice::from_ref(&expected), "{key}");
3767 }
3768 let unconfirmed = super::load_config_from_str("memory.default_mode = recall_each_prompt\n");
3771 assert_eq!(unconfirmed.warnings, std::slice::from_ref(&expected));
3772 assert_eq!(
3775 super::config_content_problems("memory.nosuchfield = 1\n"),
3776 ["Warning: unknown memory field 'nosuchfield'".to_string()]
3777 );
3778 }
3779
3780 fn axel_backend_warning() -> String {
3782 let v = crate::AXEL_BACKEND_REMOVED_IN;
3783 let expected = format!(
3784 "The Axel memory backend (memory.backend = axel, memory.user_scope, \
3785 memory.axel.*) is no longer supported: since {v} Synaps keeps context archives and \
3786 notes in plain files under its base directory, always, and Axel is an extension. \
3787 This session uses plain files. Archives and notes saved to the Axel brain are not \
3788 read any more: move them once with \"axel synaps-export\" (Axel 0.1.0) and \
3789 \"synaps memory import-archives\" / \"import-notes\", install the Axel extension \
3790 for recall and the forum, then remove the memory.backend, memory.user_scope and \
3791 memory.axel.* lines. See \
3792 https://github.com/HaseebKhalid1507/SynapsCLI/blob/dev/docs/upgrading/{v}.md"
3793 );
3794 assert_eq!(super::removed_axel_backend_warning(), expected);
3795 expected
3796 }
3797
3798 fn other_backend_warning() -> String {
3800 let expected = format!(
3801 "memory.backend is ignored: plain files are the only memory storage since {}. \
3802 Remove this line",
3803 crate::AXEL_BACKEND_REMOVED_IN
3804 );
3805 assert_eq!(super::removed_memory_backend_warning(), expected);
3806 expected
3807 }
3808
3809 #[test]
3815 fn a_config_with_the_axel_backend_keys_loads_with_one_warning() {
3816 let expected = axel_backend_warning();
3817 for content in [
3818 "memory.backend = axel\n",
3819 "memory.backend = axel\nmemory.backend = axel\nmemory.backend = axel\n",
3820 "memory.backend = axel\nmemory.axel.brain = relative/brain.r8\n",
3821 "memory.backend = axel\nmemory.axel.brain = ~/brain\n",
3822 "memory.backend = axel\nmemory.axel.executable = /nonexistent/synaps-axel-memory-service\n",
3823 "memory.backend = axel\nmemory.axel.executable = \nmemory.axel.brain = \n",
3824 "memory.backend = axel\nmemory.user_scope = true\n",
3825 "memory.user_scope = yes\nmemory.backend = axel\n",
3826 "memory.backend = axel\nmemory.user_scope = true\n\
3827 memory.axel.executable = /opt/axel/service\nmemory.axel.brain = /opt/axel/brain.r8\n",
3828 "memory.user_scope = false\n",
3830 "memory.user_scope = \n",
3831 "memory.axel.executable = /opt/axel/service\n",
3832 "memory.axel.brain = relative\n",
3833 "memory.axel.brain = /a\nmemory.axel.brain = /b\nmemory.axel.brain = /c\n",
3834 "memory.backend = legacy\nmemory.axel.brain = /opt/axel/brain.r8\n",
3836 "memory.backend = axel\nmemory.backend = legacy\n",
3837 "memory.backend = sqlite\nmemory.backend = axel\n",
3838 ] {
3839 let content = format!("{content}model = claude-sonnet-4-6\n");
3840 let config = super::load_config_from_str(&content);
3841 assert_eq!(config.warnings, std::slice::from_ref(&expected), "{content}");
3842 assert_eq!(super::config_content_problems(&content), std::slice::from_ref(&expected));
3844 assert_eq!(config.model.as_deref(), Some("claude-sonnet-4-6"), "{content}");
3845 }
3846 }
3847
3848 #[test]
3852 fn a_config_with_another_memory_backend_loads_with_the_short_warning() {
3853 let expected = other_backend_warning();
3854 for content in [
3855 "memory.backend = legacy\n",
3856 "memory.backend = sqlite\n",
3857 "memory.backend = typo\n",
3858 "memory.backend = unavailable\n",
3859 "memory.backend = \n",
3860 "memory.backend = AXEL\n",
3861 "memory.backend = legacy\nmemory.backend = sqlite\nmemory.backend = legacy\n",
3862 ] {
3863 let config = super::load_config_from_str(content);
3864 assert_eq!(config.warnings, std::slice::from_ref(&expected), "{content}");
3865 assert_eq!(super::config_content_problems(content), std::slice::from_ref(&expected));
3866 }
3867 assert!(super::load_config_from_str("model = claude-sonnet-4-6\n").warnings.is_empty());
3869 }
3870
3871 #[test]
3875 fn removed_memory_keys_do_not_touch_context_management_or_each_other() {
3876 let content = "memory.backend = axel\n\
3877 memory.user_scope = true\n\
3878 memory.axel.executable = /nonexistent/service\n\
3879 memory.axel.brain = relative/brain.r8\n\
3880 memory.default_mode = capture_and_recall\n\
3881 memory.gliner = on\n\
3882 context_management.mode = auto\n";
3883 let config = super::load_config_from_str(content);
3884 assert_eq!(
3885 config.warnings,
3886 [super::removed_automatic_memory_warning(), axel_backend_warning()],
3887 );
3888 assert_eq!(config.context_management.mode, ContextManagementMode::Auto);
3889 let plain = super::load_config_from_str("context_management.mode = auto\n");
3890 assert!(plain.warnings.is_empty(), "{:?}", plain.warnings);
3891 assert_eq!(config.context_management, plain.context_management);
3892
3893 let legacy = super::load_config_from_str(
3894 "context_management.mode = auto\nmemory.backend = legacy\n",
3895 );
3896 assert_eq!(legacy.warnings, [other_backend_warning()]);
3897 assert_eq!(legacy.context_management, plain.context_management);
3898 }
3899
3900 use super::*;
3901 use serial_test::serial;
3902
3903 #[test]
3904 fn test_levenshtein_basics() {
3905 assert_eq!(levenshtein("model", "model"), 0);
3906 assert_eq!(levenshtein("modle", "model"), 2);
3907 assert_eq!(levenshtein("them", "theme"), 1);
3908 }
3909
3910 #[test]
3911 fn test_did_you_mean_close_typos() {
3912 assert_eq!(did_you_mean("modle"), Some("model"));
3913 assert_eq!(did_you_mean("them"), Some("theme"));
3914 assert_eq!(did_you_mean("thinkng"), Some("thinking"));
3915 assert_eq!(did_you_mean("completely_unrelated_key"), None);
3916 }
3917
3918 #[test]
3919 #[serial]
3920 fn test_config_warnings_unknown_key_and_bad_values() {
3921 let home = std::env::temp_dir().join(format!("synaps-warn-test-{}", std::process::id()));
3922 let dir = home.join(".synaps-cli");
3923 std::fs::create_dir_all(&dir).unwrap();
3924 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();
3925
3926 with_home(&home, || {
3927 let config = load_config();
3928 assert_eq!(config.warnings.len(), 3, "warnings: {:?}", config.warnings);
3930 assert!(
3931 !config.warnings.iter().any(|w| w.contains("knowledge")),
3932 "{:?}",
3933 config.warnings
3934 );
3935 assert!(
3936 config
3937 .warnings
3938 .iter()
3939 .any(|w| w.contains("did you mean 'model'")),
3940 "{:?}",
3941 config.warnings
3942 );
3943 assert!(
3944 config.warnings.iter().any(|w| w.contains("thinking")),
3945 "{:?}",
3946 config.warnings
3947 );
3948 assert!(
3949 config.warnings.iter().any(|w| w.contains("below minimum")),
3950 "{:?}",
3951 config.warnings
3952 );
3953 assert_eq!(config.bash_timeout, 30);
3955 assert_eq!(config.thinking_budget, None);
3956 });
3957 let _ = std::fs::remove_dir_all(&home);
3958 }
3959
3960 #[test]
3961 fn test_client_input_is_shared_by_default_and_parses() {
3962 assert_eq!(SynapsConfig::default().client_input, ClientInput::Shared);
3963 assert_eq!(load_config_from_str("").client_input, ClientInput::Shared);
3964 assert_eq!(load_config_from_str("client_input = exclusive\n").client_input, ClientInput::Exclusive);
3965 assert_eq!(load_config_from_str("client_input = Shared\n").client_input, ClientInput::Shared);
3966 let c = load_config_from_str("client_input = mine\n");
3967 assert_eq!(c.client_input, ClientInput::Shared);
3968 assert!(c.warnings.iter().any(|w| w.contains("client_input")), "{:?}", c.warnings);
3969 assert_eq!(ClientInput::wire_default(), ClientInput::Exclusive, "an older daemon enforces the owner");
3970 }
3971
3972 #[test]
3975 fn test_cache_keepwarm_is_off_by_default() {
3976 assert_eq!(SynapsConfig::default().cache_keepwarm, CacheKeepWarm::Off);
3977 assert_eq!(load_config_from_str("").cache_keepwarm, CacheKeepWarm::Off);
3978 }
3979
3980 #[test]
3981 fn test_cache_keepwarm_parse_table() {
3982 for (raw, want) in [
3983 ("off", Some(CacheKeepWarm::Off)),
3984 ("OFF", Some(CacheKeepWarm::Off)),
3985 ("0h", Some(CacheKeepWarm::Off)),
3986 ("auto", Some(CacheKeepWarm::Auto)),
3987 (" Auto ", Some(CacheKeepWarm::Auto)),
3988 ("1h", Some(CacheKeepWarm::Hours(1))),
3989 ("12H", Some(CacheKeepWarm::Hours(12))),
3990 ("48h", Some(CacheKeepWarm::Hours(MAX_CACHE_KEEPWARM_HOURS))),
3991 ("49h", None),
3992 ("12", None),
3993 ("h", None),
3994 ("-1h", None),
3995 ("lots", None),
3996 ] {
3997 assert_eq!(CacheKeepWarm::parse(raw), want, "{raw:?}");
3998 }
3999 for mode in [CacheKeepWarm::Off, CacheKeepWarm::Auto, CacheKeepWarm::Hours(6)] {
4000 assert_eq!(CacheKeepWarm::parse(&mode.to_string()), Some(mode));
4001 }
4002 }
4003
4004 #[test]
4005 fn test_telemetry_log_generations_config_key() {
4006 let c = load_config_from_str("telemetry = basic\n");
4007 assert_eq!(c.telemetry_log_generations, 1, "default: one generation");
4008
4009 let c = load_config_from_str("telemetry_log_generations = 7\n");
4010 assert_eq!(c.telemetry_log_generations, 7);
4011 assert!(c.warnings.is_empty(), "warnings: {:?}", c.warnings);
4012
4013 for bad in ["0", "101", "-1", "lots"] {
4014 let c = load_config_from_str(&format!("telemetry_log_generations = {bad}\n"));
4015 assert_eq!(c.telemetry_log_generations, 1, "{bad}");
4016 assert!(
4017 c.warnings
4018 .iter()
4019 .any(|w| w.contains("telemetry_log_generations")),
4020 "{bad}: {:?}",
4021 c.warnings
4022 );
4023 }
4024 }
4025
4026 #[test]
4027 fn test_cache_keepwarm_headless_key() {
4028 assert!(!SynapsConfig::default().cache_keepwarm_headless, "default off");
4029 assert!(!load_config_from_str("").cache_keepwarm_headless);
4030 for on in ["true", "1", "on", "yes", "TRUE"] {
4031 let c = load_config_from_str(&format!("cache_keepwarm_headless = {on}\n"));
4032 assert!(c.cache_keepwarm_headless, "{on}");
4033 assert!(c.warnings.is_empty(), "{on}: {:?}", c.warnings);
4034 }
4035 for off in ["false", "0", "off", "no"] {
4036 let c = load_config_from_str(&format!("cache_keepwarm_headless = {off}\n"));
4037 assert!(!c.cache_keepwarm_headless, "{off}");
4038 }
4039 let c = load_config_from_str("cache_keepwarm_headless = maybe\n");
4040 assert!(!c.cache_keepwarm_headless, "garbage keeps it off");
4041 assert!(c.warnings.iter().any(|w| w.contains("cache_keepwarm_headless")));
4042 }
4043
4044 #[test]
4045 fn test_cache_keepwarm_tools_key() {
4046 assert!(SynapsConfig::default().cache_keepwarm_tools, "default on");
4047 assert!(load_config_from_str("").cache_keepwarm_tools);
4048 for off in ["false", "0", "off", "no", "OFF"] {
4049 let c = load_config_from_str(&format!("cache_keepwarm_tools = {off}\n"));
4050 assert!(!c.cache_keepwarm_tools, "{off}");
4051 assert!(c.warnings.is_empty(), "{off}: {:?}", c.warnings);
4052 }
4053 for on in ["true", "1", "on", "yes"] {
4054 assert!(load_config_from_str(&format!("cache_keepwarm_tools = {on}\n")).cache_keepwarm_tools);
4055 }
4056 let c = load_config_from_str("cache_keepwarm_tools = maybe\n");
4057 assert!(c.cache_keepwarm_tools, "garbage keeps it on");
4058 assert!(c.warnings.iter().any(|w| w.contains("cache_keepwarm_tools")));
4059 }
4060
4061 #[test]
4062 fn test_cache_keepwarm_config_key() {
4063 let c = load_config_from_str("cache_keepwarm = auto\ncache_ttl = 5m\n");
4064 assert_eq!(c.cache_keepwarm, CacheKeepWarm::Auto);
4065 assert_eq!(c.cache_ttl, CacheTtlSetting::Fixed(CacheTtl::FiveMinutes));
4066 assert!(c.warnings.is_empty(), "warnings: {:?}", c.warnings);
4067
4068 let c = load_config_from_str("cache_keepwarm = 6h\n");
4069 assert_eq!(c.cache_keepwarm, CacheKeepWarm::Hours(6));
4070
4071 assert_eq!(c.cache_keepwarm_auto_max_hours, 24, "default auto cap");
4072
4073 let c = load_config_from_str("cache_keepwarm_auto_max_hours = 8\n");
4074 assert_eq!(c.cache_keepwarm_auto_max_hours, 8);
4075 assert!(c.warnings.is_empty(), "warnings: {:?}", c.warnings);
4076 let c = load_config_from_str("cache_keepwarm_auto_max_hours = 12h\n");
4077 assert_eq!(c.cache_keepwarm_auto_max_hours, 12);
4078 for bad in ["0", "49", "lots"] {
4079 let c = load_config_from_str(&format!("cache_keepwarm_auto_max_hours = {bad}\n"));
4080 assert_eq!(c.cache_keepwarm_auto_max_hours, 24, "{bad}");
4081 assert!(
4082 c.warnings.iter().any(|w| w.contains("auto_max_hours")),
4083 "{bad}"
4084 );
4085 }
4086
4087 let c = load_config_from_str("cache_keepwarm = forever\n");
4088 assert_eq!(c.cache_keepwarm, CacheKeepWarm::Off, "garbage keeps keep-warm off");
4089 assert!(c.warnings.iter().any(|w| w.contains("cache_keepwarm")));
4090 }
4091
4092 #[test]
4093 fn request_compression_keys_parse_and_warn() {
4094 let c = load_config_from_str("");
4095 assert_eq!(c.request_compression, RequestCompressionMode::Auto);
4096 assert_eq!(c.request_compression_level, DEFAULT_REQUEST_COMPRESSION_LEVEL);
4097
4098 for (val, want) in [
4099 ("auto", RequestCompressionMode::Auto),
4100 ("ON", RequestCompressionMode::On),
4101 (" off ", RequestCompressionMode::Off),
4102 ] {
4103 let c = load_config_from_str(&format!("request_compression = {val}\n"));
4104 assert_eq!(c.request_compression, want, "{val:?}");
4105 assert!(c.warnings.is_empty(), "{val:?}: {:?}", c.warnings);
4106 assert_eq!(RequestCompressionMode::parse(&want.to_string()), Some(want));
4107 }
4108 let c = load_config_from_str("request_compression = brotli\n");
4109 assert_eq!(c.request_compression, RequestCompressionMode::Auto);
4110 assert!(c.warnings.iter().any(|w| w.contains("request_compression")));
4111
4112 for level in 1..=MAX_REQUEST_COMPRESSION_LEVEL {
4113 let c = load_config_from_str(&format!("request_compression_level = {level}\n"));
4114 assert_eq!(c.request_compression_level, level);
4115 assert!(c.warnings.is_empty());
4116 }
4117 for bad in ["0", "10", "max", "-1"] {
4118 let c = load_config_from_str(&format!("request_compression_level = {bad}\n"));
4119 assert_eq!(c.request_compression_level, DEFAULT_REQUEST_COMPRESSION_LEVEL, "{bad}");
4120 assert!(
4121 c.warnings.iter().any(|w| w.contains("request_compression_level")),
4122 "{bad}"
4123 );
4124 }
4125 }
4126
4127 #[test]
4130 fn test_cache_ttl_parse_table() {
4131 assert_eq!(CacheTtl::parse("5m"), Some(CacheTtl::FiveMinutes));
4133 assert_eq!(CacheTtl::parse("5min"), Some(CacheTtl::FiveMinutes));
4134 assert_eq!(CacheTtl::parse("default"), Some(CacheTtl::FiveMinutes));
4135 assert_eq!(CacheTtl::parse("1h"), Some(CacheTtl::OneHour));
4137 assert_eq!(CacheTtl::parse("60m"), Some(CacheTtl::OneHour));
4138 assert_eq!(CacheTtl::parse("1hr"), Some(CacheTtl::OneHour));
4139 assert_eq!(CacheTtl::parse("hybrid"), Some(CacheTtl::Hybrid));
4141 assert_eq!(CacheTtl::parse("1H"), Some(CacheTtl::OneHour));
4143 assert_eq!(CacheTtl::parse("HYBRID"), Some(CacheTtl::Hybrid));
4144 assert_eq!(CacheTtl::parse("Default"), Some(CacheTtl::FiveMinutes));
4145 assert_eq!(CacheTtl::parse("2h"), None);
4147 assert_eq!(CacheTtl::parse(""), None);
4148 assert_eq!(CacheTtl::parse("forever"), None);
4149 }
4150
4151 #[test]
4152 fn test_cache_ttl_default_is_auto() {
4153 assert_eq!(CacheTtl::default(), CacheTtl::FiveMinutes);
4156 assert_eq!(CacheTtlSetting::default(), CacheTtlSetting::Auto);
4157 assert_eq!(SynapsConfig::default().cache_ttl, CacheTtlSetting::Auto);
4158 assert_eq!(load_config_from_str("").cache_ttl, CacheTtlSetting::Auto);
4160 }
4161
4162 #[test]
4163 fn test_cache_ttl_setting_parse_and_round_trip() {
4164 assert_eq!(CacheTtlSetting::parse("auto"), Some(CacheTtlSetting::Auto));
4165 assert_eq!(CacheTtlSetting::parse(" AUTO "), Some(CacheTtlSetting::Auto));
4166 assert_eq!(
4167 CacheTtlSetting::parse("5m"),
4168 Some(CacheTtlSetting::Fixed(CacheTtl::FiveMinutes))
4169 );
4170 assert_eq!(
4171 CacheTtlSetting::parse("1hr"),
4172 Some(CacheTtlSetting::Fixed(CacheTtl::OneHour))
4173 );
4174 assert_eq!(
4175 CacheTtlSetting::parse("hybrid"),
4176 Some(CacheTtlSetting::Fixed(CacheTtl::Hybrid))
4177 );
4178 for bad in ["", "2h", "forever", "automatic", "on"] {
4179 assert_eq!(CacheTtlSetting::parse(bad), None, "{bad:?}");
4180 }
4181 for setting in [
4184 CacheTtlSetting::Auto,
4185 CacheTtlSetting::Fixed(CacheTtl::FiveMinutes),
4186 CacheTtlSetting::Fixed(CacheTtl::OneHour),
4187 CacheTtlSetting::Fixed(CacheTtl::Hybrid),
4188 ] {
4189 assert_eq!(CacheTtlSetting::parse(setting.as_str()), Some(setting));
4190 assert_eq!(setting.to_string(), setting.as_str());
4191 let cfg = load_config_from_str(&format!("cache_ttl = {setting}\n"));
4192 assert_eq!(cfg.cache_ttl, setting);
4193 assert!(cfg.warnings.is_empty(), "{setting}: {:?}", cfg.warnings);
4194 }
4195 }
4196
4197 #[test]
4198 fn test_cache_ttl_setting_resolution() {
4199 assert_eq!(CacheTtlSetting::Auto.resolve(true), CacheTtl::OneHour);
4201 assert_eq!(CacheTtlSetting::Auto.resolve(false), CacheTtl::FiveMinutes);
4202 assert_eq!(
4203 CacheTtlSetting::Auto.resolve_for_auth_type("oauth"),
4204 CacheTtl::OneHour
4205 );
4206 for not_oauth in ["api_key", "none", "", "OAuth"] {
4207 assert_eq!(
4208 CacheTtlSetting::Auto.resolve_for_auth_type(not_oauth),
4209 CacheTtl::FiveMinutes,
4210 "{not_oauth:?}"
4211 );
4212 }
4213 for ttl in [CacheTtl::FiveMinutes, CacheTtl::OneHour, CacheTtl::Hybrid] {
4215 for oauth in [true, false] {
4216 assert_eq!(CacheTtlSetting::Fixed(ttl).resolve(oauth), ttl);
4217 }
4218 }
4219 }
4220
4221 #[test]
4222 fn test_max_fps_default_is_60() {
4223 assert_eq!(SynapsConfig::default().max_fps, 60);
4224 }
4225
4226 #[test]
4229 fn test_theme_transition_parse_table() {
4230 use ThemeTransitionMode as M;
4231 assert_eq!(M::parse("on"), Some(M::On));
4232 assert_eq!(M::parse("ON"), Some(M::On));
4233 assert_eq!(M::parse("off"), Some(M::Off));
4234 assert_eq!(M::parse("Off"), Some(M::Off));
4235 assert_eq!(M::parse("500"), Some(M::Ms(500)));
4237 assert_eq!(M::parse("0"), Some(M::Ms(0)));
4238 assert_eq!(M::parse("2000"), Some(M::Ms(2000)));
4239 assert_eq!(M::parse("99999"), Some(M::Ms(2000)), "clamped to MAX_MS");
4240 assert_eq!(M::parse("fast"), None);
4242 assert_eq!(M::parse(""), None);
4243 assert_eq!(M::parse("-5"), None);
4244 assert_eq!(M::parse("1.5"), None);
4245 }
4246
4247 #[test]
4248 fn test_theme_transition_duration_ms() {
4249 use ThemeTransitionMode as M;
4250 assert_eq!(M::On.duration_ms(), M::DEFAULT_MS);
4251 assert_eq!(M::DEFAULT_MS, 350);
4252 assert_eq!(M::Off.duration_ms(), 0);
4253 assert_eq!(M::Ms(120).duration_ms(), 120);
4254 assert_eq!(M::Ms(0).duration_ms(), 0, "0 ms behaves like off");
4255 assert_eq!(M::Ms(u64::MAX).duration_ms(), M::MAX_MS);
4256 }
4257
4258 #[test]
4259 fn test_theme_transition_config_key_parses_and_defaults() {
4260 use ThemeTransitionMode as M;
4261 assert_eq!(SynapsConfig::default().theme_transition, M::On);
4262 assert_eq!(
4263 super::load_config_from_str("theme_transition = off\n").theme_transition,
4264 M::Off
4265 );
4266 assert_eq!(
4267 super::load_config_from_str("theme_transition = 750\n").theme_transition,
4268 M::Ms(750)
4269 );
4270 let bad = super::load_config_from_str("theme_transition = warp\n");
4271 assert_eq!(bad.theme_transition, M::On, "bad value keeps default");
4272 assert!(
4273 bad.warnings.iter().any(|w| w.contains("theme_transition")),
4274 "{:?}",
4275 bad.warnings
4276 );
4277 }
4278
4279 #[test]
4280 #[serial]
4281 fn test_max_fps_config_parse_and_validation() {
4282 let home = std::env::temp_dir().join(format!("synaps-maxfps-test-{}", std::process::id()));
4283 let dir = home.join(".synaps-cli");
4284 std::fs::create_dir_all(&dir).unwrap();
4285
4286 std::fs::write(dir.join("config"), "max_fps = 144\n").unwrap();
4288 with_home(&home, || {
4289 let config = load_config();
4290 assert_eq!(config.max_fps, 144);
4291 assert!(
4292 config.warnings.is_empty(),
4293 "warnings: {:?}",
4294 config.warnings
4295 );
4296 });
4297
4298 std::fs::write(dir.join("config"), "max_fps = 0\n").unwrap();
4300 with_home(&home, || {
4301 let config = load_config();
4302 assert_eq!(config.max_fps, 60);
4303 assert!(
4304 config.warnings.iter().any(|w| w.contains("max_fps")),
4305 "warnings: {:?}",
4306 config.warnings
4307 );
4308 });
4309
4310 std::fs::write(dir.join("config"), "max_fps = fast\n").unwrap();
4312 with_home(&home, || {
4313 let config = load_config();
4314 assert_eq!(config.max_fps, 60);
4315 assert!(
4316 config.warnings.iter().any(|w| w.contains("max_fps")),
4317 "warnings: {:?}",
4318 config.warnings
4319 );
4320 });
4321
4322 let _ = std::fs::remove_dir_all(&home);
4323 }
4324
4325 #[test]
4326 #[serial]
4327 fn test_cache_ttl_config_parse_and_garbage_warning() {
4328 let home =
4329 std::env::temp_dir().join(format!("synaps-cachettl-test-{}", std::process::id()));
4330 let dir = home.join(".synaps-cli");
4331 std::fs::create_dir_all(&dir).unwrap();
4332
4333 std::fs::write(dir.join("config"), "cache_ttl = hybrid\n").unwrap();
4335 with_home(&home, || {
4336 let config = load_config();
4337 assert_eq!(config.cache_ttl, CacheTtlSetting::Fixed(CacheTtl::Hybrid));
4338 assert!(
4339 config.warnings.is_empty(),
4340 "warnings: {:?}",
4341 config.warnings
4342 );
4343 });
4344
4345 std::fs::write(dir.join("config"), "cache_ttl = 2h\n").unwrap();
4347 with_home(&home, || {
4348 let config = load_config();
4349 assert_eq!(config.cache_ttl, CacheTtlSetting::Auto);
4350 assert!(
4351 config.warnings.iter().any(|w| w.contains("cache_ttl")),
4352 "warnings: {:?}",
4353 config.warnings
4354 );
4355 });
4356
4357 let _ = std::fs::remove_dir_all(&home);
4358 }
4359
4360 #[test]
4361 fn subagent_thinking_config_is_independent_and_validated() {
4362 use crate::core::reasoning::ReasoningLevel;
4363 let cfg = load_config_from_str("thinking = low\n");
4364 assert_eq!(cfg.subagent_thinking, ReasoningLevel::XHigh);
4365 for value in ["low", "medium", "high", "xhigh", "max"] {
4366 let cfg = load_config_from_str(&format!("thinking = high\nsubagent_thinking = {value}\n"));
4367 assert_eq!(cfg.subagent_thinking.as_str(), value);
4368 assert_eq!(cfg.thinking_level, Some(ReasoningLevel::High));
4369 assert!(cfg.warnings.is_empty(), "{:?}", cfg.warnings);
4370 }
4371 for value in ["typo", "", "32768", "off", "ultra"] {
4372 let cfg = load_config_from_str(&format!("subagent_thinking = {value}\n"));
4373 assert_eq!(cfg.subagent_thinking, ReasoningLevel::XHigh);
4374 assert!(!cfg.warnings.is_empty(), "{value:?}");
4375 }
4376 }
4377
4378 #[test]
4379 fn test_parse_thinking_budget() {
4380 use crate::core::reasoning::ThinkingSpec;
4381 assert_eq!(ThinkingSpec::parse("low").unwrap().to_budget(), Some(2048));
4383 assert_eq!(
4384 ThinkingSpec::parse("medium").unwrap().to_budget(),
4385 Some(4096)
4386 );
4387 assert_eq!(
4388 ThinkingSpec::parse("high").unwrap().to_budget(),
4389 Some(16384)
4390 );
4391 assert_eq!(
4392 ThinkingSpec::parse("xhigh").unwrap().to_budget(),
4393 Some(32768)
4394 );
4395 assert_eq!(ThinkingSpec::parse("8192").unwrap().to_budget(), Some(8192));
4396 let config = load_config_from_str("thinking = 8192\n");
4397 assert_eq!(config.thinking_level, None);
4398 assert_eq!(config.thinking_budget, Some(8192));
4399 assert_eq!(ThinkingSpec::parse("invalid"), None);
4400 }
4401
4402 #[test]
4403 fn test_base_dir() {
4404 let path = base_dir();
4405 assert!(path.to_string_lossy().ends_with(".synaps-cli"));
4406 }
4407
4408 #[test]
4409 fn test_resolve_system_prompt_explicit() {
4410 let result = resolve_system_prompt(Some("test prompt"));
4411 assert_eq!(result, "test prompt");
4412 }
4413
4414 #[test]
4415 #[serial]
4416 fn test_resolve_system_prompt_none() {
4417 let home = std::env::temp_dir().join(format!("synaps-prompt-none-{}", std::process::id()));
4419 std::fs::create_dir_all(home.join(".synaps-cli")).unwrap();
4420 with_home(&home, || {
4421 assert_eq!(resolve_system_prompt(None), DEFAULT_PROMPT);
4422 });
4423 let _ = std::fs::remove_dir_all(&home);
4424 }
4425
4426 #[test]
4427 fn default_prompts_carry_working_habit_steering() {
4428 for (name, prompt) in [
4429 ("unix", DEFAULT_PROMPT_UNIX),
4430 ("windows", DEFAULT_PROMPT_WINDOWS),
4431 ] {
4432 for needle in [
4433 "helpful AI agent running in a terminal",
4434 "Be concise and direct.",
4435 "targeted edits rather than rewriting them whole",
4436 "re-read it only if it may have changed",
4437 "as lean as the task allows",
4438 "Keep command output small",
4439 "read large outputs selectively",
4440 ] {
4441 assert!(prompt.contains(needle), "{name} prompt lacks {needle:?}");
4442 }
4443 assert!(!prompt.contains(" "), "{name} prompt has a double space");
4444 assert!(prompt.ends_with("selectively."), "{name}: {prompt}");
4445 }
4446 assert!(DEFAULT_PROMPT_UNIX.contains("`| tail -n`"));
4447 assert!(DEFAULT_PROMPT_WINDOWS.contains("on Windows"));
4448 assert!(DEFAULT_PROMPT_WINDOWS.contains("`Select-Object -Last N`"));
4449 assert!(!DEFAULT_PROMPT_WINDOWS.contains("tail -n"));
4450 }
4451
4452 #[test]
4455 fn default_prompts_stay_small() {
4456 assert!(DEFAULT_PROMPT_UNIX.len() <= 640, "{}", DEFAULT_PROMPT_UNIX.len());
4457 assert!(DEFAULT_PROMPT_WINDOWS.len() <= 900, "{}", DEFAULT_PROMPT_WINDOWS.len());
4458 }
4459
4460 #[test]
4463 fn explicit_system_prompt_is_byte_identical() {
4464 let text = " Custom prompt.\n\nKeep trailing whitespace. \n";
4465 assert_eq!(resolve_system_prompt(Some(text)), text);
4466
4467 let dir = std::env::temp_dir().join(format!("synaps-prompt-file-{}", std::process::id()));
4468 std::fs::create_dir_all(&dir).unwrap();
4469 let file = dir.join("system.txt");
4470 std::fs::write(&file, text).unwrap();
4471 assert_eq!(resolve_system_prompt(Some(file.to_str().unwrap())), text);
4472 let _ = std::fs::remove_dir_all(&dir);
4473 }
4474
4475 #[test]
4478 #[serial]
4479 fn system_md_prompt_is_byte_identical() {
4480 let text = "You are my agent.\nNo extra steering.\n";
4481 let home = std::env::temp_dir().join(format!("synaps-prompt-md-{}", std::process::id()));
4482 let dir = home.join(".synaps-cli");
4483 std::fs::create_dir_all(&dir).unwrap();
4484 std::fs::write(dir.join("system.md"), text).unwrap();
4485 with_home(&home, || {
4486 assert_eq!(resolve_system_prompt(None), text);
4487 assert_eq!(resolve_system_prompt(Some("explicit")), "explicit");
4489 });
4490 let _ = std::fs::remove_dir_all(&home);
4491 }
4492
4493 #[test]
4494 fn test_synaps_config_default() {
4495 let config = SynapsConfig::default();
4496 assert_eq!(config.model, None);
4497 assert_eq!(config.thinking_budget, None);
4498 assert_eq!(config.max_tool_output, 30000);
4499 assert_eq!(config.bash_timeout, 30);
4500 assert_eq!(config.bash_max_timeout, 300);
4501 assert_eq!(config.subagent_timeout, 300);
4502 assert_eq!(config.api_retries, 3);
4503 assert_eq!(config.theme, None);
4504 assert!(config.disabled_plugins.is_empty());
4505 assert!(config.favorite_models.is_empty());
4506 assert!(config.disabled_skills.is_empty());
4507 assert!(!config.progressive_tool_disclosure);
4508 assert_eq!(config.tools_activation_confirm, ActivationConfirm::Auto);
4509 assert!(!config.server.auto_approve_confirms);
4511 }
4512
4513 #[test]
4514 fn read_dedupe_key_parses_defaults_on_and_warns() {
4515 assert!(super::load_config_from_str("").read_dedupe, "default on");
4516 for (line, want) in [
4517 ("read_dedupe = off\n", false),
4518 ("read_dedupe = false\n", false),
4519 ("read_dedupe = on\n", true),
4520 ("read_dedupe = true\n", true),
4521 ] {
4522 let c = super::load_config_from_str(line);
4523 assert_eq!(c.read_dedupe, want, "{line}");
4524 assert!(c.warnings.is_empty(), "{line}: {:?}", c.warnings);
4525 }
4526 let c = super::load_config_from_str("read_dedupe = maybe\n");
4527 assert!(c.read_dedupe, "bad value keeps the default");
4528 assert!(c.warnings.iter().any(|w| w.contains("read_dedupe")));
4529 }
4530
4531 #[test]
4532 fn tool_output_spill_keys_parse_defaults_and_warn() {
4533 let c = super::load_config_from_str("");
4534 assert_eq!(c.tool_output_spill_bytes, 12 * 1024);
4535 assert_eq!(c.tool_output_spill_head_bytes, 4 * 1024);
4536 assert_eq!(c.tool_output_spill_tail_bytes, 3 * 1024);
4537 let d = super::SynapsConfig::default();
4538 assert_eq!(d.tool_output_spill_bytes, super::DEFAULT_TOOL_OUTPUT_SPILL_BYTES);
4539 assert_eq!(d.tool_output_spill_head_bytes, super::DEFAULT_TOOL_OUTPUT_SPILL_HEAD_BYTES);
4540 assert_eq!(d.tool_output_spill_tail_bytes, super::DEFAULT_TOOL_OUTPUT_SPILL_TAIL_BYTES);
4541
4542 let c = super::load_config_from_str(concat!(
4543 "tool_output_spill_bytes = 20000\n",
4544 "tool_output_spill_head_bytes = 6000\n",
4545 "tool_output_spill_tail_bytes = 0\n",
4546 ));
4547 assert_eq!(c.tool_output_spill_bytes, 20_000);
4548 assert_eq!(c.tool_output_spill_head_bytes, 6_000);
4549 assert_eq!(c.tool_output_spill_tail_bytes, 0, "a head-only preview is allowed");
4550 assert!(c.warnings.is_empty(), "warnings: {:?}", c.warnings);
4551
4552 let c = super::load_config_from_str("tool_output_spill_bytes = 0\n");
4553 assert_eq!(c.tool_output_spill_bytes, 0, "0 turns virtualisation off");
4554 assert!(c.warnings.is_empty(), "warnings: {:?}", c.warnings);
4555
4556 for key in [
4557 "tool_output_spill_bytes",
4558 "tool_output_spill_head_bytes",
4559 "tool_output_spill_tail_bytes",
4560 ] {
4561 let c = super::load_config_from_str(&format!("{key} = lots\n"));
4562 assert_eq!(c.tool_output_spill_bytes, super::DEFAULT_TOOL_OUTPUT_SPILL_BYTES);
4563 assert_eq!(c.tool_output_spill_head_bytes, super::DEFAULT_TOOL_OUTPUT_SPILL_HEAD_BYTES);
4564 assert_eq!(c.tool_output_spill_tail_bytes, super::DEFAULT_TOOL_OUTPUT_SPILL_TAIL_BYTES);
4565 assert!(
4566 c.warnings.iter().any(|w| w.contains(key)),
4567 "{key}: warnings: {:?}",
4568 c.warnings
4569 );
4570 assert!(super::KNOWN_CONFIG_KEYS.contains(&key));
4571 }
4572 }
4573
4574 #[test]
4575 #[serial]
4576 fn test_load_config_tools_activation_confirm_modes() {
4577 for (raw, expected, warns) in [
4578 ("prompt", ActivationConfirm::Prompt, false),
4579 ("deny", ActivationConfirm::Deny, false),
4580 ("AUTO", ActivationConfirm::Auto, false),
4581 ("garbage", ActivationConfirm::Auto, true),
4582 ] {
4583 let home = make_test_home(&format!("activation-confirm-{raw}"));
4584 let cfg = home.join(".synaps-cli/config");
4585 std::fs::write(&cfg, format!("tools.activation_confirm = {raw}\n")).unwrap();
4586 with_home(&home, || {
4587 let config = load_config();
4588 assert_eq!(config.tools_activation_confirm, expected, "{raw}");
4589 assert_eq!(
4590 config.warnings.iter().any(|w| w.contains("tools.activation_confirm")),
4591 warns,
4592 "{raw}: {:?}",
4593 config.warnings
4594 );
4595 });
4596 let _ = std::fs::remove_dir_all(&home);
4597 }
4598 }
4599
4600 #[test]
4601 #[serial]
4602 fn test_load_config_progressive_tool_disclosure_is_opt_in() {
4603 let home = make_test_home("progressive-tool-disclosure");
4604 let cfg = home.join(".synaps-cli/config");
4605 std::fs::write(&cfg, "progressive_tool_disclosure = true\n").unwrap();
4606
4607 with_home(&home, || {
4608 let config = load_config();
4609 assert!(config.progressive_tool_disclosure);
4610 assert!(config.warnings.is_empty());
4611 });
4612
4613 let _ = std::fs::remove_dir_all(&home);
4614 }
4615
4616 #[test]
4617 fn removed_watcher_bridge_keys_still_load_with_one_warning() {
4618 let config = load_config_from_str(
4619 "bridge.uds_path = /tmp/some/control.sock\n\
4620 bridge.heartbeat_mirror = true\n\
4621 bridge.heartbeat_timeout_ms = 750\n\
4622 bridge.plugin_key = kept\n\
4623 model = claude-sonnet-4-6\n",
4624 );
4625 assert_eq!(config.model.as_deref(), Some("claude-sonnet-4-6"));
4626 assert_eq!(config.warnings, [removed_bridge_warning()], "{:?}", config.warnings);
4627 }
4628
4629 #[test]
4630 fn compaction_disclosure_keys_parse_with_typed_warnings() {
4631 let config = load_config_from_str(
4632 "compaction_mode = local\ncompaction_exclude = thinking, tool_results, bogus\n",
4633 );
4634 assert_eq!(
4635 config.compaction_mode,
4636 crate::core::compaction::CompactionMode::LocalOnly
4637 );
4638 assert_eq!(
4639 config.compaction_exclude,
4640 vec![
4641 crate::core::compaction::ContentClass::Thinking,
4642 crate::core::compaction::ContentClass::ToolResults,
4643 ]
4644 );
4645 assert!(
4646 config.warnings.iter().any(|w| w.contains("bogus")),
4647 "unknown class must warn: {:?}",
4648 config.warnings
4649 );
4650
4651 let defaults = load_config_from_str("");
4652 assert_eq!(
4653 defaults.compaction_mode,
4654 crate::core::compaction::CompactionMode::Remote
4655 );
4656 assert!(defaults.compaction_exclude.is_empty());
4657
4658 let bad_mode = load_config_from_str("compaction_mode = cloud\n");
4659 assert_eq!(
4660 bad_mode.compaction_mode,
4661 crate::core::compaction::CompactionMode::Remote
4662 );
4663 assert!(bad_mode
4664 .warnings
4665 .iter()
4666 .any(|w| w.contains("compaction_mode")));
4667 }
4668
4669 #[test]
4670 #[serial]
4671 fn test_load_config_server_keys() {
4672 let home = make_test_home("server-keys");
4673 let cfg = home.join(".synaps-cli/config");
4674 std::fs::write(
4675 &cfg,
4676 "\
4677server.allowed_origins = http://localhost:3000, http://localhost:5193\n\
4678server.token = my-secret-token\n\
4679server.auto_approve_confirms = true\n\
4680server.max_message_size = 65536\n\
4681context_window = 200k\n\
4682",
4683 )
4684 .unwrap();
4685
4686 with_home(&home, || {
4687 let config = load_config();
4691 assert!(config.server.auto_approve_confirms);
4692 assert_eq!(config.context_window, Some(200_000));
4693 assert_eq!(config.warnings, [removed_server_warning()], "{:?}", config.warnings);
4694 for value in ["my-secret-token", "localhost:3000", "localhost:5193", "65536"] {
4696 assert!(
4697 !config.warnings.iter().any(|w| w.contains(value)),
4698 "{value} echoed: {:?}",
4699 config.warnings
4700 );
4701 }
4702 });
4703
4704 let _ = std::fs::remove_dir_all(&home);
4705 }
4706
4707 fn make_test_home(subdir: &str) -> std::path::PathBuf {
4708 let dir = std::path::PathBuf::from(format!("/tmp/synaps-write-test-{}", subdir));
4709 let _ = std::fs::remove_dir_all(&dir);
4710 std::fs::create_dir_all(dir.join(".synaps-cli")).unwrap();
4711 dir
4712 }
4713
4714 fn with_home<F: FnOnce()>(home: &std::path::Path, f: F) {
4715 let original = std::env::var("HOME").ok();
4718 let original_base = std::env::var("SYNAPS_BASE_DIR").ok();
4719 std::env::set_var("HOME", home);
4720 std::env::set_var("SYNAPS_BASE_DIR", home.join(".synaps-cli"));
4721 f();
4722 if let Some(h) = original {
4723 std::env::set_var("HOME", h);
4724 } else {
4725 std::env::remove_var("HOME");
4726 }
4727 if let Some(b) = original_base {
4728 std::env::set_var("SYNAPS_BASE_DIR", b);
4729 } else {
4730 std::env::remove_var("SYNAPS_BASE_DIR");
4731 }
4732 }
4733
4734 #[test]
4735 #[serial]
4736 fn write_config_value_replaces_existing_key() {
4737 let home = make_test_home("replace");
4738 let cfg = home.join(".synaps-cli/config");
4739 std::fs::write(&cfg, "model = claude-opus-4-6\nthinking = low\n").unwrap();
4740
4741 with_home(&home, || {
4742 write_config_value("model", "claude-sonnet-4-6").unwrap();
4743 });
4744
4745 let contents = std::fs::read_to_string(&cfg).unwrap();
4746 assert!(contents.contains("model = claude-sonnet-4-6"));
4747 assert!(contents.contains("thinking = low"));
4748 let _ = std::fs::remove_dir_all(&home);
4749 }
4750
4751 #[test]
4752 #[serial]
4753 fn write_config_value_appends_when_missing() {
4754 let home = make_test_home("append");
4755 let cfg = home.join(".synaps-cli/config");
4756 std::fs::write(&cfg, "model = claude-opus-4-6\n").unwrap();
4757
4758 with_home(&home, || {
4759 write_config_value("theme", "dracula").unwrap();
4760 });
4761
4762 let contents = std::fs::read_to_string(&cfg).unwrap();
4763 assert!(contents.contains("model = claude-opus-4-6"));
4764 assert!(contents.contains("theme = dracula"));
4765 let _ = std::fs::remove_dir_all(&home);
4766 }
4767
4768 #[test]
4769 #[serial]
4770 fn write_config_value_concurrent_writers_lose_nothing() {
4771 let home = make_test_home("concurrent");
4772 let cfg = home.join(".synaps-cli/config");
4773 std::fs::write(&cfg, "# keep\n").unwrap();
4774
4775 with_home(&home, || {
4777 let a = std::thread::spawn(|| {
4778 for i in 0..200 {
4779 write_config_value("a_key", &i.to_string()).unwrap();
4780 }
4781 });
4782 let b = std::thread::spawn(|| {
4783 for i in 0..200 {
4784 write_config_value("b_key", &i.to_string()).unwrap();
4785 }
4786 });
4787 a.join().unwrap();
4788 b.join().unwrap();
4789 });
4790
4791 let contents = std::fs::read_to_string(&cfg).unwrap();
4792 assert!(contents.contains("# keep"), "{contents}");
4793 assert!(contents.contains("a_key = 199"), "{contents}");
4794 assert!(contents.contains("b_key = 199"), "{contents}");
4795 assert_eq!(contents.matches("a_key").count(), 1);
4796 assert_eq!(contents.matches("b_key").count(), 1);
4797
4798 #[cfg(unix)]
4799 {
4800 use std::os::unix::fs::PermissionsExt;
4801 let lock = home.join(".synaps-cli/config.lock");
4802 let mode = std::fs::metadata(&lock).unwrap().permissions().mode() & 0o777;
4803 assert_eq!(mode, 0o600);
4804 }
4805 let _ = std::fs::remove_dir_all(&home);
4806 }
4807
4808 #[test]
4809 #[serial]
4810 fn config_lock_kill_switch_still_writes() {
4811 let home = make_test_home("lock-off");
4812 let cfg = home.join(".synaps-cli/config");
4813 std::env::set_var("SYNAPS_CONFIG_LOCK", "0");
4814 with_home(&home, || {
4815 write_config_value("model", "claude-sonnet-4-6").unwrap();
4816 });
4817 std::env::remove_var("SYNAPS_CONFIG_LOCK");
4818 let contents = std::fs::read_to_string(&cfg).unwrap();
4819 assert!(contents.contains("model = claude-sonnet-4-6"));
4820 assert!(!home.join(".synaps-cli/config.lock").exists());
4821 let _ = std::fs::remove_dir_all(&home);
4822 }
4823
4824 #[test]
4825 #[serial]
4826 fn add_favorite_model_end_to_end_does_not_deadlock() {
4827 let home = make_test_home("favorite-lock");
4828 let cfg = home.join(".synaps-cli/config");
4829 with_home(&home, || {
4830 add_favorite_model("anthropic/claude-sonnet-4-6").unwrap();
4831 add_favorite_model("openai/gpt-5").unwrap();
4832 remove_favorite_model("anthropic/claude-sonnet-4-6").unwrap();
4833 });
4834 let contents = std::fs::read_to_string(&cfg).unwrap();
4835 assert!(contents.contains("favorite_models = openai/gpt-5"), "{contents}");
4836 let _ = std::fs::remove_dir_all(&home);
4837 }
4838
4839 #[test]
4840 #[serial]
4841 fn write_config_value_preserves_comments() {
4842 let home = make_test_home("comments");
4843 let cfg = home.join(".synaps-cli/config");
4844 std::fs::write(&cfg, "# user comment\nmodel = claude-opus-4-6\n# another\n").unwrap();
4845
4846 with_home(&home, || {
4847 write_config_value("model", "claude-sonnet-4-6").unwrap();
4848 });
4849
4850 let contents = std::fs::read_to_string(&cfg).unwrap();
4851 assert!(contents.contains("# user comment"));
4852 assert!(contents.contains("# another"));
4853 assert!(contents.contains("model = claude-sonnet-4-6"));
4854 let _ = std::fs::remove_dir_all(&home);
4855 }
4856
4857 #[test]
4858 #[serial]
4859 fn write_config_value_preserves_unknown_keys() {
4860 let home = make_test_home("unknown");
4861 let cfg = home.join(".synaps-cli/config");
4862 std::fs::write(&cfg, "custom_thing = 42\nmodel = claude-opus-4-6\n").unwrap();
4863
4864 with_home(&home, || {
4865 write_config_value("model", "claude-sonnet-4-6").unwrap();
4866 });
4867
4868 let contents = std::fs::read_to_string(&cfg).unwrap();
4869 assert!(contents.contains("custom_thing = 42"));
4870 let _ = std::fs::remove_dir_all(&home);
4871 }
4872
4873 #[test]
4874 #[serial]
4875 fn write_config_value_creates_file_if_absent() {
4876 let home = make_test_home("create");
4877 let cfg = home.join(".synaps-cli/config");
4878 assert!(!cfg.exists());
4879
4880 with_home(&home, || {
4881 write_config_value("model", "claude-sonnet-4-6").unwrap();
4882 });
4883
4884 let contents = std::fs::read_to_string(&cfg).unwrap();
4885 assert!(contents.contains("model = claude-sonnet-4-6"));
4886 let _ = std::fs::remove_dir_all(&home);
4887 }
4888
4889 #[test]
4890 #[serial]
4891 fn load_config_parses_theme_key() {
4892 let dir = std::path::PathBuf::from("/tmp/synaps-config-test-theme/.synaps-cli");
4893 let _ = std::fs::create_dir_all(&dir);
4894 std::fs::write(dir.join("config"), "theme = dracula\n").unwrap();
4895
4896 let original_home = std::env::var("HOME").ok();
4897 std::env::set_var("HOME", "/tmp/synaps-config-test-theme");
4898
4899 let config = load_config();
4900
4901 if let Some(home) = original_home {
4902 std::env::set_var("HOME", home);
4903 } else {
4904 std::env::remove_var("HOME");
4905 }
4906 let _ = std::fs::remove_dir_all("/tmp/synaps-config-test-theme");
4907
4908 assert_eq!(config.theme.as_deref(), Some("dracula"));
4909 }
4910
4911 #[test]
4912 #[serial]
4913 fn test_load_config_disable_lists() {
4914 let test_dir =
4915 std::path::PathBuf::from("/tmp/synaps-config-test-disable-lists/.synaps-cli");
4916 let _ = std::fs::create_dir_all(&test_dir);
4917 let config_path = test_dir.join("config");
4918
4919 let config_content = r#"
4920# Test config with disable lists
4921favorite_models = claude/claude-opus-4-7, groq/llama-3.3-70b-versatile
4922
4923disabled_plugins = foo, bar
4924disabled_skills = baz, plug:qual
4925"#;
4926 std::fs::write(&config_path, config_content).unwrap();
4927
4928 let original_home = std::env::var("HOME").ok();
4929 std::env::set_var("HOME", "/tmp/synaps-config-test-disable-lists");
4930
4931 let config = load_config();
4932
4933 if let Some(home) = original_home {
4934 std::env::set_var("HOME", home);
4935 } else {
4936 std::env::remove_var("HOME");
4937 }
4938
4939 let _ = std::fs::remove_dir_all("/tmp/synaps-config-test-disable-lists");
4940
4941 assert_eq!(
4942 config.disabled_plugins,
4943 vec!["foo".to_string(), "bar".to_string()]
4944 );
4945 assert_eq!(
4946 config.favorite_models,
4947 vec![
4948 "claude/claude-opus-4-7".to_string(),
4949 "groq/llama-3.3-70b-versatile".to_string(),
4950 ]
4951 );
4952 assert_eq!(
4953 config.disabled_skills,
4954 vec!["baz".to_string(), "plug:qual".to_string()]
4955 );
4956 }
4957
4958 #[test]
4959 #[serial]
4960 fn favorite_model_helpers_round_trip_through_config_file() {
4961 let home = make_test_home("favorite-models");
4962 let cfg = home.join(".synaps-cli/config");
4963 std::fs::write(&cfg, "model = claude-opus-4-7\n").unwrap();
4964
4965 with_home(&home, || {
4966 add_favorite_model("groq/llama-3.3-70b-versatile").unwrap();
4967 add_favorite_model("claude/claude-opus-4-7").unwrap();
4968 add_favorite_model("groq/llama-3.3-70b-versatile").unwrap();
4969 assert!(is_favorite_model("groq/llama-3.3-70b-versatile"));
4970 remove_favorite_model("groq/llama-3.3-70b-versatile").unwrap();
4971 assert!(!is_favorite_model("groq/llama-3.3-70b-versatile"));
4972 assert!(is_favorite_model("claude/claude-opus-4-7"));
4973 });
4974
4975 let contents = std::fs::read_to_string(&cfg).unwrap();
4976 assert!(contents.contains("model = claude-opus-4-7"));
4977 assert!(contents.contains("favorite_models = claude/claude-opus-4-7"));
4978 let _ = std::fs::remove_dir_all(&home);
4979 }
4980
4981 #[test]
4982 #[serial]
4983 fn test_load_config_new_keys() {
4984 let test_dir = std::path::PathBuf::from("/tmp/synaps-config-test-new-keys/.synaps-cli");
4986 let _ = std::fs::create_dir_all(&test_dir);
4987 let config_path = test_dir.join("config");
4988
4989 let config_content = r#"
4990# Test config with new keys
4991model = claude-haiku
4992thinking = medium
4993max_tool_output = 50000
4994bash_timeout = 45
4995bash_max_timeout = 600
4996subagent_timeout = 120
4997api_retries = 5
4998"#;
4999 std::fs::write(&config_path, config_content).unwrap();
5000
5001 let original_home = std::env::var("HOME").ok();
5003 std::env::set_var("HOME", "/tmp/synaps-config-test-new-keys");
5004
5005 let config = load_config();
5006
5007 if let Some(home) = original_home {
5009 std::env::set_var("HOME", home);
5010 } else {
5011 std::env::remove_var("HOME");
5012 }
5013
5014 let _ = std::fs::remove_dir_all("/tmp/synaps-config-test-new-keys");
5016
5017 assert_eq!(config.model, Some("claude-haiku".to_string()));
5018 assert_eq!(config.thinking_budget, Some(4096)); assert_eq!(config.max_tool_output, 50000);
5020 assert_eq!(config.bash_timeout, 45);
5021 assert_eq!(config.bash_max_timeout, 600);
5022 assert_eq!(config.subagent_timeout, 120);
5023 assert_eq!(config.api_retries, 5);
5024 }
5025
5026 #[test]
5031 fn events_auto_turn_explicit_true_values() {
5032 for val in &["true", "TRUE", "1", "yes", "YES", "on", "ON"] {
5033 let cfg = load_config_from_str(&format!("events.auto_turn = {val}"));
5034 assert!(
5035 cfg.events.auto_turn,
5036 "expected true for events.auto_turn = {val}"
5037 );
5038 }
5039 }
5040
5041 #[test]
5042 fn events_auto_turn_explicit_false_values() {
5043 for val in &["false", "FALSE", "0", "no", "NO", "off", "OFF"] {
5044 let cfg = load_config_from_str(&format!("events.auto_turn = {val}"));
5045 assert!(
5046 !cfg.events.auto_turn,
5047 "expected false for events.auto_turn = {val}"
5048 );
5049 }
5050 }
5051
5052 #[test]
5053 fn events_auto_turn_typo_fails_safe_false() {
5054 let cfg = load_config_from_str("events.auto_turn = fales");
5056 assert!(
5057 !cfg.events.auto_turn,
5058 "typo 'fales' must fail safe to false"
5059 );
5060 }
5061
5062 #[test]
5065 fn events_auto_turn_cap_default_is_five() {
5066 let cfg = load_config_from_str("");
5067 assert_eq!(cfg.events.auto_turn_cap, 5);
5068 assert_eq!(DEFAULT_AUTO_TURN_CAP, 5);
5069 }
5070
5071 #[test]
5072 fn events_auto_turn_cap_zero_is_unlimited() {
5073 let cfg = load_config_from_str("events.auto_turn_cap = 0");
5074 assert_eq!(cfg.events.auto_turn_cap, 0);
5075 }
5076
5077 #[test]
5078 fn events_auto_turn_cap_unlimited_aliases() {
5079 for val in ["unlimited", "inf", "infinite", "Infinity", "UNLIMITED", " inf "] {
5080 let cfg = load_config_from_str(&format!("events.auto_turn_cap = {val}"));
5081 assert_eq!(
5082 cfg.events.auto_turn_cap, 0,
5083 "expected 0 for events.auto_turn_cap = {val:?}"
5084 );
5085 }
5086 }
5087
5088 #[test]
5089 fn events_auto_turn_cap_explicit_number() {
5090 let cfg = load_config_from_str("events.auto_turn_cap = 12");
5091 assert_eq!(cfg.events.auto_turn_cap, 12);
5092 }
5093
5094 #[test]
5095 fn events_auto_turn_cap_garbage_keeps_default() {
5096 for val in ["banana", "-3", "5.5", "", "true"] {
5097 let cfg = load_config_from_str(&format!("events.auto_turn_cap = {val}"));
5098 assert_eq!(
5099 cfg.events.auto_turn_cap, 5,
5100 "garbage {val:?} must keep default 5"
5101 );
5102 }
5103 }
5104
5105 #[test]
5106 fn events_auto_turn_cap_independent_of_auto_turn() {
5107 let cfg = load_config_from_str("events.auto_turn = false\nevents.auto_turn_cap = 0");
5108 assert!(!cfg.events.auto_turn);
5109 assert_eq!(cfg.events.auto_turn_cap, 0);
5110 }
5111
5112 #[test]
5113 fn events_auto_turn_default_is_true() {
5114 let cfg = load_config_from_str("model = claude-haiku");
5115 assert!(cfg.events.auto_turn, "default must be true when key absent");
5116 }
5117
5118 #[test]
5119 #[serial]
5120 fn test_auth_config_parses_endpoint_and_token() {
5121 let home = std::env::temp_dir().join(format!("synaps-auth-test-{}", std::process::id()));
5122 let dir = home.join(".synaps-cli");
5123 std::fs::create_dir_all(&dir).unwrap();
5124 std::fs::write(
5125 dir.join("config"),
5126 "auth.remote_endpoint = https://jade.jade:8181\nauth.machine_token = machine-abc\n",
5127 )
5128 .unwrap();
5129 with_home(&home, || {
5130 let config = load_config();
5131 assert_eq!(
5132 config.auth.remote_endpoint.as_deref(),
5133 Some("https://jade.jade:8181")
5134 );
5135 assert_eq!(config.auth.machine_token.as_deref(), Some("machine-abc"));
5136 assert!(
5138 !config.warnings.iter().any(|w| w.contains("auth.")),
5139 "{:?}",
5140 config.warnings
5141 );
5142 });
5143 let _ = std::fs::remove_dir_all(&home);
5144 }
5145
5146 #[test]
5147 #[serial]
5148 fn test_credential_source_local_by_default() {
5149 std::env::remove_var("SYNAPS_AUTH_ENDPOINT");
5150 std::env::remove_var("SYNAPS_MACHINE_TOKEN");
5151 assert!(!AuthConfig::default().credential_source().is_remote());
5152 }
5153
5154 #[test]
5155 #[serial]
5156 fn test_credential_source_remote_from_config() {
5157 std::env::remove_var("SYNAPS_AUTH_ENDPOINT");
5158 std::env::remove_var("SYNAPS_MACHINE_TOKEN");
5159 let auth = AuthConfig {
5160 remote_endpoint: Some("https://b".into()),
5161 machine_token: Some("m".into()),
5162 accounts: BTreeMap::new(),
5163 auto: BTreeMap::new(),
5164 };
5165 assert_eq!(
5166 auth.credential_source(),
5167 crate::core::auth::CredentialSource::Remote {
5168 endpoint: "https://b".into(),
5169 machine_token: "m".into()
5170 }
5171 );
5172 }
5173
5174 #[test]
5175 fn auth_account_keys_parse_and_invalid_fail_closed_with_warning() {
5176 let config = load_config_from_str(
5177 "auth.account.openai-codex = astra2\nauth.account.anthropic = auto\nauth.account.kimi-code = Bad Label\nauth.account.groq = x\n",
5178 );
5179 assert_eq!(
5180 config.auth.accounts.get("openai-codex").map(String::as_str),
5181 Some("astra2")
5182 );
5183 assert_eq!(
5184 config.auth.accounts.get("anthropic").map(String::as_str),
5185 Some("auto")
5186 );
5187 let policy = config.auth.account_policy();
5188 use crate::core::auth::{AccountSelector, OAuthProviderId};
5189 assert!(matches!(
5190 policy.selector(OAuthProviderId::OpenAiCodex),
5191 AccountSelector::Account(_)
5192 ));
5193 assert_eq!(
5194 policy.selector(OAuthProviderId::Anthropic),
5195 AccountSelector::Auto
5196 );
5197 assert!(
5198 matches!(
5199 policy.selector(OAuthProviderId::KimiCode),
5200 AccountSelector::Invalid { .. }
5201 ),
5202 "malformed label must fail closed"
5203 );
5204 assert!(
5205 config
5206 .warnings
5207 .iter()
5208 .any(|w| w.contains("auth.account.kimi-code")),
5209 "{:?}",
5210 config.warnings
5211 );
5212 assert!(
5213 config
5214 .warnings
5215 .iter()
5216 .any(|w| w.contains("auth.account.groq")),
5217 "{:?}",
5218 config.warnings
5219 );
5220 assert!(
5221 !config
5222 .warnings
5223 .iter()
5224 .any(|w| w.contains("auth.account.openai-codex")),
5225 "{:?}",
5226 config.warnings
5227 );
5228 }
5229
5230 #[test]
5231 #[serial]
5232 fn test_credential_source_env_overrides_config() {
5233 let auth = AuthConfig {
5234 remote_endpoint: Some("https://config-host".into()),
5235 machine_token: Some("config-tok".into()),
5236 accounts: BTreeMap::new(),
5237 auto: BTreeMap::new(),
5238 };
5239 std::env::set_var("SYNAPS_AUTH_ENDPOINT", "https://env-host");
5240 std::env::set_var("SYNAPS_MACHINE_TOKEN", "env-tok");
5241 let src = auth.credential_source();
5242 std::env::remove_var("SYNAPS_AUTH_ENDPOINT");
5243 std::env::remove_var("SYNAPS_MACHINE_TOKEN");
5244 assert_eq!(
5245 src,
5246 crate::core::auth::CredentialSource::Remote {
5247 endpoint: "https://env-host".into(),
5248 machine_token: "env-tok".into()
5249 }
5250 );
5251 }
5252 #[test]
5255 #[serial]
5256 fn test_credential_source_records_token_provenance_and_file_is_resolved_lazily() {
5257 use crate::core::auth::MachineToken;
5258 let dir = tempfile::tempdir().unwrap();
5259 let prev = std::env::var_os("SYNAPS_BASE_DIR");
5260 std::env::set_var("SYNAPS_BASE_DIR", dir.path());
5261 std::env::remove_var("SYNAPS_AUTH_ENDPOINT");
5262 std::env::remove_var("SYNAPS_MACHINE_TOKEN");
5263 std::fs::write(dir.path().join("auth-broker.token"), "local-file-tok\n").unwrap();
5264 let source = |endpoint: &str, token: Option<&str>| {
5265 AuthConfig {
5266 remote_endpoint: Some(endpoint.into()),
5267 machine_token: token.map(str::to_string),
5268 accounts: BTreeMap::new(),
5269 auto: BTreeMap::new(),
5270 }
5271 .credential_source()
5272 };
5273 let remote_tok = |s: crate::core::auth::CredentialSource| match s {
5274 crate::core::auth::CredentialSource::Remote { machine_token, .. } => machine_token,
5275 other => panic!("{other:?}"),
5276 };
5277 let loopback = remote_tok(source("http://127.0.0.1:8181", None));
5279 let explicit = remote_tok(source("http://127.0.0.1:8181", Some("cfg-tok")));
5280 let remote = remote_tok(source("https://jade.jade:8181", None));
5281 assert_eq!(loopback, "");
5282 assert_eq!(explicit, "cfg-tok");
5283 assert_eq!(remote, "");
5284 let auto = MachineToken::for_endpoint("http://localhost:8181", &loopback);
5286 let fixed = MachineToken::for_endpoint("http://127.0.0.1:8181", &explicit);
5287 let far = MachineToken::for_endpoint("https://jade.jade:8181", &remote);
5288 match prev {
5289 Some(v) => std::env::set_var("SYNAPS_BASE_DIR", v),
5290 None => std::env::remove_var("SYNAPS_BASE_DIR"),
5291 }
5292 assert_eq!(auto.get(), "local-file-tok");
5293 assert_eq!(fixed.get(), "cfg-tok", "explicit token is never replaced");
5294 assert_eq!(far.get(), "", "never read for a non-loopback broker");
5295 }
5296
5297 #[test]
5298 fn config_parses_ultracode_as_distinct_canonical_level() {
5299 let cfg = load_config_from_str("thinking = ultracode\n");
5300 assert_eq!(
5301 cfg.thinking_level,
5302 Some(crate::reasoning::ReasoningLevel::UltraCode)
5303 );
5304 assert_eq!(cfg.thinking_budget, None);
5305 for other in [
5306 crate::reasoning::ReasoningLevel::Ultra,
5307 crate::reasoning::ReasoningLevel::Max,
5308 crate::reasoning::ReasoningLevel::XHigh,
5309 ] {
5310 assert_ne!(cfg.thinking_level, Some(other));
5311 }
5312 }
5313
5314 #[test]
5319 fn context_window_auto_is_model_default_without_warning() {
5320 for spelling in ["auto", "AUTO", "Auto"] {
5321 let cfg = load_config_from_str(&format!("context_window = {spelling}\n"));
5322 assert_eq!(cfg.context_window, None, "{spelling}");
5323 assert!(cfg.warnings.is_empty(), "{spelling}: {:?}", cfg.warnings);
5324 }
5325 let cfg = load_config_from_str("context_window = 1m\ncontext_window = auto\n");
5327 assert_eq!(cfg.context_window, None);
5328 assert!(cfg.warnings.is_empty(), "{:?}", cfg.warnings);
5329 assert_eq!(
5331 load_config_from_str("context_window = 200k\n").context_window,
5332 Some(200_000)
5333 );
5334 let bad = load_config_from_str("context_window = huge\n");
5335 assert_eq!(bad.context_window, None);
5336 assert!(bad.warnings.iter().any(|w| w.contains("context_window")));
5337 }
5338
5339 #[test]
5342 fn removed_sidecar_toggle_key_loads_and_warns_once() {
5343 let expected = format!(
5344 "sidecar_toggle_key is ignored: the plugin sidecar system was removed in {}, so \
5345 core binds no toggle key any more. Plugin keybinds and the keybinds setting \
5346 still work. Remove this line",
5347 crate::SIDECAR_REMOVED_IN
5348 );
5349 assert_eq!(super::removed_sidecar_toggle_key_warning(), expected);
5350 for content in [
5351 "sidecar_toggle_key = F2\n",
5352 "sidecar_toggle_key = F8\nmodel = claude-opus-4-7\nsidecar_toggle_key = C-V\n",
5353 "sidecar_toggle_key = \n",
5354 ] {
5355 let cfg = load_config_from_str(content);
5356 assert_eq!(cfg.warnings, vec![expected.clone()], "{content:?}");
5357 }
5358 assert!(!KNOWN_CONFIG_KEYS.contains(&"sidecar_toggle_key"));
5359 assert!(KNOWN_CONFIG_KEYS.contains(&"compaction_mode"));
5360 assert!(KNOWN_CONFIG_KEYS.contains(&"compaction_exclude"));
5361 }
5362
5363 #[test]
5367 fn config_content_problems_covers_family_diagnostics() {
5368 for bad in [
5369 "events.auto_turn = maybe",
5370 "events.auto_turn_cap = many",
5371 "turn_budget.worker.max_cost_usd = cheap",
5372 "turn_budget.nobody.max_tool_calls = 3",
5373 "memory.nosuchfield = 1",
5374 "cache_ttl = forever",
5375 ] {
5376 let problems = config_content_problems(bad);
5377 assert!(!problems.is_empty(), "{bad} should be reported");
5378 }
5379 for good in [
5380 "events.auto_turn = off",
5381 "events.auto_turn_cap = unlimited",
5382 "turn_budget.worker.max_cost_usd = 2.5",
5383 "cache_ttl = hybrid",
5384 "cache_ttl = auto",
5385 ] {
5386 let problems = config_content_problems(good);
5387 assert!(problems.is_empty(), "{good}: {problems:?}");
5388 }
5389 let cfg = load_config_from_str("events.auto_turn = maybe\n");
5391 assert!(!cfg.events.auto_turn, "unrecognised auto_turn still fails safe to false");
5392 }
5393
5394 #[test]
5397 fn removed_shell_keys_warn_once() {
5398 assert_eq!(REMOVED_SHELL_KEYS.len(), 8);
5399 for key in REMOVED_SHELL_KEYS {
5400 assert!(!KNOWN_CONFIG_KEYS.contains(key) && !FAMILY_CONFIG_KEYS.contains(key));
5401 }
5402 let expected = format!(
5403 "shell.max_sessions, shell.max_output no longer do anything (removed in {}; they \
5404 were never read): delete these lines",
5405 crate::REMOVED_IN
5406 );
5407 let content = "shell.max_output = big\nshell.max_sessions = 10\nmodel = m\n\
5409 shell.max_output = huge\n";
5410 let cfg = load_config_from_str(content);
5411 assert_eq!(cfg.warnings, std::slice::from_ref(&expected), "{:?}", cfg.warnings);
5412 assert_eq!(cfg.model.as_deref(), Some("m"), "the rest of the file still loads");
5413 assert_eq!(config_content_problems(content), [expected]);
5414 let one = load_config_from_str("shell.default_rows = 30\n");
5415 assert_eq!(
5416 one.warnings,
5417 [format!(
5418 "shell.default_rows no longer does anything (removed in {}; it was never read): \
5419 delete this line",
5420 crate::REMOVED_IN
5421 )]
5422 );
5423 }
5424
5425 #[test]
5426 fn bare_or_empty_auth_family_keys_warn() {
5427 let cfg = load_config_from_str(
5428 "auth.account = work\nauth.account. = work\nauth.auto = on\nauth.auto. = 1\n",
5429 );
5430 let w = &cfg.warnings;
5431 for key in ["auth.account", "auth.account."] {
5432 assert!(
5433 w.contains(&format!("unknown key '{key}' (did you mean 'auth.account.<provider>'?)")),
5434 "{key}: {w:?}"
5435 );
5436 }
5437 for key in ["auth.auto", "auth.auto."] {
5438 assert!(
5439 w.contains(&format!("unknown key '{key}' (did you mean 'auth.auto.<key>'?)")),
5440 "{key}: {w:?}"
5441 );
5442 }
5443 assert!(cfg.auth.accounts.is_empty() && cfg.auth.auto.is_empty());
5444 }
5445
5446 #[test]
5447 fn unknown_auth_keys_warn_with_a_suggestion() {
5448 let cfg = load_config_from_str(
5449 "auth.acount.anthropic = work\nauth.remote_endpont = x\nauth.bogus = 1\n\
5450 auth.account.anthropic = work\nauth.machine_token = t\n",
5451 );
5452 let w = &cfg.warnings;
5453 assert!(
5454 w.contains(&"unknown key 'auth.acount.anthropic' (did you mean 'auth.account.anthropic'?)".into()),
5455 "{w:?}"
5456 );
5457 assert!(
5458 w.contains(&"unknown key 'auth.remote_endpont' (did you mean 'auth.remote_endpoint'?)".into()),
5459 "{w:?}"
5460 );
5461 assert!(w.contains(&"unknown key 'auth.bogus' — ignored".into()), "{w:?}");
5462 assert_eq!(w.iter().filter(|w| w.contains("auth.")).count(), 3, "{w:?}");
5463 assert_eq!(cfg.auth.accounts.get("anthropic").map(String::as_str), Some("work"));
5464 assert!(!cfg.auth.accounts.contains_key("acount.anthropic"));
5465 }
5466
5467 #[cfg(windows)]
5469 const ABSOLUTE_SAMPLE_PATH: &str = r"C:\opt\axel";
5470 #[cfg(not(windows))]
5471 const ABSOLUTE_SAMPLE_PATH: &str = "/opt/axel";
5472
5473 #[test]
5476 fn family_config_keys_are_real_parser_keys() {
5477 let sample = |key: &str| -> &'static str {
5478 match key {
5479 "events.auto_turn" => "on",
5480 "server.auto_approve_confirms" => "false",
5481 "auth.remote_endpoint" | "auth.machine_token" => "x",
5482 _ => "7",
5483 }
5484 };
5485 for key in FAMILY_CONFIG_KEYS.iter().filter(|k| !k.ends_with(".*")) {
5486 assert!(!KNOWN_CONFIG_KEYS.contains(key), "{key} is listed twice");
5487 let content = format!("{key} = {}\n", sample(key));
5488 let problems = config_content_problems(&content);
5489 assert!(problems.is_empty(), "{key}: {problems:?}");
5490 }
5491 }
5492
5493 #[test]
5494 #[serial]
5495 fn validate_config_edit_uses_the_real_parser_and_whole_file() {
5496 let home = make_test_home("validate-edit");
5497 let cfg = home.join(".synaps-cli/config");
5498 with_home(&home, || {
5499 assert!(validate_config_edit("cache_ttl", "1h").is_ok());
5501 assert!(validate_config_edit("cache_ttl", "auto").is_ok());
5502 assert!(validate_config_edit("cache_ttl", "2h").is_err());
5503 assert!(validate_config_edit("events.auto_turn_cap", "many").is_err());
5504 assert!(validate_config_edit("model", "a\nb").is_err());
5505
5506 std::fs::write(&cfg, "context_management.rollover_tokens = 100000\n").unwrap();
5508 assert!(validate_config_edit("context_management.pressure_tokens", "90000").is_ok());
5509 assert!(validate_config_edit("context_management.pressure_tokens", "150000").is_err());
5510 std::fs::write(&cfg, "subagent.max_total = 8\n").unwrap();
5511 assert!(validate_config_edit("subagent.max_concurrent", "8").is_ok());
5512 assert!(validate_config_edit("subagent.max_concurrent", "9").is_err());
5513 std::fs::write(&cfg, "").unwrap();
5516 for value in ["recall_once", "off"] {
5517 assert_eq!(
5518 validate_config_edit("memory.default_mode", value),
5519 Err(removed_automatic_memory_warning())
5520 );
5521 }
5522 for (key, value) in [
5523 ("memory.backend", "axel"),
5524 ("memory.user_scope", "true"),
5525 ("memory.axel.executable", ABSOLUTE_SAMPLE_PATH),
5526 ("memory.axel.brain", "relative/brain"),
5527 ] {
5528 assert_eq!(validate_config_edit(key, value), Err(removed_axel_backend_warning()));
5529 }
5530 assert_eq!(
5531 validate_config_edit("memory.backend", "legacy"),
5532 Err(removed_memory_backend_warning())
5533 );
5534
5535 std::fs::write(&cfg, "cache_ttl = forever\n").unwrap();
5537 assert!(validate_config_edit("max_fps", "120").is_ok());
5538 assert_eq!(std::fs::read_to_string(&cfg).unwrap(), "cache_ttl = forever\n");
5540 });
5541 let _ = std::fs::remove_dir_all(&home);
5542 }
5543}