1use 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 pub enabled: bool,
145}
146
147#[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 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 pub enabled: bool,
188 pub confirm: bool,
191 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
270#[serde(rename_all = "lowercase")]
271pub enum SessionShareDestination {
272 #[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 pub desktop_notifications: bool,
291 pub self_update_command: Option<Vec<String>>,
293 pub default_project_trust: ProjectTrustDefault,
294 pub compaction: CompactionSettings,
295 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 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 #[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
339fn 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 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 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 settings.subagents.enabled = subagents_enabled;
410 settings.experimental_context_file = experimental_context_file;
411 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 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 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 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}