Skip to main content

kiss_coding/
settings.rs

1//! Settings: global `~/.kiss/agent/settings.json` deep-merged with project
2//! `.kiss/settings.json` (project wins).
3
4use crate::decision::DecisionModel;
5use kiss_ai::Transport;
6use serde::{Deserialize, Serialize};
7use serde_json::{Map, Value};
8use std::collections::BTreeMap;
9use std::path::{Path, PathBuf};
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
12#[serde(rename_all = "lowercase")]
13pub enum CompactionMode {
14    #[default]
15    Summary,
16    Jev,
17    #[serde(rename = "d1.1", alias = "d1")]
18    D1,
19    #[serde(rename = "d1-ultra")]
20    D1Ultra,
21    Clef,
22    #[serde(rename = "clef-flash")]
23    ClefFlash,
24}
25
26/// A setting that is either off or names a decision model, in a fixed
27/// cycling order (the compaction and reasoning-effort modes).
28pub trait DecisionMode: Copy + PartialEq + 'static {
29    const ALL: &'static [Self];
30
31    fn decision_model(self) -> Option<DecisionModel>;
32
33    /// The next mode after this one whose decision model is available. The
34    /// off mode is always available. `None` when no other mode is.
35    fn next_available(self, available: impl Fn(DecisionModel) -> bool) -> Option<Self> {
36        let index = Self::ALL.iter().position(|mode| *mode == self)?;
37        Self::ALL
38            .iter()
39            .cycle()
40            .skip(index + 1)
41            .take(Self::ALL.len() - 1)
42            .copied()
43            .find(|mode| mode.decision_model().is_none_or(&available))
44    }
45}
46
47impl DecisionMode for CompactionMode {
48    const ALL: &'static [Self] = &[
49        Self::Summary,
50        Self::Jev,
51        Self::D1Ultra,
52        Self::D1,
53        Self::ClefFlash,
54        Self::Clef,
55    ];
56
57    fn decision_model(self) -> Option<DecisionModel> {
58        match self {
59            Self::Summary => None,
60            Self::Jev => Some(DecisionModel::Jev),
61            Self::D1 => Some(DecisionModel::D1),
62            Self::D1Ultra => Some(DecisionModel::D1Ultra),
63            Self::Clef => Some(DecisionModel::Clef),
64            Self::ClefFlash => Some(DecisionModel::ClefFlash),
65        }
66    }
67}
68
69#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
70#[serde(rename_all = "camelCase", default)]
71pub struct CompactionSettings {
72    pub enabled: bool,
73    pub mode: CompactionMode,
74    pub reserve_tokens: u64,
75    pub keep_recent_tokens: u64,
76    pub model_overrides: BTreeMap<String, CompactionModelOverride>,
77}
78
79#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
80#[serde(rename_all = "camelCase", default)]
81pub struct CompactionModelOverride {
82    pub reserve_tokens: Option<u64>,
83    pub keep_recent_tokens: Option<u64>,
84}
85
86impl CompactionSettings {
87    fn model_override(&self, model: &kiss_ai::Model) -> Option<&CompactionModelOverride> {
88        self.model_overrides
89            .get(&format!("{}/{}", model.provider, model.id))
90    }
91
92    /// Tokens to keep free for the reply, with the model's override.
93    pub fn reserve_tokens_for(&self, model: &kiss_ai::Model) -> u64 {
94        self.model_override(model)
95            .and_then(|value| value.reserve_tokens)
96            .unwrap_or(self.reserve_tokens)
97    }
98
99    /// Recent tokens a compaction keeps verbatim, with the model's override.
100    pub fn keep_recent_tokens_for(&self, model: &kiss_ai::Model) -> u64 {
101        self.model_override(model)
102            .and_then(|value| value.keep_recent_tokens)
103            .unwrap_or(self.keep_recent_tokens)
104    }
105}
106
107impl Default for CompactionSettings {
108    fn default() -> Self {
109        CompactionSettings {
110            enabled: true,
111            mode: CompactionMode::Summary,
112            reserve_tokens: 16_384,
113            keep_recent_tokens: 20_000,
114            model_overrides: BTreeMap::new(),
115        }
116    }
117}
118
119#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
120#[serde(rename_all = "lowercase")]
121pub enum ReasoningEffortMode {
122    #[default]
123    Fixed,
124    Jev,
125    #[serde(rename = "d1.1", alias = "d1")]
126    D1,
127    #[serde(rename = "d1-ultra")]
128    D1Ultra,
129    Clef,
130    #[serde(rename = "clef-flash")]
131    ClefFlash,
132}
133
134impl DecisionMode for ReasoningEffortMode {
135    const ALL: &'static [Self] = &[
136        Self::Fixed,
137        Self::Jev,
138        Self::D1Ultra,
139        Self::D1,
140        Self::ClefFlash,
141        Self::Clef,
142    ];
143
144    fn decision_model(self) -> Option<DecisionModel> {
145        match self {
146            Self::Fixed => None,
147            Self::Jev => Some(DecisionModel::Jev),
148            Self::D1 => Some(DecisionModel::D1),
149            Self::D1Ultra => Some(DecisionModel::D1Ultra),
150            Self::Clef => Some(DecisionModel::Clef),
151            Self::ClefFlash => Some(DecisionModel::ClefFlash),
152        }
153    }
154}
155
156#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
157#[serde(rename_all = "camelCase", default)]
158pub struct ReasoningEffortSettings {
159    pub mode: ReasoningEffortMode,
160}
161
162#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
163#[serde(rename_all = "camelCase", default)]
164pub struct RetrySettings {
165    pub enabled: bool,
166    pub max_retries: u32,
167    pub base_delay_ms: u64,
168    pub max_agent_delay_ms: u64,
169}
170
171impl Default for RetrySettings {
172    fn default() -> Self {
173        RetrySettings {
174            enabled: true,
175            max_retries: 3,
176            base_delay_ms: 2000,
177            max_agent_delay_ms: 60_000,
178        }
179    }
180}
181
182#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
183#[serde(rename_all = "camelCase", default)]
184pub struct SubagentSettings {
185    /// Subagent tools are hidden until the user enables them.
186    pub enabled: bool,
187}
188
189/// How many agents to advise the model to aim for when it writes a workflow.
190///
191/// This is advice sent to the model, not a cap: a prompt that clearly calls for
192/// a different scale still wins. The runtime limits are the real bound.
193#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
194#[serde(rename_all = "lowercase")]
195pub enum WorkflowSize {
196    Small,
197    #[default]
198    Medium,
199    Large,
200    Unrestricted,
201}
202
203impl WorkflowSize {
204    pub fn as_str(self) -> &'static str {
205        match self {
206            Self::Small => "small",
207            Self::Medium => "medium",
208            Self::Large => "large",
209            Self::Unrestricted => "unrestricted",
210        }
211    }
212
213    /// The agent count the model is asked to stay under, if any.
214    pub fn target_agents(self) -> Option<u32> {
215        match self {
216            Self::Small => Some(5),
217            Self::Medium => Some(15),
218            Self::Large => Some(50),
219            Self::Unrestricted => None,
220        }
221    }
222}
223
224#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
225#[serde(rename_all = "camelCase", default)]
226pub struct WorkflowSettings {
227    /// Dynamic workflows are built on subagents, so they are available only
228    /// when `subagents.enabled` is also on.
229    pub enabled: bool,
230    /// Ask before a run starts. This is a cost gate, not a permission gate: one
231    /// run can start hundreds of child agents.
232    pub confirm: bool,
233    /// Let a typed prompt containing `ultracode`, or asking for a workflow in
234    /// plain words, start one.
235    pub keyword_trigger: bool,
236    pub size: WorkflowSize,
237}
238
239impl Default for WorkflowSettings {
240    fn default() -> Self {
241        WorkflowSettings {
242            enabled: true,
243            confirm: true,
244            keyword_trigger: true,
245            size: WorkflowSize::default(),
246        }
247    }
248}
249
250#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
251#[serde(rename_all = "kebab-case")]
252pub enum QueueMode {
253    All,
254    #[default]
255    OneAtATime,
256}
257
258#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
259#[serde(rename_all = "lowercase")]
260pub enum ProjectTrustDefault {
261    #[default]
262    Ask,
263    Always,
264    Never,
265}
266
267#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
268#[serde(rename_all = "lowercase")]
269pub enum CacheWarmingMode {
270    Off,
271    Streaming,
272    #[default]
273    Idle,
274}
275
276#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
277#[serde(rename_all = "lowercase")]
278pub enum MermaidRendering {
279    Off,
280    Final,
281    #[default]
282    Streaming,
283}
284
285impl MermaidRendering {
286    pub fn as_str(self) -> &'static str {
287        match self {
288            Self::Off => "off",
289            Self::Final => "final",
290            Self::Streaming => "streaming",
291        }
292    }
293}
294
295#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
296#[serde(rename_all = "camelCase", default)]
297pub struct MarkdownSettings {
298    pub code_block_indent: Option<String>,
299    pub mermaid: MermaidRendering,
300}
301
302#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
303#[serde(rename_all = "camelCase", default)]
304pub struct WebMcpSettings {
305    pub allowed_origins: Option<Vec<String>>,
306    pub disallowed_origins: Vec<String>,
307    pub cdp: Option<Value>,
308}
309
310/// A Claude Code-compatible custom status line: an external command that
311/// receives session state as JSON on stdin and prints the footer to stdout.
312#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
313#[serde(rename_all = "camelCase", default)]
314pub struct StatusLineSettings {
315    /// Only "command" is recognized; other types are ignored.
316    pub r#type: String,
317    pub command: String,
318    /// Leading spaces prepended to every output line.
319    pub padding: Option<usize>,
320    /// Minimum milliseconds between command runs.
321    pub debounce_ms: Option<u64>,
322    /// Milliseconds after which a run is killed and its output dropped.
323    pub timeout_ms: Option<u64>,
324}
325
326impl StatusLineSettings {
327    /// The command to run, when this configuration is usable.
328    pub fn command(&self) -> Option<&str> {
329        (self.r#type == "command" && !self.command.trim().is_empty())
330            .then_some(self.command.as_str())
331    }
332
333    pub fn padding(&self) -> usize {
334        self.padding.unwrap_or(0)
335    }
336
337    pub fn debounce(&self) -> std::time::Duration {
338        std::time::Duration::from_millis(self.debounce_ms.unwrap_or(300))
339    }
340
341    pub fn timeout(&self) -> std::time::Duration {
342        std::time::Duration::from_millis(self.timeout_ms.unwrap_or(5_000))
343    }
344}
345
346/// Where `/share` uploads a session.
347#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
348#[serde(rename_all = "lowercase")]
349pub enum SessionShareDestination {
350    /// Radius when signed in, otherwise a secret GitHub gist.
351    #[default]
352    Auto,
353    Gist,
354    Radius,
355}
356
357#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
358#[serde(rename_all = "camelCase", default)]
359pub struct Settings {
360    pub default_provider: Option<String>,
361    pub default_model: Option<String>,
362    pub default_thinking_level: Option<String>,
363    pub transport: Transport,
364    pub hide_thinking_block: bool,
365    pub theme: Option<String>,
366    pub quiet_startup: bool,
367    /// User-only opt-in for interactive prompt-completion desktop notifications.
368    pub desktop_notifications: bool,
369    /// User-only argv that `kiss update` runs instead of the KISS installer.
370    pub self_update_command: Option<Vec<String>>,
371    pub default_project_trust: ProjectTrustDefault,
372    pub compaction: CompactionSettings,
373    /// Let the model edit its live conversation through a private JSON file.
374    pub experimental_context_file: bool,
375    pub reasoning_effort: ReasoningEffortSettings,
376    pub retry: RetrySettings,
377    pub cache_warming: CacheWarmingMode,
378    pub subagents: SubagentSettings,
379    /// `provider/model[:thinking]` the `advisor` tool asks. User settings only.
380    pub advisor_model: Option<String>,
381    /// Advisor calls allowed per user prompt (default 3). User settings only.
382    pub advisor_max_uses: Option<u32>,
383    pub workflows: WorkflowSettings,
384    pub steering_mode: QueueMode,
385    pub follow_up_mode: QueueMode,
386    pub shell_path: Option<String>,
387    pub shell_command_prefix: Option<String>,
388    pub session_dir: Option<String>,
389    pub session_share_destination: SessionShareDestination,
390    pub enabled_models: Option<Vec<String>>,
391    pub default_tools: Option<Vec<String>>,
392    pub external_editor: Option<String>,
393    pub skills: Vec<String>,
394    pub prompts: Vec<String>,
395    pub themes: Vec<String>,
396    pub enable_skill_commands: Option<bool>,
397    /// `None` preserves the default-on behavior for existing settings files.
398    pub auto_recap: Option<bool>,
399    pub voice_language: Option<String>,
400    pub voice_backend: Option<String>,
401    pub markdown: MarkdownSettings,
402    pub webmcp: WebMcpSettings,
403    /// Replace the footer with the output of an external command.
404    pub status_line: Option<StatusLineSettings>,
405    /// Unknown keys survive load/save.
406    #[serde(flatten)]
407    pub extra: serde_json::Map<String, Value>,
408    /// These settings as loaded, so a save writes only what changed since.
409    /// Project values and run-only overrides are part of it, so they never
410    /// reach the user's file unless the user changes them.
411    /// Public only so that struct literals with `..Default::default()` keep
412    /// working; `load` and `save_global` own it.
413    #[serde(skip)]
414    #[doc(hidden)]
415    pub baseline: Value,
416    /// What `load` could not read and ignored, for the caller to show.
417    /// Public for the same reason as `baseline`.
418    #[serde(skip)]
419    #[doc(hidden)]
420    pub problems: Vec<String>,
421}
422
423pub fn global_settings_path() -> Option<PathBuf> {
424    kiss_ai::paths::agent_dir().map(|dir| dir.join("settings.json"))
425}
426
427pub fn project_settings_path(cwd: &Path) -> PathBuf {
428    cwd.join(".kiss/settings.json")
429}
430
431fn read_json(path: &Path, problems: &mut Vec<String>) -> Option<Value> {
432    let text = std::fs::read_to_string(path).ok()?;
433    serde_json::from_str(&text)
434        .map_err(|error| {
435            problems.push(format!(
436                "{} is not valid JSON ({error}), so kiss ignores all of it. Correct the file and restart kiss.",
437                path.display()
438            ));
439        })
440        .ok()
441}
442
443fn merged_settings(mut global: Value, project: Option<Value>) -> Settings {
444    let desktop_notifications = global
445        .get("desktopNotifications")
446        .and_then(Value::as_bool)
447        .unwrap_or(false);
448    // A project must not choose a command that `kiss update` runs.
449    let self_update_command = global
450        .get("selfUpdateCommand")
451        .cloned()
452        .and_then(|value| serde_json::from_value(value).ok());
453    let experimental_context_file = global
454        .get("experimentalContextFile")
455        .and_then(Value::as_bool)
456        .unwrap_or(false);
457    let advisor_model = global
458        .get("advisorModel")
459        .and_then(Value::as_str)
460        .map(str::to_owned);
461    let advisor_max_uses = global
462        .get("advisorMaxUses")
463        .and_then(Value::as_u64)
464        .and_then(|uses| u32::try_from(uses).ok());
465    let subagents_enabled = global
466        .pointer("/subagents/enabled")
467        .and_then(Value::as_bool)
468        .unwrap_or(false);
469    let workflows_enabled = global
470        .pointer("/workflows/enabled")
471        .and_then(Value::as_bool)
472        .unwrap_or(true);
473    // A project must not opt the user's microphone in to a cloud service.
474    let voice_backend = global
475        .get("voiceBackend")
476        .and_then(Value::as_str)
477        .map(str::to_owned);
478    let cache_warming = global
479        .get("cacheWarming")
480        .cloned()
481        .and_then(|value| serde_json::from_value(value).ok())
482        .unwrap_or_default();
483    if let Some(mut project) = project {
484        if let Some(object) = project.as_object_mut() {
485            object.remove("deviceId");
486        }
487        if let Some(entries) = project.get("defaultTools").and_then(Value::as_array)
488            && entries.iter().all(|entry| {
489                entry
490                    .as_str()
491                    .is_some_and(|name| name.starts_with(['+', '-']))
492            })
493            && let Some(base) = global.get("defaultTools").and_then(Value::as_array)
494        {
495            let mut combined = base.clone();
496            combined.extend(entries.iter().cloned());
497            project["defaultTools"] = Value::Array(combined);
498        }
499        kiss_ai::config_file::deep_merge(&mut global, project);
500    }
501    let mut problems = Vec::new();
502    // One value of the wrong type must not reset every other setting: drop
503    // only the keys that cannot be read.
504    if serde_json::from_value::<Settings>(global.clone()).is_err()
505        && let Some(object) = global.as_object_mut()
506    {
507        object.retain(|key, value| {
508            let readable = serde_json::from_value::<Settings>(Value::Object(Map::from_iter([(
509                key.clone(),
510                value.clone(),
511            )])))
512            .is_ok();
513            if !readable {
514                problems.push(format!(
515                    "the setting `{key}` has a value kiss cannot read, so kiss uses its default. Correct `{key}` in {} or the project's .kiss/settings.json.",
516                    global_settings_path().map_or_else(|| "settings.json".into(), |path| path.display().to_string())
517                ));
518            }
519            readable
520        });
521    }
522    let mut settings: Settings = serde_json::from_value(global).unwrap_or_default();
523    settings.problems = problems;
524    // Subagents are a user-level authority choice. A repository must not turn
525    // them on through a trusted project settings file.
526    settings.subagents.enabled = subagents_enabled;
527    // The advisor receives the whole transcript and spends tokens with a
528    // provider the user chose, so a repository must not choose it.
529    settings.advisor_model = advisor_model;
530    settings.advisor_max_uses = advisor_max_uses;
531    settings.experimental_context_file = experimental_context_file;
532    // Workflows spend the user's tokens many agents at a time, so the same rule
533    // applies: only the user's own settings file decides whether they are on.
534    settings.workflows.enabled = workflows_enabled;
535    settings.cache_warming = cache_warming;
536    settings.voice_backend = voice_backend;
537    settings.desktop_notifications = desktop_notifications;
538    settings.self_update_command = self_update_command;
539    settings
540}
541
542/// Write into `target` every leaf where `new` differs from `old`, and remove
543/// the keys that `new` no longer has. Keys that did not change keep the
544/// value `target` already holds.
545fn apply_changes(
546    target: &mut Map<String, Value>,
547    old: &Map<String, Value>,
548    new: &Map<String, Value>,
549) {
550    for (key, value) in new {
551        match (old.get(key), value) {
552            (Some(previous), _) if previous == value => {}
553            (Some(Value::Object(previous)), Value::Object(value)) => {
554                let slot = target
555                    .entry(key.clone())
556                    .or_insert_with(|| Value::Object(Map::new()));
557                if !slot.is_object() {
558                    *slot = Value::Object(Map::new());
559                }
560                apply_changes(slot.as_object_mut().unwrap(), previous, value);
561            }
562            _ => {
563                target.insert(key.clone(), value.clone());
564            }
565        }
566    }
567    for key in old.keys() {
568        if !new.contains_key(key) {
569            target.remove(key);
570        }
571    }
572}
573
574impl Settings {
575    /// Read only user settings when creating the installation ID.
576    pub fn get_or_create_device_id() -> anyhow::Result<String> {
577        let path = global_settings_path().ok_or_else(|| anyhow::anyhow!(
578            "ChatGPT login has no user settings path. A home directory is required. Set HOME and run login again."
579        ))?;
580        kiss_ai::config_file::with_lock(&path, || {
581            let mut settings: Value = kiss_ai::config_file::read_json(&path)?;
582            if settings.is_null() {
583                settings = Value::Object(Map::new());
584            }
585            if !settings.is_object() {
586                anyhow::bail!(
587                    "ChatGPT login cannot update {}. Settings must contain a JSON object. Correct the JSON and run login again.",
588                    path.display()
589                );
590            }
591            if let Some(id) = settings.get("deviceId").and_then(Value::as_str)
592                && uuid::Uuid::parse_str(id).is_ok()
593            {
594                return Ok(id.to_string());
595            }
596            let id = uuid::Uuid::new_v4().to_string();
597            settings["deviceId"] = Value::String(id.clone());
598            kiss_ai::config_file::write_atomic(
599                &path,
600                serde_json::to_string_pretty(&settings)?.as_bytes(),
601            )?;
602            Ok(id)
603        })
604    }
605
606    pub fn auto_recap_enabled(&self) -> bool {
607        self.auto_recap.unwrap_or(true)
608    }
609
610    /// Load global settings, overlaying project settings when trusted.
611    pub fn load(cwd: &Path, project_trusted: bool) -> Settings {
612        let mut problems = Vec::new();
613        let global = global_settings_path()
614            .and_then(|p| read_json(&p, &mut problems))
615            .unwrap_or_else(|| Value::Object(Default::default()));
616        let project = project_trusted
617            .then(|| read_json(&project_settings_path(cwd), &mut problems))
618            .flatten();
619        let mut settings = Self::from_layers(global, project);
620        problems.append(&mut settings.problems);
621        settings.problems = problems;
622        settings
623    }
624
625    fn from_layers(global: Value, project: Option<Value>) -> Settings {
626        let mut settings = merged_settings(global, project);
627        settings.baseline = serde_json::to_value(&settings).unwrap_or_default();
628        settings
629    }
630
631    /// Change a setting for this run only, for example from a CLI flag.
632    /// `save_global` does not write it.
633    pub fn override_for_run(&mut self, change: impl Fn(&mut Settings)) {
634        change(self);
635        if let Ok(mut baseline) = serde_json::from_value::<Settings>(self.baseline.clone()) {
636            change(&mut baseline);
637            self.baseline = serde_json::to_value(&baseline).unwrap_or_default();
638        }
639    }
640
641    /// Write the settings changed since `load` (or the last save) into the
642    /// user's settings file, leaving every other key in that file as it is.
643    pub fn save_global(&mut self) -> anyhow::Result<()> {
644        let path = global_settings_path().ok_or_else(|| anyhow::anyhow!(
645            "Settings cannot be saved because the home directory is unknown. Set HOME, then try again."
646        ))?;
647        self.save_to(&path)
648    }
649
650    fn save_to(&mut self, path: &Path) -> anyhow::Result<()> {
651        let current = serde_json::to_value(&*self)?;
652        kiss_ai::config_file::with_lock(path, || {
653            let mut disk: Value = kiss_ai::config_file::read_json(path)?;
654            if disk.is_null() {
655                disk = Value::Object(Map::new());
656            }
657            let Some(target) = disk.as_object_mut() else {
658                anyhow::bail!(
659                    "{} must contain a JSON object. Correct the file, then try again.",
660                    path.display()
661                );
662            };
663            let empty = Map::new();
664            apply_changes(
665                target,
666                self.baseline.as_object().unwrap_or(&empty),
667                current.as_object().unwrap_or(&empty),
668            );
669            kiss_ai::config_file::write_atomic(
670                path,
671                serde_json::to_string_pretty(&disk)?.as_bytes(),
672            )
673        })?;
674        self.baseline = current;
675        Ok(())
676    }
677}
678
679#[cfg(test)]
680mod tests {
681    use super::*;
682
683    #[test]
684    fn one_unreadable_value_keeps_the_other_settings_and_is_reported() {
685        let settings = Settings::from_layers(
686            json!({"defaultModel": 5, "defaultProvider": "openai"}),
687            None,
688        );
689        assert_eq!(settings.default_provider.as_deref(), Some("openai"));
690        assert_eq!(settings.default_model, None);
691        assert_eq!(settings.problems.len(), 1);
692        assert!(settings.problems[0].contains("defaultModel"));
693    }
694
695    #[test]
696    fn only_user_settings_choose_the_advisor() {
697        let project = Some(json!({"advisorModel": "openai/gpt-5.2", "advisorMaxUses": 50}));
698        let from_project = Settings::from_layers(json!({}), project.clone());
699        assert_eq!(from_project.advisor_model, None);
700        assert_eq!(from_project.advisor_max_uses, None);
701        let user = json!({"advisorModel": "anthropic/claude-opus-4-5"});
702        assert_eq!(
703            Settings::from_layers(user, project)
704                .advisor_model
705                .as_deref(),
706            Some("anthropic/claude-opus-4-5")
707        );
708    }
709
710    #[test]
711    fn a_saved_advisor_survives_a_reload_and_clearing_it_turns_it_off() {
712        let directory = tempfile::tempdir().unwrap();
713        let path = directory.path().join("settings.json");
714        let reload = || {
715            let text = std::fs::read_to_string(&path).unwrap();
716            Settings::from_layers(serde_json::from_str(&text).unwrap(), None)
717        };
718        let mut settings = Settings::from_layers(json!({}), None);
719        settings.advisor_model = Some("anthropic/claude-opus-4-5".into());
720        settings.save_to(&path).unwrap();
721        let mut settings = reload();
722        assert_eq!(
723            settings.advisor_model.as_deref(),
724            Some("anthropic/claude-opus-4-5")
725        );
726        settings.advisor_model = None;
727        settings.save_to(&path).unwrap();
728        assert_eq!(reload().advisor_model, None);
729    }
730
731    #[test]
732    fn a_save_writes_only_changed_settings_and_never_project_or_run_values() {
733        let directory = tempfile::tempdir().unwrap();
734        let path = directory.path().join("settings.json");
735        let global = json!({"theme": "dark", "deviceId": "id", "futureKey": 1});
736        std::fs::write(&path, global.to_string()).unwrap();
737        let mut settings = Settings::from_layers(
738            global,
739            Some(json!({"shellCommandPrefix": "source .envrc", "quietStartup": true})),
740        );
741        settings.override_for_run(|settings| settings.experimental_context_file = true);
742
743        settings.hide_thinking_block = true;
744        settings.save_to(&path).unwrap();
745
746        let saved: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
747        assert_eq!(
748            saved,
749            json!({"theme": "dark", "deviceId": "id", "futureKey": 1, "hideThinkingBlock": true})
750        );
751    }
752
753    #[test]
754    fn a_save_refuses_to_replace_a_settings_file_it_cannot_parse() {
755        let directory = tempfile::tempdir().unwrap();
756        let path = directory.path().join("settings.json");
757        std::fs::write(&path, "{ not json").unwrap();
758        let mut settings = Settings::from_layers(json!({}), None);
759        settings.quiet_startup = true;
760        assert!(settings.save_to(&path).is_err());
761        assert_eq!(std::fs::read_to_string(&path).unwrap(), "{ not json");
762    }
763    use serde_json::json;
764
765    #[test]
766    fn project_tool_modifiers_preserve_user_selection() {
767        let user = json!({"defaultTools":["read", "+grep"]});
768        let settings =
769            merged_settings(user.clone(), Some(json!({"defaultTools":["-read", "+ls"]})));
770        assert_eq!(
771            settings.default_tools.unwrap(),
772            ["read", "+grep", "-read", "+ls"]
773        );
774        let settings = merged_settings(user.clone(), Some(json!({"defaultTools":["bash"]})));
775        assert_eq!(settings.default_tools.unwrap(), ["bash"]);
776        let settings = merged_settings(user, Some(json!({"defaultTools":[]})));
777        assert_eq!(settings.default_tools.unwrap(), ["read", "+grep"]);
778        let settings = merged_settings(
779            json!({"deviceId":"user-id"}),
780            Some(json!({"deviceId":"project-id"})),
781        );
782        assert_eq!(settings.extra["deviceId"], "user-id");
783    }
784
785    #[test]
786    fn only_user_settings_choose_the_update_command() {
787        let project = Some(json!({"selfUpdateCommand": ["sh", "-c", "evil"]}));
788        assert_eq!(
789            merged_settings(json!({}), project.clone()).self_update_command,
790            None
791        );
792        assert_eq!(
793            merged_settings(
794                json!({"selfUpdateCommand": ["brew", "upgrade", "kiss"]}),
795                project
796            )
797            .self_update_command,
798            Some(vec!["brew".into(), "upgrade".into(), "kiss".into()])
799        );
800    }
801
802    #[test]
803    fn desktop_notifications_require_user_opt_in() {
804        assert!(!Settings::default().desktop_notifications);
805        let old: Settings = serde_json::from_value(json!({})).unwrap();
806        assert!(!old.desktop_notifications);
807        assert!(
808            !merged_settings(json!({}), Some(json!({"desktopNotifications": true})))
809                .desktop_notifications
810        );
811        for enabled in [false, true] {
812            let settings = merged_settings(
813                json!({"desktopNotifications": enabled}),
814                Some(json!({"desktopNotifications": !enabled})),
815            );
816            assert_eq!(settings.desktop_notifications, enabled);
817            let saved = serde_json::to_value(settings).unwrap();
818            assert_eq!(saved["desktopNotifications"], enabled);
819            let restored: Settings = serde_json::from_value(saved).unwrap();
820            assert_eq!(restored.desktop_notifications, enabled);
821        }
822    }
823
824    #[test]
825    fn project_cannot_enable_cloud_voice() {
826        let settings = merged_settings(json!({}), Some(json!({"voiceBackend":"deepgram"})));
827        assert_eq!(settings.voice_backend, None);
828        let settings = merged_settings(
829            json!({"voiceBackend":"elevenlabs"}),
830            Some(json!({"voiceBackend":"local"})),
831        );
832        assert_eq!(settings.voice_backend.as_deref(), Some("elevenlabs"));
833    }
834
835    #[test]
836    fn deep_merge_nested() {
837        let mut base =
838            json!({"theme": "dark", "compaction": {"enabled": true, "reserveTokens": 16384}});
839        kiss_ai::config_file::deep_merge(&mut base, json!({"compaction": {"reserveTokens": 8192}}));
840        assert_eq!(base["theme"], "dark");
841        assert_eq!(base["compaction"]["enabled"], true);
842        assert_eq!(base["compaction"]["reserveTokens"], 8192);
843    }
844
845    #[test]
846    fn defaults_match_pi() {
847        let s = Settings::default();
848        assert_eq!(s.compaction.mode, CompactionMode::Summary);
849        assert!(!s.experimental_context_file);
850        assert_eq!(s.compaction.reserve_tokens, 16_384);
851        assert_eq!(s.compaction.keep_recent_tokens, 20_000);
852        assert_eq!(s.reasoning_effort.mode, ReasoningEffortMode::Fixed);
853        assert_eq!(s.retry.max_retries, 3);
854        assert_eq!(s.retry.max_agent_delay_ms, 60_000);
855        assert_eq!(s.cache_warming, CacheWarmingMode::Idle);
856        assert!(!s.subagents.enabled);
857        assert_eq!(s.steering_mode, QueueMode::OneAtATime);
858        assert!(s.auto_recap_enabled());
859        assert_eq!(s.markdown.mermaid, MermaidRendering::Streaming);
860    }
861
862    #[test]
863    fn compaction_model_overrides_use_exact_provider_model_keys() {
864        let settings: Settings = serde_json::from_value(json!({
865            "compaction": {
866                "modelOverrides": {
867                    "anthropic/claude-opus-4-8": {
868                        "reserveTokens": 1000,
869                        "keepRecentTokens": 2000
870                    }
871                }
872            },
873            "cacheWarming": "streaming"
874        }))
875        .unwrap();
876        let override_settings = &settings.compaction.model_overrides["anthropic/claude-opus-4-8"];
877        assert_eq!(override_settings.reserve_tokens, Some(1000));
878        assert_eq!(override_settings.keep_recent_tokens, Some(2000));
879        assert_eq!(settings.cache_warming, CacheWarmingMode::Streaming);
880    }
881
882    #[test]
883    fn old_compaction_settings_default_to_summary_mode() {
884        let settings: Settings = serde_json::from_value(json!({
885            "compaction": {"enabled": true, "reserveTokens": 1, "keepRecentTokens": 2}
886        }))
887        .unwrap();
888        assert_eq!(settings.compaction.mode, CompactionMode::Summary);
889        assert_eq!(
890            serde_json::to_value(settings).unwrap()["compaction"]["mode"],
891            "summary"
892        );
893    }
894
895    #[test]
896    fn old_settings_default_to_fixed_reasoning_effort() {
897        let settings: Settings = serde_json::from_value(json!({})).unwrap();
898        assert_eq!(settings.reasoning_effort.mode, ReasoningEffortMode::Fixed);
899        assert_eq!(
900            serde_json::to_value(settings).unwrap()["reasoningEffort"]["mode"],
901            "fixed"
902        );
903    }
904
905    #[test]
906    fn decision_models_roundtrip_and_can_differ_between_features() {
907        for (wire, provider, model) in [
908            ("jev", "typesafe", "jev-latest"),
909            ("d1.1", "liquid", "d1.1"),
910            ("d1-ultra", "liquid", "d1-ultra"),
911            ("clef", "cloudflare-workers-ai", "clef"),
912            ("clef-flash", "cloudflare-workers-ai", "clef-flash"),
913        ] {
914            let settings: Settings = serde_json::from_value(json!({
915                "compaction": {"mode": wire}, "reasoningEffort": {"mode": wire}
916            }))
917            .unwrap();
918            for selected in [
919                settings.compaction.mode.decision_model(),
920                settings.reasoning_effort.mode.decision_model(),
921            ] {
922                let selected = selected.unwrap();
923                assert_eq!((selected.provider(), selected.id()), (provider, model));
924            }
925            let value = serde_json::to_value(settings).unwrap();
926            assert_eq!(value["compaction"]["mode"], wire);
927            assert_eq!(value["reasoningEffort"]["mode"], wire);
928        }
929        let alias: Settings = serde_json::from_value(json!({"compaction":{"mode":"d1"}})).unwrap();
930        assert_eq!(alias.compaction.mode.decision_model().unwrap().id(), "d1.1");
931        assert_eq!(
932            serde_json::to_value(alias).unwrap()["compaction"]["mode"],
933            "d1.1"
934        );
935        let settings = merged_settings(
936            json!({"compaction":{"mode":"jev"}, "reasoningEffort":{"mode":"d1-ultra"}}),
937            Some(json!({"compaction":{"mode":"clef-flash"}})),
938        );
939        assert_eq!(
940            settings.compaction.mode.decision_model(),
941            Some(DecisionModel::ClefFlash)
942        );
943        assert_eq!(
944            settings.reasoning_effort.mode.decision_model(),
945            Some(DecisionModel::D1Ultra)
946        );
947    }
948
949    #[test]
950    fn queue_mode_wire_format() {
951        assert_eq!(
952            serde_json::to_value(QueueMode::OneAtATime).unwrap(),
953            "one-at-a-time"
954        );
955    }
956
957    #[test]
958    fn markdown_settings_use_the_pi_wire_shape() {
959        let value = serde_json::to_value(Settings::default()).unwrap();
960        assert_eq!(value["markdown"]["mermaid"], "streaming");
961        assert!(value.get("mermaidRendering").is_none());
962    }
963
964    #[test]
965    fn webmcp_settings_use_the_reference_wire_shape() {
966        let loaded: Settings = serde_json::from_value(json!({
967            "webmcp": {
968                "allowedOrigins": ["example.com"],
969                "disallowedOrigins": ["blocked.example"],
970                "cdp": 9333
971            }
972        }))
973        .unwrap();
974        assert_eq!(
975            loaded.webmcp.allowed_origins,
976            Some(vec!["example.com".to_string()])
977        );
978        assert_eq!(
979            loaded.webmcp.disallowed_origins,
980            vec!["blocked.example".to_string()]
981        );
982        assert_eq!(loaded.webmcp.cdp, Some(json!(9333)));
983    }
984
985    #[test]
986    fn status_line_uses_the_claude_code_wire_shape() {
987        let settings: Settings = serde_json::from_value(json!({
988            "statusLine": {"type": "command", "command": "~/.claude/statusline.sh", "padding": 1}
989        }))
990        .unwrap();
991        let status_line = settings.status_line.as_ref().unwrap();
992        assert_eq!(status_line.command(), Some("~/.claude/statusline.sh"));
993        assert_eq!(status_line.padding(), 1);
994        assert_eq!(
995            status_line.debounce(),
996            std::time::Duration::from_millis(300)
997        );
998        assert_eq!(status_line.timeout(), std::time::Duration::from_secs(5));
999        assert_eq!(
1000            serde_json::to_value(&settings).unwrap()["statusLine"]["command"],
1001            "~/.claude/statusline.sh"
1002        );
1003
1004        // An unrecognized type or an empty command disables the status line.
1005        let unknown: StatusLineSettings =
1006            serde_json::from_value(json!({"type": "widget", "command": "x"})).unwrap();
1007        assert_eq!(unknown.command(), None);
1008        let empty: StatusLineSettings =
1009            serde_json::from_value(json!({"type": "command", "command": " "})).unwrap();
1010        assert_eq!(empty.command(), None);
1011        assert_eq!(Settings::default().status_line, None);
1012    }
1013
1014    #[test]
1015    fn subagents_are_opt_in_on_the_wire() {
1016        let value = serde_json::to_value(Settings::default()).unwrap();
1017        assert_eq!(value["subagents"]["enabled"], false);
1018
1019        let loaded: Settings = serde_json::from_value(json!({})).unwrap();
1020        assert!(!loaded.subagents.enabled);
1021    }
1022
1023    #[test]
1024    fn workflows_default_on_but_only_matter_with_subagents() {
1025        let settings = Settings::default();
1026        assert!(settings.workflows.enabled);
1027        assert!(settings.workflows.confirm);
1028        assert!(settings.workflows.keyword_trigger);
1029        assert_eq!(settings.workflows.size, WorkflowSize::Medium);
1030        // Subagents stay off, so workflows are unavailable until the user opts
1031        // in to child agents.
1032        assert!(!settings.subagents.enabled);
1033    }
1034
1035    #[test]
1036    fn project_settings_cannot_turn_workflows_on_or_off() {
1037        let off = merged_settings(
1038            json!({"workflows": {"enabled": false}}),
1039            Some(json!({"workflows": {"enabled": true}})),
1040        );
1041        assert!(!off.workflows.enabled);
1042
1043        let on = merged_settings(json!({}), Some(json!({"workflows": {"enabled": false}})));
1044        assert!(on.workflows.enabled);
1045    }
1046
1047    #[test]
1048    fn project_settings_cannot_enable_paid_cache_warming() {
1049        let settings = merged_settings(
1050            json!({"cacheWarming": "off"}),
1051            Some(json!({"cacheWarming": "idle"})),
1052        );
1053        assert_eq!(settings.cache_warming, CacheWarmingMode::Off);
1054    }
1055
1056    #[test]
1057    fn experimental_context_file_is_a_user_setting() {
1058        for enabled in [false, true] {
1059            let settings = merged_settings(
1060                json!({"experimentalContextFile": enabled}),
1061                Some(json!({"experimentalContextFile": !enabled})),
1062            );
1063            assert_eq!(settings.experimental_context_file, enabled);
1064            assert_eq!(
1065                serde_json::to_value(settings).unwrap()["experimentalContextFile"],
1066                enabled
1067            );
1068        }
1069    }
1070
1071    #[test]
1072    fn the_size_guideline_maps_to_an_agent_count() {
1073        assert_eq!(WorkflowSize::Small.target_agents(), Some(5));
1074        assert_eq!(WorkflowSize::Medium.target_agents(), Some(15));
1075        assert_eq!(WorkflowSize::Large.target_agents(), Some(50));
1076        assert_eq!(WorkflowSize::Unrestricted.target_agents(), None);
1077    }
1078
1079    #[test]
1080    fn project_settings_cannot_change_subagent_authority() {
1081        let enabled = merged_settings(
1082            json!({"subagents": {"enabled": true}}),
1083            Some(json!({"subagents": {"enabled": false}})),
1084        );
1085        assert!(enabled.subagents.enabled);
1086
1087        let disabled = merged_settings(json!({}), Some(json!({"subagents": {"enabled": true}})));
1088        assert!(!disabled.subagents.enabled);
1089    }
1090}