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::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
26impl CompactionMode {
27    pub const ALL: [Self; 6] = [
28        Self::Summary,
29        Self::Jev,
30        Self::D1Ultra,
31        Self::D1,
32        Self::ClefFlash,
33        Self::Clef,
34    ];
35
36    pub fn decision_model(self) -> Option<DecisionModel> {
37        match self {
38            Self::Summary => None,
39            Self::Jev => Some(DecisionModel::Jev),
40            Self::D1 => Some(DecisionModel::D1),
41            Self::D1Ultra => Some(DecisionModel::D1Ultra),
42            Self::Clef => Some(DecisionModel::Clef),
43            Self::ClefFlash => Some(DecisionModel::ClefFlash),
44        }
45    }
46}
47
48#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
49#[serde(rename_all = "camelCase", default)]
50pub struct CompactionSettings {
51    pub enabled: bool,
52    pub mode: CompactionMode,
53    pub reserve_tokens: u64,
54    pub keep_recent_tokens: u64,
55    pub model_overrides: BTreeMap<String, CompactionModelOverride>,
56}
57
58#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
59#[serde(rename_all = "camelCase", default)]
60pub struct CompactionModelOverride {
61    pub reserve_tokens: Option<u64>,
62    pub keep_recent_tokens: Option<u64>,
63}
64
65impl Default for CompactionSettings {
66    fn default() -> Self {
67        CompactionSettings {
68            enabled: true,
69            mode: CompactionMode::Summary,
70            reserve_tokens: 16_384,
71            keep_recent_tokens: 20_000,
72            model_overrides: BTreeMap::new(),
73        }
74    }
75}
76
77#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
78#[serde(rename_all = "lowercase")]
79pub enum ReasoningEffortMode {
80    #[default]
81    Fixed,
82    Jev,
83    #[serde(rename = "d1.1", alias = "d1")]
84    D1,
85    #[serde(rename = "d1-ultra")]
86    D1Ultra,
87    Clef,
88    #[serde(rename = "clef-flash")]
89    ClefFlash,
90}
91
92impl ReasoningEffortMode {
93    pub const ALL: [Self; 6] = [
94        Self::Fixed,
95        Self::Jev,
96        Self::D1Ultra,
97        Self::D1,
98        Self::ClefFlash,
99        Self::Clef,
100    ];
101
102    pub fn decision_model(self) -> Option<DecisionModel> {
103        match self {
104            Self::Fixed => None,
105            Self::Jev => Some(DecisionModel::Jev),
106            Self::D1 => Some(DecisionModel::D1),
107            Self::D1Ultra => Some(DecisionModel::D1Ultra),
108            Self::Clef => Some(DecisionModel::Clef),
109            Self::ClefFlash => Some(DecisionModel::ClefFlash),
110        }
111    }
112}
113
114#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
115#[serde(rename_all = "camelCase", default)]
116pub struct ReasoningEffortSettings {
117    pub mode: ReasoningEffortMode,
118}
119
120#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
121#[serde(rename_all = "camelCase", default)]
122pub struct RetrySettings {
123    pub enabled: bool,
124    pub max_retries: u32,
125    pub base_delay_ms: u64,
126    pub max_agent_delay_ms: u64,
127}
128
129impl Default for RetrySettings {
130    fn default() -> Self {
131        RetrySettings {
132            enabled: true,
133            max_retries: 3,
134            base_delay_ms: 2000,
135            max_agent_delay_ms: 60_000,
136        }
137    }
138}
139
140#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
141#[serde(rename_all = "camelCase", default)]
142pub struct SubagentSettings {
143    /// Subagent tools are hidden until the user enables them.
144    pub enabled: bool,
145}
146
147/// How many agents to advise the model to aim for when it writes a workflow.
148///
149/// This is advice sent to the model, not a cap: a prompt that clearly calls for
150/// a different scale still wins. The runtime limits are the real bound.
151#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
152#[serde(rename_all = "lowercase")]
153pub enum WorkflowSize {
154    Small,
155    #[default]
156    Medium,
157    Large,
158    Unrestricted,
159}
160
161impl WorkflowSize {
162    pub fn as_str(self) -> &'static str {
163        match self {
164            Self::Small => "small",
165            Self::Medium => "medium",
166            Self::Large => "large",
167            Self::Unrestricted => "unrestricted",
168        }
169    }
170
171    /// The agent count the model is asked to stay under, if any.
172    pub fn target_agents(self) -> Option<u32> {
173        match self {
174            Self::Small => Some(5),
175            Self::Medium => Some(15),
176            Self::Large => Some(50),
177            Self::Unrestricted => None,
178        }
179    }
180}
181
182#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
183#[serde(rename_all = "camelCase", default)]
184pub struct WorkflowSettings {
185    /// Dynamic workflows are built on subagents, so they are available only
186    /// when `subagents.enabled` is also on.
187    pub enabled: bool,
188    /// Ask before a run starts. This is a cost gate, not a permission gate: one
189    /// run can start hundreds of child agents.
190    pub confirm: bool,
191    /// Let a typed prompt containing `ultracode`, or asking for a workflow in
192    /// plain words, start one.
193    pub keyword_trigger: bool,
194    pub size: WorkflowSize,
195}
196
197impl Default for WorkflowSettings {
198    fn default() -> Self {
199        WorkflowSettings {
200            enabled: true,
201            confirm: true,
202            keyword_trigger: true,
203            size: WorkflowSize::default(),
204        }
205    }
206}
207
208#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
209#[serde(rename_all = "kebab-case")]
210pub enum QueueMode {
211    All,
212    #[default]
213    OneAtATime,
214}
215
216#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
217#[serde(rename_all = "lowercase")]
218pub enum ProjectTrustDefault {
219    #[default]
220    Ask,
221    Always,
222    Never,
223}
224
225#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
226#[serde(rename_all = "lowercase")]
227pub enum CacheWarmingMode {
228    Off,
229    #[default]
230    Streaming,
231    Idle,
232}
233
234#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
235#[serde(rename_all = "lowercase")]
236pub enum MermaidRendering {
237    Off,
238    Final,
239    #[default]
240    Streaming,
241}
242
243impl MermaidRendering {
244    pub fn as_str(self) -> &'static str {
245        match self {
246            Self::Off => "off",
247            Self::Final => "final",
248            Self::Streaming => "streaming",
249        }
250    }
251}
252
253#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
254#[serde(rename_all = "camelCase", default)]
255pub struct MarkdownSettings {
256    pub code_block_indent: Option<String>,
257    pub mermaid: MermaidRendering,
258}
259
260#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
261#[serde(rename_all = "camelCase", default)]
262pub struct WebMcpSettings {
263    pub allowed_origins: Option<Vec<String>>,
264    pub disallowed_origins: Vec<String>,
265    pub cdp: Option<Value>,
266}
267
268/// Where `/share` uploads a session.
269#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
270#[serde(rename_all = "lowercase")]
271pub enum SessionShareDestination {
272    /// Radius when signed in, otherwise a secret GitHub gist.
273    #[default]
274    Auto,
275    Gist,
276    Radius,
277}
278
279#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
280#[serde(rename_all = "camelCase", default)]
281pub struct Settings {
282    pub default_provider: Option<String>,
283    pub default_model: Option<String>,
284    pub default_thinking_level: Option<String>,
285    pub transport: Transport,
286    pub hide_thinking_block: bool,
287    pub theme: Option<String>,
288    pub quiet_startup: bool,
289    /// User-only opt-in for interactive prompt-completion desktop notifications.
290    pub desktop_notifications: bool,
291    /// User-only argv that `kiss update` runs instead of the KISS installer.
292    pub self_update_command: Option<Vec<String>>,
293    pub default_project_trust: ProjectTrustDefault,
294    pub compaction: CompactionSettings,
295    /// Let the model edit its live conversation through a private JSON file.
296    pub experimental_context_file: bool,
297    pub reasoning_effort: ReasoningEffortSettings,
298    pub retry: RetrySettings,
299    pub cache_warming: CacheWarmingMode,
300    pub subagents: SubagentSettings,
301    pub workflows: WorkflowSettings,
302    pub steering_mode: QueueMode,
303    pub follow_up_mode: QueueMode,
304    pub shell_path: Option<String>,
305    pub shell_command_prefix: Option<String>,
306    pub session_dir: Option<String>,
307    pub session_share_destination: SessionShareDestination,
308    pub enabled_models: Option<Vec<String>>,
309    pub default_tools: Option<Vec<String>>,
310    pub external_editor: Option<String>,
311    pub skills: Vec<String>,
312    pub prompts: Vec<String>,
313    pub themes: Vec<String>,
314    pub enable_skill_commands: Option<bool>,
315    /// `None` preserves the default-on behavior for existing settings files.
316    pub auto_recap: Option<bool>,
317    pub voice_language: Option<String>,
318    pub voice_backend: Option<String>,
319    pub markdown: MarkdownSettings,
320    pub webmcp: WebMcpSettings,
321    /// Unknown keys survive load/save.
322    #[serde(flatten)]
323    pub extra: serde_json::Map<String, Value>,
324}
325
326pub fn global_settings_path() -> Option<PathBuf> {
327    dirs::home_dir().map(|h| h.join(".kiss/agent/settings.json"))
328}
329
330pub fn project_settings_path(cwd: &Path) -> PathBuf {
331    cwd.join(".kiss/settings.json")
332}
333
334fn read_json(path: &Path) -> Option<Value> {
335    let text = std::fs::read_to_string(path).ok()?;
336    serde_json::from_str(&text).ok()
337}
338
339/// Deep-merge `overlay` onto `base` (objects merge, everything else replaces).
340fn deep_merge(base: &mut Value, overlay: Value) {
341    match (base, overlay) {
342        (Value::Object(base_map), Value::Object(overlay_map)) => {
343            for (k, v) in overlay_map {
344                match base_map.get_mut(&k) {
345                    Some(slot) => deep_merge(slot, v),
346                    None => {
347                        base_map.insert(k, v);
348                    }
349                }
350            }
351        }
352        (slot, v) => *slot = v,
353    }
354}
355
356fn merged_settings(mut global: Value, project: Option<Value>) -> Settings {
357    let desktop_notifications = global
358        .get("desktopNotifications")
359        .and_then(Value::as_bool)
360        .unwrap_or(false);
361    // A project must not choose a command that `kiss update` runs.
362    let self_update_command = global
363        .get("selfUpdateCommand")
364        .cloned()
365        .and_then(|value| serde_json::from_value(value).ok());
366    let experimental_context_file = global
367        .get("experimentalContextFile")
368        .and_then(Value::as_bool)
369        .unwrap_or(false);
370    let subagents_enabled = global
371        .pointer("/subagents/enabled")
372        .and_then(Value::as_bool)
373        .unwrap_or(false);
374    let workflows_enabled = global
375        .pointer("/workflows/enabled")
376        .and_then(Value::as_bool)
377        .unwrap_or(true);
378    // A project must not opt the user's microphone in to a cloud service.
379    let voice_backend = global
380        .get("voiceBackend")
381        .and_then(Value::as_str)
382        .map(str::to_owned);
383    let cache_warming = global
384        .get("cacheWarming")
385        .cloned()
386        .and_then(|value| serde_json::from_value(value).ok())
387        .unwrap_or_default();
388    if let Some(mut project) = project {
389        if let Some(object) = project.as_object_mut() {
390            object.remove("deviceId");
391        }
392        if let Some(entries) = project.get("defaultTools").and_then(Value::as_array)
393            && entries.iter().all(|entry| {
394                entry
395                    .as_str()
396                    .is_some_and(|name| name.starts_with(['+', '-']))
397            })
398            && let Some(base) = global.get("defaultTools").and_then(Value::as_array)
399        {
400            let mut combined = base.clone();
401            combined.extend(entries.iter().cloned());
402            project["defaultTools"] = Value::Array(combined);
403        }
404        deep_merge(&mut global, project);
405    }
406    let mut settings: Settings = serde_json::from_value(global).unwrap_or_default();
407    // Subagents are a user-level authority choice. A repository must not turn
408    // them on through a trusted project settings file.
409    settings.subagents.enabled = subagents_enabled;
410    settings.experimental_context_file = experimental_context_file;
411    // Workflows spend the user's tokens many agents at a time, so the same rule
412    // applies: only the user's own settings file decides whether they are on.
413    settings.workflows.enabled = workflows_enabled;
414    settings.cache_warming = cache_warming;
415    settings.voice_backend = voice_backend;
416    settings.desktop_notifications = desktop_notifications;
417    settings.self_update_command = self_update_command;
418    settings
419}
420
421impl Settings {
422    /// Read only user settings when creating the installation ID.
423    pub fn get_or_create_device_id() -> anyhow::Result<String> {
424        let path = global_settings_path().ok_or_else(|| anyhow::anyhow!(
425            "ChatGPT login has no user settings path. A home directory is required. Set HOME and run login again."
426        ))?;
427        let mut settings: Value = match std::fs::read_to_string(&path) {
428            Ok(text) => serde_json::from_str(&text).map_err(|_| anyhow::anyhow!(
429                "ChatGPT login cannot read {}. Settings must contain a JSON object. Correct the JSON and run login again.", path.display()
430            ))?,
431            Err(error) if error.kind() == std::io::ErrorKind::NotFound => serde_json::json!({}),
432            Err(error) => return Err(error.into()),
433        };
434        if !settings.is_object() {
435            anyhow::bail!(
436                "ChatGPT login cannot update {}. Settings must contain a JSON object. Correct the JSON and run login again.",
437                path.display()
438            );
439        }
440        if let Some(id) = settings.get("deviceId").and_then(Value::as_str)
441            && uuid::Uuid::parse_str(id).is_ok()
442        {
443            return Ok(id.to_string());
444        }
445        let id = uuid::Uuid::new_v4().to_string();
446        settings["deviceId"] = Value::String(id.clone());
447        if let Some(parent) = path.parent() {
448            std::fs::create_dir_all(parent)?;
449        }
450        std::fs::write(path, serde_json::to_string_pretty(&settings)?)?;
451        Ok(id)
452    }
453
454    pub fn auto_recap_enabled(&self) -> bool {
455        self.auto_recap.unwrap_or(true)
456    }
457
458    /// Load global settings, overlaying project settings when trusted.
459    pub fn load(cwd: &Path, project_trusted: bool) -> Settings {
460        let global = global_settings_path()
461            .and_then(|p| read_json(&p))
462            .unwrap_or_else(|| Value::Object(Default::default()));
463        let project = project_trusted
464            .then(|| read_json(&project_settings_path(cwd)))
465            .flatten();
466        merged_settings(global, project)
467    }
468
469    pub fn save_global(&self) -> anyhow::Result<()> {
470        let path = global_settings_path().ok_or_else(|| anyhow::anyhow!("no home directory"))?;
471        if let Some(parent) = path.parent() {
472            std::fs::create_dir_all(parent)?;
473        }
474        let mut value = serde_json::to_value(self)?;
475        if let Some(id) = read_json(&path).and_then(|disk| disk.get("deviceId").cloned()) {
476            value["deviceId"] = id;
477        }
478        std::fs::write(&path, serde_json::to_string_pretty(&value)?)?;
479        Ok(())
480    }
481}
482
483#[cfg(test)]
484mod tests {
485    use super::*;
486    use serde_json::json;
487
488    #[test]
489    fn project_tool_modifiers_preserve_user_selection() {
490        let user = json!({"defaultTools":["read", "+grep"]});
491        let settings =
492            merged_settings(user.clone(), Some(json!({"defaultTools":["-read", "+ls"]})));
493        assert_eq!(
494            settings.default_tools.unwrap(),
495            ["read", "+grep", "-read", "+ls"]
496        );
497        let settings = merged_settings(user.clone(), Some(json!({"defaultTools":["bash"]})));
498        assert_eq!(settings.default_tools.unwrap(), ["bash"]);
499        let settings = merged_settings(user, Some(json!({"defaultTools":[]})));
500        assert_eq!(settings.default_tools.unwrap(), ["read", "+grep"]);
501        let settings = merged_settings(
502            json!({"deviceId":"user-id"}),
503            Some(json!({"deviceId":"project-id"})),
504        );
505        assert_eq!(settings.extra["deviceId"], "user-id");
506    }
507
508    #[test]
509    fn only_user_settings_choose_the_update_command() {
510        let project = Some(json!({"selfUpdateCommand": ["sh", "-c", "evil"]}));
511        assert_eq!(
512            merged_settings(json!({}), project.clone()).self_update_command,
513            None
514        );
515        assert_eq!(
516            merged_settings(
517                json!({"selfUpdateCommand": ["brew", "upgrade", "kiss"]}),
518                project
519            )
520            .self_update_command,
521            Some(vec!["brew".into(), "upgrade".into(), "kiss".into()])
522        );
523    }
524
525    #[test]
526    fn desktop_notifications_require_user_opt_in() {
527        assert!(!Settings::default().desktop_notifications);
528        let old: Settings = serde_json::from_value(json!({})).unwrap();
529        assert!(!old.desktop_notifications);
530        assert!(
531            !merged_settings(json!({}), Some(json!({"desktopNotifications": true})))
532                .desktop_notifications
533        );
534        for enabled in [false, true] {
535            let settings = merged_settings(
536                json!({"desktopNotifications": enabled}),
537                Some(json!({"desktopNotifications": !enabled})),
538            );
539            assert_eq!(settings.desktop_notifications, enabled);
540            let saved = serde_json::to_value(settings).unwrap();
541            assert_eq!(saved["desktopNotifications"], enabled);
542            let restored: Settings = serde_json::from_value(saved).unwrap();
543            assert_eq!(restored.desktop_notifications, enabled);
544        }
545    }
546
547    #[test]
548    fn project_cannot_enable_cloud_voice() {
549        let settings = merged_settings(json!({}), Some(json!({"voiceBackend":"deepgram"})));
550        assert_eq!(settings.voice_backend, None);
551        let settings = merged_settings(
552            json!({"voiceBackend":"elevenlabs"}),
553            Some(json!({"voiceBackend":"local"})),
554        );
555        assert_eq!(settings.voice_backend.as_deref(), Some("elevenlabs"));
556    }
557
558    #[test]
559    fn deep_merge_nested() {
560        let mut base =
561            json!({"theme": "dark", "compaction": {"enabled": true, "reserveTokens": 16384}});
562        deep_merge(&mut base, json!({"compaction": {"reserveTokens": 8192}}));
563        assert_eq!(base["theme"], "dark");
564        assert_eq!(base["compaction"]["enabled"], true);
565        assert_eq!(base["compaction"]["reserveTokens"], 8192);
566    }
567
568    #[test]
569    fn defaults_match_pi() {
570        let s = Settings::default();
571        assert_eq!(s.compaction.mode, CompactionMode::Summary);
572        assert!(!s.experimental_context_file);
573        assert_eq!(s.compaction.reserve_tokens, 16_384);
574        assert_eq!(s.compaction.keep_recent_tokens, 20_000);
575        assert_eq!(s.reasoning_effort.mode, ReasoningEffortMode::Fixed);
576        assert_eq!(s.retry.max_retries, 3);
577        assert_eq!(s.retry.max_agent_delay_ms, 60_000);
578        assert_eq!(s.cache_warming, CacheWarmingMode::Streaming);
579        assert!(!s.subagents.enabled);
580        assert_eq!(s.steering_mode, QueueMode::OneAtATime);
581        assert!(s.auto_recap_enabled());
582        assert_eq!(s.markdown.mermaid, MermaidRendering::Streaming);
583    }
584
585    #[test]
586    fn compaction_model_overrides_use_exact_provider_model_keys() {
587        let settings: Settings = serde_json::from_value(json!({
588            "compaction": {
589                "modelOverrides": {
590                    "anthropic/claude-opus-4-8": {
591                        "reserveTokens": 1000,
592                        "keepRecentTokens": 2000
593                    }
594                }
595            },
596            "cacheWarming": "idle"
597        }))
598        .unwrap();
599        let override_settings = &settings.compaction.model_overrides["anthropic/claude-opus-4-8"];
600        assert_eq!(override_settings.reserve_tokens, Some(1000));
601        assert_eq!(override_settings.keep_recent_tokens, Some(2000));
602        assert_eq!(settings.cache_warming, CacheWarmingMode::Idle);
603    }
604
605    #[test]
606    fn old_compaction_settings_default_to_summary_mode() {
607        let settings: Settings = serde_json::from_value(json!({
608            "compaction": {"enabled": true, "reserveTokens": 1, "keepRecentTokens": 2}
609        }))
610        .unwrap();
611        assert_eq!(settings.compaction.mode, CompactionMode::Summary);
612        assert_eq!(
613            serde_json::to_value(settings).unwrap()["compaction"]["mode"],
614            "summary"
615        );
616    }
617
618    #[test]
619    fn old_settings_default_to_fixed_reasoning_effort() {
620        let settings: Settings = serde_json::from_value(json!({})).unwrap();
621        assert_eq!(settings.reasoning_effort.mode, ReasoningEffortMode::Fixed);
622        assert_eq!(
623            serde_json::to_value(settings).unwrap()["reasoningEffort"]["mode"],
624            "fixed"
625        );
626    }
627
628    #[test]
629    fn decision_models_roundtrip_and_can_differ_between_features() {
630        for (wire, provider, model) in [
631            ("jev", "typesafe", "jev-latest"),
632            ("d1.1", "liquid", "d1.1"),
633            ("d1-ultra", "liquid", "d1-ultra"),
634            ("clef", "cloudflare-workers-ai", "clef"),
635            ("clef-flash", "cloudflare-workers-ai", "clef-flash"),
636        ] {
637            let settings: Settings = serde_json::from_value(json!({
638                "compaction": {"mode": wire}, "reasoningEffort": {"mode": wire}
639            }))
640            .unwrap();
641            for selected in [
642                settings.compaction.mode.decision_model(),
643                settings.reasoning_effort.mode.decision_model(),
644            ] {
645                let selected = selected.unwrap();
646                assert_eq!((selected.provider(), selected.id()), (provider, model));
647            }
648            let value = serde_json::to_value(settings).unwrap();
649            assert_eq!(value["compaction"]["mode"], wire);
650            assert_eq!(value["reasoningEffort"]["mode"], wire);
651        }
652        let alias: Settings = serde_json::from_value(json!({"compaction":{"mode":"d1"}})).unwrap();
653        assert_eq!(alias.compaction.mode.decision_model().unwrap().id(), "d1.1");
654        assert_eq!(
655            serde_json::to_value(alias).unwrap()["compaction"]["mode"],
656            "d1.1"
657        );
658        let settings = merged_settings(
659            json!({"compaction":{"mode":"jev"}, "reasoningEffort":{"mode":"d1-ultra"}}),
660            Some(json!({"compaction":{"mode":"clef-flash"}})),
661        );
662        assert_eq!(
663            settings.compaction.mode.decision_model(),
664            Some(DecisionModel::ClefFlash)
665        );
666        assert_eq!(
667            settings.reasoning_effort.mode.decision_model(),
668            Some(DecisionModel::D1Ultra)
669        );
670    }
671
672    #[test]
673    fn queue_mode_wire_format() {
674        assert_eq!(
675            serde_json::to_value(QueueMode::OneAtATime).unwrap(),
676            "one-at-a-time"
677        );
678    }
679
680    #[test]
681    fn markdown_settings_use_the_pi_wire_shape() {
682        let value = serde_json::to_value(Settings::default()).unwrap();
683        assert_eq!(value["markdown"]["mermaid"], "streaming");
684        assert!(value.get("mermaidRendering").is_none());
685    }
686
687    #[test]
688    fn webmcp_settings_use_the_reference_wire_shape() {
689        let loaded: Settings = serde_json::from_value(json!({
690            "webmcp": {
691                "allowedOrigins": ["example.com"],
692                "disallowedOrigins": ["blocked.example"],
693                "cdp": 9333
694            }
695        }))
696        .unwrap();
697        assert_eq!(
698            loaded.webmcp.allowed_origins,
699            Some(vec!["example.com".to_string()])
700        );
701        assert_eq!(
702            loaded.webmcp.disallowed_origins,
703            vec!["blocked.example".to_string()]
704        );
705        assert_eq!(loaded.webmcp.cdp, Some(json!(9333)));
706    }
707
708    #[test]
709    fn subagents_are_opt_in_on_the_wire() {
710        let value = serde_json::to_value(Settings::default()).unwrap();
711        assert_eq!(value["subagents"]["enabled"], false);
712
713        let loaded: Settings = serde_json::from_value(json!({})).unwrap();
714        assert!(!loaded.subagents.enabled);
715    }
716
717    #[test]
718    fn workflows_default_on_but_only_matter_with_subagents() {
719        let settings = Settings::default();
720        assert!(settings.workflows.enabled);
721        assert!(settings.workflows.confirm);
722        assert!(settings.workflows.keyword_trigger);
723        assert_eq!(settings.workflows.size, WorkflowSize::Medium);
724        // Subagents stay off, so workflows are unavailable until the user opts
725        // in to child agents.
726        assert!(!settings.subagents.enabled);
727    }
728
729    #[test]
730    fn project_settings_cannot_turn_workflows_on_or_off() {
731        let off = merged_settings(
732            json!({"workflows": {"enabled": false}}),
733            Some(json!({"workflows": {"enabled": true}})),
734        );
735        assert!(!off.workflows.enabled);
736
737        let on = merged_settings(json!({}), Some(json!({"workflows": {"enabled": false}})));
738        assert!(on.workflows.enabled);
739    }
740
741    #[test]
742    fn project_settings_cannot_enable_paid_cache_warming() {
743        let settings = merged_settings(
744            json!({"cacheWarming": "off"}),
745            Some(json!({"cacheWarming": "idle"})),
746        );
747        assert_eq!(settings.cache_warming, CacheWarmingMode::Off);
748    }
749
750    #[test]
751    fn experimental_context_file_is_a_user_setting() {
752        for enabled in [false, true] {
753            let settings = merged_settings(
754                json!({"experimentalContextFile": enabled}),
755                Some(json!({"experimentalContextFile": !enabled})),
756            );
757            assert_eq!(settings.experimental_context_file, enabled);
758            assert_eq!(
759                serde_json::to_value(settings).unwrap()["experimentalContextFile"],
760                enabled
761            );
762        }
763    }
764
765    #[test]
766    fn the_size_guideline_maps_to_an_agent_count() {
767        assert_eq!(WorkflowSize::Small.target_agents(), Some(5));
768        assert_eq!(WorkflowSize::Medium.target_agents(), Some(15));
769        assert_eq!(WorkflowSize::Large.target_agents(), Some(50));
770        assert_eq!(WorkflowSize::Unrestricted.target_agents(), None);
771    }
772
773    #[test]
774    fn project_settings_cannot_change_subagent_authority() {
775        let enabled = merged_settings(
776            json!({"subagents": {"enabled": true}}),
777            Some(json!({"subagents": {"enabled": false}})),
778        );
779        assert!(enabled.subagents.enabled);
780
781        let disabled = merged_settings(json!({}), Some(json!({"subagents": {"enabled": true}})));
782        assert!(!disabled.subagents.enabled);
783    }
784}