1use 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
26pub trait DecisionMode: Copy + PartialEq + 'static {
29 const ALL: &'static [Self];
30
31 fn decision_model(self) -> Option<DecisionModel>;
32
33 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 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 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 pub enabled: bool,
187}
188
189#[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 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 pub enabled: bool,
230 pub confirm: bool,
233 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
313#[serde(rename_all = "camelCase", default)]
314pub struct StatusLineSettings {
315 pub r#type: String,
317 pub command: String,
318 pub padding: Option<usize>,
320 pub debounce_ms: Option<u64>,
322 pub timeout_ms: Option<u64>,
324}
325
326impl StatusLineSettings {
327 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
348#[serde(rename_all = "lowercase")]
349pub enum SessionShareDestination {
350 #[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 pub desktop_notifications: bool,
369 pub self_update_command: Option<Vec<String>>,
371 pub default_project_trust: ProjectTrustDefault,
372 pub compaction: CompactionSettings,
373 pub experimental_context_file: bool,
375 pub reasoning_effort: ReasoningEffortSettings,
376 pub retry: RetrySettings,
377 pub cache_warming: CacheWarmingMode,
378 pub subagents: SubagentSettings,
379 pub advisor_model: Option<String>,
381 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 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 pub status_line: Option<StatusLineSettings>,
405 #[serde(flatten)]
407 pub extra: serde_json::Map<String, Value>,
408 #[serde(skip)]
414 #[doc(hidden)]
415 pub baseline: Value,
416 #[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 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 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 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 settings.subagents.enabled = subagents_enabled;
527 settings.advisor_model = advisor_model;
530 settings.advisor_max_uses = advisor_max_uses;
531 settings.experimental_context_file = experimental_context_file;
532 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
542fn 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 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 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 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 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 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 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}