Skip to main content

yapi_core/
settings.rs

1//! Settings: global `settings.json` merged with the trusted project's.
2//!
3//! Port of `packages/coding-agent/src/core/settings-manager.ts` in pi `v1.0.0`.
4//! Each scope is an order-preserving document. Edits re-read its file under the
5//! file's lock, change it and write it back the way pi does. Readers use the
6//! typed view of the merged document.
7
8use std::path::{Path, PathBuf};
9
10use serde::Deserialize as _;
11use serde_json::{Map, Value};
12use yapi_ai::auth::lock::{FileLock, write_atomic};
13use yapi_ai::http::is_truthy;
14use yapi_types::config::ConfigFile;
15use yapi_types::settings::{CacheWarming, Settings};
16
17use crate::config::PROJECT_DIR;
18
19/// Which file a setting lives in.
20#[derive(Clone, Copy, Debug, PartialEq, Eq)]
21pub enum Scope {
22    /// `<agent>/settings.json`.
23    Global,
24    /// `<project>/.yapi/settings.json`.
25    Project,
26}
27
28/// Settings errors.
29#[derive(Debug, thiserror::Error)]
30pub enum SettingsError {
31    /// The file could not be read or written.
32    #[error("{path}: {source}")]
33    Io {
34        /// The file.
35        path: PathBuf,
36        /// The cause.
37        source: std::io::Error,
38    },
39    /// The file is not valid settings JSON.
40    #[error("Failed to parse {path}: {message}")]
41    Parse {
42        /// The file.
43        path: PathBuf,
44        /// What is wrong.
45        message: String,
46    },
47}
48
49/// Both scopes and their merged view.
50#[derive(Clone, Debug, Default)]
51pub struct SettingsManager {
52    project_dir: PathBuf,
53    global_path: PathBuf,
54    project_path: PathBuf,
55    global: Map<String, Value>,
56    project: Map<String, Value>,
57    project_trusted: bool,
58    /// Both scopes merged as written, as pi's `settings`.
59    document: Map<String, Value>,
60    merged: Settings,
61    /// Files that failed to load, by scope. They read as empty and are never
62    /// written, so a broken file is not overwritten.
63    broken: [bool; 2],
64    errors: Vec<String>,
65}
66
67fn read_document(path: &Path) -> Result<Map<String, Value>, SettingsError> {
68    let text = match std::fs::read_to_string(path) {
69        Ok(text) => text,
70        Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(Map::new()),
71        Err(source) => {
72            return Err(SettingsError::Io {
73                path: path.to_path_buf(),
74                source,
75            });
76        }
77    };
78    let text = text.strip_prefix('\u{feff}').unwrap_or(&text);
79    if text.trim().is_empty() {
80        return Ok(Map::new());
81    }
82    match serde_json::from_str::<Value>(text) {
83        Ok(Value::Object(mut map)) => {
84            migrate(&mut map);
85            Ok(map)
86        }
87        Ok(_) => Err(SettingsError::Parse {
88            path: path.to_path_buf(),
89            message: "expected an object".into(),
90        }),
91        Err(err) => Err(SettingsError::Parse {
92            path: path.to_path_buf(),
93            message: err.to_string(),
94        }),
95    }
96}
97
98/// pi's `migrateSettings`: settings in older forms take their current
99/// ones in the document, which is written back that way the next time its
100/// scope is saved. Keys move as JavaScript moves them: a new key goes last
101/// and a reassigned one keeps its place.
102fn migrate(document: &mut Map<String, Value>) {
103    if !document.contains_key("steeringMode")
104        && let Some(mode) = document.shift_remove("queueMode")
105    {
106        document.insert("steeringMode".into(), mode);
107    }
108    if let Some(&Value::Bool(websockets)) = document.get("websockets")
109        && !document.contains_key("transport")
110    {
111        let transport = if websockets { "websocket" } else { "sse" };
112        document.insert("transport".into(), Value::from(transport));
113        document.shift_remove("websockets");
114    }
115    if let Some(Value::Object(skills)) = document.get("skills").cloned() {
116        if let Some(enable) = skills.get("enableSkillCommands")
117            && !document.contains_key("enableSkillCommands")
118        {
119            document.insert("enableSkillCommands".into(), enable.clone());
120        }
121        match skills.get("customDirectories") {
122            Some(Value::Array(directories)) if !directories.is_empty() => {
123                document.insert("skills".into(), Value::Array(directories.clone()));
124            }
125            _ => {
126                document.shift_remove("skills");
127            }
128        }
129    }
130    if let Some(Value::Object(retry)) = document.get_mut("retry") {
131        let provider = retry.get("provider").and_then(Value::as_object).cloned();
132        let delay_unset = provider
133            .as_ref()
134            .is_none_or(|provider| provider.get("maxRetryDelayMs").is_none_or(Value::is_null));
135        if let Some(delay) = retry.get("maxDelayMs").filter(|delay| delay.is_number())
136            && delay_unset
137        {
138            let mut provider = provider.unwrap_or_default();
139            provider.insert("maxRetryDelayMs".into(), delay.clone());
140            retry.insert("provider".into(), Value::Object(provider));
141        }
142        retry.shift_remove("maxDelayMs");
143    }
144}
145
146/// JavaScript's `String(value)` for a JSON value.
147pub fn js_string(value: &Value) -> String {
148    match value {
149        Value::String(text) => text.clone(),
150        Value::Array(items) => items
151            .iter()
152            .map(|item| match item {
153                Value::Null => String::new(),
154                item => js_string(item),
155            })
156            .collect::<Vec<_>>()
157            .join(","),
158        Value::Object(_) => "[object Object]".to_owned(),
159        other => yapi_types::json::stringify(other),
160    }
161}
162
163/// pi's deep merge: objects merge recursively, anything else replaces.
164fn deep_merge(base: &Map<String, Value>, overrides: &Map<String, Value>) -> Map<String, Value> {
165    let mut result = base.clone();
166    for (key, value) in overrides {
167        let merged = match (base.get(key), value) {
168            (Some(Value::Object(base)), Value::Object(over)) => {
169                Value::Object(deep_merge(base, over))
170            }
171            _ => value.clone(),
172        };
173        result.insert(key.clone(), merged);
174    }
175    result
176}
177
178fn is_tool_modifier(entry: &str) -> bool {
179    entry.starts_with('+') || entry.starts_with('-')
180}
181
182/// `defaultTools`: a list of plain names, or any other value, replaces the
183/// inherited one. A list of only `+name` and `-name` entries is appended to it.
184fn merge_default_tools(base: Option<&Value>, overrides: Option<&Value>) -> Option<Value> {
185    let Some(overrides) = overrides else {
186        return base.cloned();
187    };
188    match (base.and_then(Value::as_array), overrides.as_array()) {
189        (Some(base), Some(list))
190            if list
191                .iter()
192                .all(|entry| entry.as_str().is_some_and(is_tool_modifier)) =>
193        {
194            Some(Value::Array(base.iter().chain(list).cloned().collect()))
195        }
196        _ => Some(overrides.clone()),
197    }
198}
199
200/// pi's `resolveDefaultTools`: the plain names, or the default tools when a
201/// list has only modifiers, then each `+name` adds and `-name` removes a tool
202/// in list order.
203fn resolve_default_tools(entries: &[&str]) -> Vec<String> {
204    let mut tools: Vec<String> = entries
205        .iter()
206        .filter(|entry| !is_tool_modifier(entry))
207        .map(|&entry| entry.to_owned())
208        .collect();
209    if tools.is_empty() && !entries.is_empty() {
210        tools = crate::tools::DEFAULT_TOOLS.map(str::to_owned).to_vec();
211    }
212    for entry in entries.iter().filter(|entry| is_tool_modifier(entry)) {
213        let name = &entry[1..];
214        let index = tools.iter().position(|tool| tool == name);
215        match index {
216            None if entry.starts_with('+') && !name.is_empty() => tools.push(name.to_owned()),
217            Some(index) if entry.starts_with('-') => {
218                tools.remove(index);
219            }
220            _ => {}
221        }
222    }
223    tools
224}
225
226/// Whether [`Settings`] reads `value` at `path`.
227fn reads_at(path: &[String], value: &Value) -> bool {
228    let nested = path.iter().rev().fold(value.clone(), |inner, key| {
229        Value::Object(Map::from_iter([(key.clone(), inner)]))
230    });
231    Settings::deserialize(nested).is_ok()
232}
233
234/// The fields of `object`, found at `path` in the settings, that [`Settings`]
235/// reads: a field of the wrong type is left out alone, also inside an object,
236/// as pi reads each field on its own.
237fn valid_fields(path: &mut Vec<String>, object: Map<String, Value>) -> Map<String, Value> {
238    let mut valid = Map::new();
239    for (key, value) in object {
240        path.push(key.clone());
241        let kept = match value {
242            value if reads_at(path, &value) => Some(value),
243            Value::Object(inner) => {
244                Some(Value::Object(valid_fields(path, inner))).filter(|inner| reads_at(path, inner))
245            }
246            _ => None,
247        };
248        path.pop();
249        if let Some(value) = kept {
250            valid.insert(key, value);
251        }
252    }
253    valid
254}
255
256impl SettingsManager {
257    /// Loads both scopes, the project's from `project_dir`, and that only
258    /// when `project_trusted`.
259    pub fn load(agent_dir: &Path, project_dir: &Path, project_trusted: bool) -> SettingsManager {
260        let global_path = agent_dir.join(ConfigFile::Settings.file_name());
261        let project_path = project_dir
262            .join(PROJECT_DIR)
263            .join(ConfigFile::Settings.file_name());
264        let mut errors = Vec::new();
265        let mut broken = [false; 2];
266        // pi reports a file that fails to load and continues without it.
267        let mut read = |path: &Path, scope: usize| match read_document(path) {
268            Ok(document) => document,
269            Err(error) => {
270                let message = match error {
271                    SettingsError::Parse { message, .. } => message,
272                    SettingsError::Io { source, .. } => source.to_string(),
273                };
274                errors.push(format!(
275                    "Invalid settings file {}: {message}",
276                    path.display()
277                ));
278                broken[scope] = true;
279                Map::new()
280            }
281        };
282        let global = read(&global_path, 0);
283        let project = if project_trusted {
284            read(&project_path, 1)
285        } else {
286            Map::new()
287        };
288        let mut manager = SettingsManager {
289            project_dir: project_dir.to_path_buf(),
290            global_path,
291            project_path,
292            global,
293            project,
294            project_trusted,
295            document: Map::new(),
296            merged: Settings::default(),
297            broken,
298            errors,
299        };
300        manager.remerge();
301        manager
302    }
303
304    /// Settings with no files behind them.
305    pub fn in_memory() -> SettingsManager {
306        SettingsManager::default()
307    }
308
309    /// Why settings files failed to load, as pi's warnings word them.
310    pub fn errors(&self) -> &[String] {
311        &self.errors
312    }
313
314    fn remerge(&mut self) {
315        let mut merged = deep_merge(&self.global, &self.project);
316        if let Some(tools) = merge_default_tools(
317            self.global.get("defaultTools"),
318            self.project.get("defaultTools"),
319        ) {
320            merged.insert("defaultTools".into(), tools);
321        }
322        self.document = merged.clone();
323        // pi reads these switches by truthiness, so a value of another type
324        // keeps the rest of its object. `null` reads as unset.
325        for field in ["compaction", "retry"] {
326            if let Some(Value::Object(object)) = merged.get_mut(field)
327                && let Some(enabled) = object.get("enabled").filter(|value| !value.is_boolean())
328            {
329                match enabled {
330                    Value::Null => object.shift_remove("enabled"),
331                    value => object.insert("enabled".into(), is_truthy(value).into()),
332                };
333            }
334        }
335        // Unknown or invalid fields are ignored, as in pi.
336        self.merged = Settings::deserialize(Value::Object(merged.clone())).unwrap_or_else(|_| {
337            let valid = Value::Object(valid_fields(&mut Vec::new(), merged));
338            Settings::deserialize(valid).unwrap_or_default()
339        });
340    }
341
342    /// The merged settings.
343    pub fn settings(&self) -> &Settings {
344        &self.merged
345    }
346
347    /// pi's `getDefaultTools`: the tools the merged `defaultTools` enables, or
348    /// `None` when no scope sets it. A value that is not a list enables none,
349    /// and entries that are not strings are skipped.
350    pub fn default_tools(&self) -> Option<Vec<String>> {
351        let merged = merge_default_tools(
352            self.global.get("defaultTools"),
353            self.project.get("defaultTools"),
354        )?;
355        let entries: Vec<&str> = match &merged {
356            Value::Array(entries) => entries.iter().filter_map(Value::as_str).collect(),
357            _ => Vec::new(),
358        };
359        Some(resolve_default_tools(&entries))
360    }
361
362    /// Both scopes merged as written, values of any type included: pi's
363    /// `settings`.
364    pub fn merged_document(&self) -> &Map<String, Value> {
365        &self.document
366    }
367
368    /// `defaultThinkingLevel`, if set: a value that names no level becomes
369    /// `off`, the first level pi's clamp offers.
370    pub fn default_thinking_level(&self) -> Option<yapi_types::message::ThinkingLevel> {
371        let value = self
372            .project
373            .get("defaultThinkingLevel")
374            .or_else(|| self.global.get("defaultThinkingLevel"))?;
375        Some(
376            value
377                .as_str()
378                .and_then(yapi_types::message::ThinkingLevel::parse)
379                .unwrap_or(yapi_types::message::ThinkingLevel::Off),
380        )
381    }
382
383    /// pi's `getHttpIdleTimeoutMs`: `httpIdleTimeoutMs` in milliseconds, as a
384    /// number, a numeric string or `"disabled"` (0), five minutes when unset.
385    pub fn http_idle_timeout_ms(&self) -> Result<u64, String> {
386        Ok(self.timeout_ms("httpIdleTimeoutMs")?.unwrap_or(300_000))
387    }
388
389    /// pi's `getWebSocketConnectTimeoutMs`: `websocketConnectTimeoutMs`, read
390    /// as `httpIdleTimeoutMs` is, or `None` when unset.
391    pub fn websocket_connect_timeout_ms(&self) -> Result<Option<u64>, String> {
392        self.timeout_ms("websocketConnectTimeoutMs")
393    }
394
395    /// `retry.provider.timeoutMs` when it is a number, unchecked, as pi
396    /// passes it on to providers.
397    pub fn provider_timeout_ms(&self) -> Option<f64> {
398        let read = |document: &Map<String, Value>| {
399            document
400                .get("retry")?
401                .get("provider")?
402                .get("timeoutMs")?
403                .as_f64()
404        };
405        read(&self.project).or_else(|| read(&self.global))
406    }
407
408    /// pi's `parseTimeoutSetting` of setting `key`: milliseconds as a
409    /// number, a numeric string or `"disabled"` (0). `None` when unset, and
410    /// pi's error for any other value.
411    fn timeout_ms(&self, key: &str) -> Result<Option<u64>, String> {
412        let Some(value) = self.project.get(key).or_else(|| self.global.get(key)) else {
413            return Ok(None);
414        };
415        let number = match value {
416            Value::String(text) if text.trim().eq_ignore_ascii_case("disabled") => Some(0.0),
417            Value::String(text) if text.trim().is_empty() => None,
418            Value::String(text) => text.trim().parse::<f64>().ok(),
419            Value::Number(number) => number.as_f64(),
420            _ => None,
421        };
422        number
423            .filter(|ms| ms.is_finite() && *ms >= 0.0)
424            .map(|ms| Some(ms.floor() as u64))
425            .ok_or_else(|| format!("Invalid {key} setting: {}", js_string(value)))
426    }
427
428    /// pi's `getCompactionTokenSetting` checks: the error for a
429    /// `compaction` token setting, or `model`'s override of one, that is not
430    /// a non-negative safe integer.
431    pub fn compaction_error(&self, model: Option<&yapi_types::model::Model>) -> Option<String> {
432        const MAX_SAFE_INTEGER: f64 = 9_007_199_254_740_991.0;
433        let merged = deep_merge(&self.global, &self.project);
434        let compaction = merged.get("compaction");
435        let key = model.map(|model| format!("{}/{}", model.provider, model.id));
436        let entry = key
437            .as_ref()
438            .and_then(|key| compaction?.get("modelOverrides")?.get(key));
439        let invalid = |value: &&Value| {
440            !value
441                .as_f64()
442                .is_some_and(|n| n.fract() == 0.0 && (0.0..=MAX_SAFE_INTEGER).contains(&n))
443        };
444        let expected = "Expected a non-negative safe integer.";
445        for field in ["reserveTokens", "keepRecentTokens"] {
446            if let Some(value) = compaction.and_then(|c| c.get(field)).filter(invalid) {
447                let value = js_string(value);
448                return Some(format!(
449                    "Invalid compaction.{field} setting: {value}. {expected}"
450                ));
451            }
452            let (Some(key), Some(entry)) = (&key, entry) else {
453                continue;
454            };
455            let name = format!("compaction.modelOverrides[\"{key}\"]");
456            if !entry.is_object() {
457                let entry = js_string(entry);
458                return Some(format!(
459                    "Invalid {name} setting: {entry}. Expected an object."
460                ));
461            }
462            if let Some(value) = entry.get(field).filter(invalid) {
463                let value = js_string(value);
464                return Some(format!(
465                    "Invalid {name}.{field} setting: {value}. {expected}"
466                ));
467            }
468        }
469        None
470    }
471
472    /// pi's `getCacheWarmingMode`: `cacheWarming` from the global settings
473    /// only, because each refresh costs money, defaulting to `streaming` when
474    /// unset or invalid.
475    pub fn cache_warming_mode(&self) -> CacheWarming {
476        self.global
477            .get("cacheWarming")
478            .and_then(|mode| CacheWarming::deserialize(mode).ok())
479            .unwrap_or(CacheWarming::Streaming)
480    }
481
482    /// The directory whose `.yapi` folder holds the project's files: the
483    /// project's settings, resources and system prompt files.
484    pub fn project_dir(&self) -> &Path {
485        &self.project_dir
486    }
487
488    /// Whether the project scope is loaded.
489    pub fn project_trusted(&self) -> bool {
490        self.project_trusted
491    }
492
493    /// The settings document of `scope` as written, empty for an untrusted
494    /// project.
495    pub fn document(&self, scope: Scope) -> &Map<String, Value> {
496        match scope {
497            Scope::Global => &self.global,
498            Scope::Project => &self.project,
499        }
500    }
501
502    /// Sets a top-level key in a scope and writes that file. `None` removes it.
503    pub fn set(
504        &mut self,
505        scope: Scope,
506        key: &str,
507        value: Option<Value>,
508    ) -> Result<(), SettingsError> {
509        self.update(scope, |document| match &value {
510            Some(value) => {
511                document.insert(key.to_owned(), value.clone());
512            }
513            None => {
514                document.shift_remove(key);
515            }
516        })
517    }
518
519    /// Sets `key` inside the object setting `field` of `scope`, keeping its
520    /// other keys, and writes that scope's file. `None` removes the key and
521    /// keeps the object.
522    pub fn set_nested(
523        &mut self,
524        scope: Scope,
525        field: &str,
526        key: &str,
527        value: Option<Value>,
528    ) -> Result<(), SettingsError> {
529        self.update(scope, |document| {
530            let mut object = match document.get(field) {
531                Some(Value::Object(object)) => object.clone(),
532                _ => Map::new(),
533            };
534            match &value {
535                Some(value) => object.insert(key.to_owned(), value.clone()),
536                None => object.shift_remove(key),
537            };
538            document.insert(field.to_owned(), Value::Object(object));
539        })
540    }
541
542    /// Applies `change` to `scope` and writes its file as pi does: under the
543    /// file's lock, to the file as it is now, so keys other processes changed
544    /// since loading are kept. The scope then holds the file's document.
545    fn update(
546        &mut self,
547        scope: Scope,
548        change: impl Fn(&mut Map<String, Value>),
549    ) -> Result<(), SettingsError> {
550        let (document, path, broken) = match scope {
551            Scope::Global => (&mut self.global, &self.global_path, self.broken[0]),
552            Scope::Project => (&mut self.project, &self.project_path, self.broken[1]),
553        };
554        let mut written = None;
555        if !path.as_os_str().is_empty() && !broken {
556            if let Some(dir) = path.parent() {
557                std::fs::create_dir_all(dir).map_err(|source| SettingsError::Io {
558                    path: dir.to_path_buf(),
559                    source,
560                })?;
561            }
562            let failed = |source: std::io::Error| SettingsError::Io {
563                path: path.clone(),
564                source,
565            };
566            let _lock = FileLock::acquire_blocking(path)
567                .map_err(|err| failed(std::io::Error::other(err)))?;
568            let mut current = read_document(path)?;
569            change(&mut current);
570            let text = ConfigFile::Settings
571                .render(&Value::Object(current.clone()))
572                .map_err(|err| SettingsError::Parse {
573                    path: path.clone(),
574                    message: err.to_string(),
575                })?;
576            write_atomic(path, text.as_bytes(), 0o666).map_err(failed)?;
577            written = Some(current);
578        }
579        match written {
580            // An untrusted project's other settings stay unloaded.
581            Some(current) if scope == Scope::Global || self.project_trusted => *document = current,
582            _ => change(document),
583        }
584        self.remerge();
585        Ok(())
586    }
587}
588
589#[cfg(test)]
590mod tests {
591    use super::*;
592    use serde_json::json;
593
594    #[test]
595    fn merges_like_pi() {
596        let global = json!({"compaction": {"enabled": true, "reserveTokens": 100}, "defaultTools": ["read", "bash"], "theme": "dark"});
597        let project =
598            json!({"compaction": {"reserveTokens": 5}, "defaultTools": ["+grep", "-bash"]});
599        let mut merged = deep_merge(global.as_object().unwrap(), project.as_object().unwrap());
600        merged.insert(
601            "defaultTools".into(),
602            merge_default_tools(global.get("defaultTools"), project.get("defaultTools")).unwrap(),
603        );
604        assert_eq!(
605            Value::Object(merged),
606            json!({"compaction": {"enabled": true, "reserveTokens": 5}, "defaultTools": ["read", "bash", "+grep", "-bash"], "theme": "dark"})
607        );
608        assert_eq!(
609            merge_default_tools(Some(&json!(["read"])), Some(&json!(["ls"]))),
610            Some(json!(["ls"]))
611        );
612        // pi's getDefaultTools resolves the merged list.
613        let resolved = |global: &Value, project: &Value| {
614            let mut manager = SettingsManager::in_memory();
615            manager.global = Map::from_iter([("defaultTools".to_owned(), global.clone())]);
616            manager.project = Map::from_iter([("defaultTools".to_owned(), project.clone())]);
617            manager.default_tools().unwrap()
618        };
619        let (global, project) = (&global["defaultTools"], &project["defaultTools"]);
620        assert_eq!(resolved(global, project), ["read", "grep"]);
621        let plain = json!(["read", "+ls"]);
622        assert_eq!(resolved(&plain, &json!(["+find"])), ["read", "ls", "find"]);
623        let modifiers = json!(["-bash"]);
624        assert_eq!(
625            resolved(&modifiers, &json!(["+grep"])),
626            ["read", "edit", "write", "grep"]
627        );
628        // An empty list, or a value that is not a list, enables no tool.
629        assert!(resolved(&json!([]), &json!([])).is_empty());
630        assert!(resolved(&plain, &json!("read")).is_empty());
631        assert_eq!(SettingsManager::in_memory().default_tools(), None);
632    }
633
634    /// pi's legacy setting forms, migrated as pi writes them back.
635    #[test]
636    fn migrates_like_pi() {
637        let path = Path::new(env!("CARGO_MANIFEST_DIR"))
638            .join("../../tests/fixtures/pi/settings/migrations.json");
639        let cases: Vec<Value> =
640            serde_json::from_str(&std::fs::read_to_string(path).unwrap()).unwrap();
641        let dir =
642            std::env::temp_dir().join(format!("yapi-settings-migrate-{}", std::process::id()));
643        std::fs::create_dir_all(&dir).unwrap();
644        let file = dir.join("settings.json");
645        for case in cases {
646            std::fs::write(&file, case["input"].as_str().unwrap()).unwrap();
647            let mut manager = SettingsManager::load(&dir, &dir.join("cwd"), false);
648            manager
649                .set(Scope::Global, "lastChangelogVersion", Some(json!("1.0.0")))
650                .unwrap();
651            assert_eq!(
652                std::fs::read_to_string(&file).unwrap(),
653                case["output"].as_str().unwrap(),
654                "{}",
655                case["name"]
656            );
657        }
658        std::fs::remove_dir_all(&dir).unwrap();
659    }
660
661    #[tokio::test]
662    async fn invalid_timeouts_fail() {
663        let mut manager = SettingsManager::in_memory();
664        manager.global =
665            json!({"httpIdleTimeoutMs": " 1500.7 ", "websocketConnectTimeoutMs": "soon"})
666                .as_object()
667                .unwrap()
668                .clone();
669        assert_eq!(manager.http_idle_timeout_ms(), Ok(1500));
670        assert_eq!(
671            manager.websocket_connect_timeout_ms(),
672            Err("Invalid websocketConnectTimeoutMs setting: soon".to_owned())
673        );
674        manager.global = json!({"httpIdleTimeoutMs": -1, "websocketConnectTimeoutMs": "disabled"})
675            .as_object()
676            .unwrap()
677            .clone();
678        assert_eq!(
679            manager.http_idle_timeout_ms(),
680            Err("Invalid httpIdleTimeoutMs setting: -1".to_owned())
681        );
682        assert_eq!(manager.websocket_connect_timeout_ms(), Ok(Some(0)));
683        manager.global = Map::new();
684        assert_eq!(manager.http_idle_timeout_ms(), Ok(300_000));
685        assert_eq!(manager.websocket_connect_timeout_ms(), Ok(None));
686        assert_eq!(manager.compaction_error(None), None);
687        // pi's compaction getters take whole numbers only.
688        manager.global = json!({"compaction": {"reserveTokens": 1.5e5, "keepRecentTokens": 1.5}})
689            .as_object()
690            .unwrap()
691            .clone();
692        let error = "Invalid compaction.keepRecentTokens setting: 1.5. Expected a non-negative safe integer.";
693        assert_eq!(manager.compaction_error(None).as_deref(), Some(error));
694
695        // As pi's next-turn hook throws it, the run ends between turns with a
696        // failed response. As its check before a prompt throws it, the next
697        // prompt fails before its request.
698        use yapi_ai::faux::{Faux, Response};
699        use yapi_types::message::{Message, StopReason};
700        let faux = Faux::new([
701            Response::tool_call("t", "missing", json!({})),
702            Response::text("unused"),
703        ]);
704        let model: yapi_types::model::Model = serde_json::from_value(json!({
705            "id": "m", "name": "M", "api": "faux", "provider": "p", "baseUrl": "",
706            "reasoning": false, "input": ["text"],
707            "cost": {"input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0},
708            "contextWindow": 1000, "maxTokens": 100,
709        }))
710        .unwrap();
711        let mut registry = yapi_ai::registry::ModelRegistry::builtin();
712        registry.register_provider("p", vec![model.clone()]);
713        registry.set_runtime_key("p", "key".into());
714        let mut apis = yapi_ai::api::Apis::default();
715        apis.register(std::sync::Arc::new(faux.clone()));
716        let session =
717            crate::agent_session::AgentSession::new(crate::agent_session::SessionConfig {
718                cwd: "/work".into(),
719                agent_dir: "/agent".into(),
720                settings: manager,
721                registry,
722                apis,
723                session: crate::session::SessionManager::in_memory(Path::new("/work")),
724                model: Some(model),
725                thinking_level: yapi_types::message::ThinkingLevel::Off,
726                tools: Vec::new(),
727                extensions: Vec::new(),
728                include_extension_tools: false,
729                allowed_tools: None,
730                excluded_tools: Vec::new(),
731                resources: crate::agent_session::Resources::default(),
732                docs: crate::docs::Locations::default(),
733            });
734        assert_eq!(session.prompt("go", Vec::new()).await, Err(error.into()));
735        let Some(Message::Assistant(failed)) = session.messages().pop() else {
736            panic!("no failed response");
737        };
738        assert_eq!(
739            (failed.stop_reason, failed.error_message.as_deref()),
740            (StopReason::Error, Some(error))
741        );
742        assert_eq!(session.prompt("again", Vec::new()).await, Err(error.into()));
743        assert_eq!(faux.requests().len(), 1);
744    }
745
746    #[test]
747    fn invalid_fields_keep_valid() {
748        let mut manager = SettingsManager::in_memory();
749        manager.global = json!({
750            "theme": "dark",
751            "retry": "often",
752            "steeringMode": "all",
753            "markdown": {"codeBlockIndent": "    ", "mermaid": "bogus"},
754            "compaction": {"enabled": "", "reserveTokens": "16384", "keepRecentTokens": 2e4},
755            "branchSummary": {"skipPrompt": true, "reserveTokens": 1.5e5},
756            "modelThinkingLevels": {"a/b": "high", "a/c": "extreme"},
757            "terminal": {"imageWidthCells": 40.7},
758            "outputPad": 0.5,
759        })
760        .as_object()
761        .unwrap()
762        .clone();
763        manager.remerge();
764        let settings = manager.settings();
765        assert_eq!(settings.theme.as_deref(), Some("dark"));
766        assert!(settings.retry.is_none());
767        // A wrongly typed nested field drops alone, and pi reads a switch of
768        // another type by truthiness.
769        let compaction = settings.compaction.as_ref().unwrap();
770        let tokens = (compaction.reserve_tokens, compaction.keep_recent_tokens);
771        assert_eq!(
772            (compaction.enabled, tokens),
773            (Some(false), (None, Some(20000)))
774        );
775        // A whole float is its integer. pi rounds a fraction down where it
776        // counts cells, and reads any output padding but 0 as 1.
777        let summary = settings.branch_summary.as_ref().unwrap();
778        assert_eq!(
779            (summary.skip_prompt, summary.reserve_tokens),
780            (Some(true), Some(150_000))
781        );
782        let terminal = settings.terminal.as_ref().unwrap();
783        assert_eq!(
784            (terminal.image_width_cells, settings.output_pad),
785            (Some(40), None)
786        );
787        assert_eq!(settings.model_thinking_levels.as_ref().unwrap().len(), 1);
788        assert_eq!(
789            settings.steering_mode,
790            Some(yapi_types::settings::QueueMode::All)
791        );
792        // pi reads a mermaid mode it does not know as streaming.
793        assert_eq!(
794            settings.markdown,
795            Some(yapi_types::settings::MarkdownSettings {
796                code_block_indent: Some("    ".into()),
797                mermaid: Some(yapi_types::settings::MermaidMode::Streaming),
798            })
799        );
800    }
801
802    #[test]
803    fn cache_warming_global_only() {
804        let mut manager = SettingsManager::in_memory();
805        assert_eq!(manager.cache_warming_mode(), CacheWarming::Streaming);
806        manager.project = json!({"cacheWarming": "off"}).as_object().unwrap().clone();
807        manager.remerge();
808        assert_eq!(manager.cache_warming_mode(), CacheWarming::Streaming);
809        manager.global = json!({"cacheWarming": "idle"}).as_object().unwrap().clone();
810        assert_eq!(manager.cache_warming_mode(), CacheWarming::Idle);
811        manager.global = json!({"cacheWarming": "often"})
812            .as_object()
813            .unwrap()
814            .clone();
815        assert_eq!(manager.cache_warming_mode(), CacheWarming::Streaming);
816    }
817
818    /// Two processes that loaded the same file keep each other's changes.
819    #[test]
820    fn concurrent_writes_keep_keys() {
821        let dir = std::env::temp_dir().join(format!("yapi-settings-two-{}", std::process::id()));
822        let _ = std::fs::remove_dir_all(&dir);
823        std::fs::create_dir_all(&dir).unwrap();
824        let mut first = SettingsManager::load(&dir, &dir.join("cwd"), false);
825        let mut second = SettingsManager::load(&dir, &dir.join("cwd"), false);
826        second
827            .set(Scope::Global, "packages", Some(json!(["npm:foo"])))
828            .unwrap();
829        first
830            .set(Scope::Global, "defaultModel", Some(json!("m")))
831            .unwrap();
832        first
833            .set_nested(Scope::Global, "retry", "enabled", Some(json!(false)))
834            .unwrap();
835        assert_eq!(
836            std::fs::read_to_string(dir.join("settings.json")).unwrap(),
837            "{\n  \"packages\": [\n    \"npm:foo\"\n  ],\n  \"defaultModel\": \"m\",\n  \"retry\": {\n    \"enabled\": false\n  }\n}"
838        );
839        assert_eq!(first.document(Scope::Global).len(), 3);
840        assert!(!dir.join("settings.json.lock").exists());
841        std::fs::remove_dir_all(&dir).unwrap();
842    }
843}