Skip to main content

mermaid_cli/app/
config.rs

1use crate::constants::{DEFAULT_MAX_TOKENS, DEFAULT_OLLAMA_PORT, DEFAULT_TEMPERATURE};
2use crate::models::ReasoningLevel;
3use crate::runtime::{PolicyOverride, SafetyMode};
4use anyhow::{Context, Result};
5use directories::ProjectDirs;
6use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8use std::path::PathBuf;
9
10/// Main configuration structure
11#[derive(Debug, Clone, Default, Serialize, Deserialize)]
12pub struct Config {
13    /// Last used model (persisted between sessions)
14    #[serde(default)]
15    pub last_used_model: Option<String>,
16
17    /// Default model configuration
18    #[serde(default)]
19    pub default_model: ModelSettings,
20
21    /// Ollama configuration
22    #[serde(default)]
23    pub ollama: OllamaConfig,
24
25    /// Non-interactive mode configuration
26    #[serde(default)]
27    pub non_interactive: NonInteractiveConfig,
28
29    /// MCP server configurations
30    #[serde(default)]
31    pub mcp_servers: HashMap<String, McpServerConfig>,
32
33    /// User overrides + custom OpenAI-compatible providers. Keys are
34    /// provider names; matching a built-in registry entry overrides its
35    /// defaults, anything else defines a fully custom provider.
36    /// Example:
37    /// ```toml
38    /// [providers.groq]
39    /// api_key_env = "MY_GROQ_KEY"  # override default GROQ_API_KEY
40    ///
41    /// [providers.my-vllm]
42    /// base_url = "http://192.168.1.42:8000/v1"
43    /// api_key_env = "VLLM_KEY"
44    /// compat = "openai-effort"
45    /// ```
46    #[serde(default)]
47    pub providers: HashMap<String, UserProviderConfig>,
48
49    /// Per-model reasoning preferences keyed by full model ID
50    /// (`provider/name`). Set when the user runs `/reasoning <level>` or
51    /// Alt+T cycles while using a specific model — the new value sticks
52    /// for that model until changed. Falls back to
53    /// `default_model.reasoning` when no entry exists.
54    /// Example:
55    /// ```toml
56    /// [reasoning_per_model]
57    /// "anthropic/claude-sonnet-4-6" = "high"
58    /// "ollama/qwen3-coder:30b" = "low"
59    /// ```
60    #[serde(default)]
61    pub reasoning_per_model: HashMap<String, ReasoningLevel>,
62
63    /// Named model profiles that agents/plugins can request without
64    /// hardcoding a concrete provider model. Values are full model IDs.
65    /// Example:
66    /// ```toml
67    /// [model_profiles]
68    /// fast = "ollama/qwen3-coder:14b"
69    /// large-context = "openai/gpt-5.2"
70    /// tool-strong = "anthropic/claude-sonnet-4-6"
71    /// vision = "gemini/gemini-2.5-pro"
72    /// cheap = "groq/llama-3.3-70b-versatile"
73    /// ```
74    #[serde(default)]
75    pub model_profiles: HashMap<String, String>,
76
77    /// Runtime safety policy. Defaults to `Ask` so mutations / shell /
78    /// network actions require approval out of the box; users opt into
79    /// `Auto` (LLM-vetted) or `FullAccess` deliberately.
80    #[serde(default)]
81    pub safety: SafetyConfig,
82
83    /// Durable semantic memory settings.
84    #[serde(default)]
85    pub memory: MemoryConfig,
86
87    /// Computer-use (desktop control) preferences.
88    #[serde(default)]
89    pub computer_use: ComputerUseConfig,
90
91    /// Runtime-only prompt customizations supplied by CLI flags. These are
92    /// deliberately skipped when saving config so one-off agent personas do
93    /// not pollute the user's persistent Mermaid settings.
94    #[serde(skip)]
95    pub prompt: PromptConfig,
96}
97
98#[derive(Debug, Clone, Default)]
99pub struct PromptConfig {
100    pub system_prompt: Option<String>,
101    pub append_system_prompt: Vec<String>,
102}
103
104impl PromptConfig {
105    pub fn render_system_prompt(&self, default_prompt: &str) -> String {
106        let mut rendered = self
107            .system_prompt
108            .as_deref()
109            .unwrap_or(default_prompt)
110            .trim_end()
111            .to_string();
112
113        for extra in &self.append_system_prompt {
114            let extra = extra.trim();
115            if extra.is_empty() {
116                continue;
117            }
118            if !rendered.is_empty() {
119                rendered.push_str("\n\n");
120            }
121            rendered.push_str(extra);
122        }
123
124        rendered
125    }
126
127    pub fn is_customized(&self) -> bool {
128        self.system_prompt.is_some() || !self.append_system_prompt.is_empty()
129    }
130}
131
132#[derive(Debug, Clone, Serialize, Deserialize)]
133#[serde(default)]
134pub struct SafetyConfig {
135    pub mode: SafetyMode,
136    pub checkpoint_on_mutation: bool,
137    #[serde(default)]
138    pub overrides: Vec<PolicyOverride>,
139    /// Model id the `Auto`-mode safety classifier uses to vet borderline
140    /// actions. `None` ⇒ vet with the session's active model. Set this to
141    /// point the vet at a cheaper/faster model than the one driving the work.
142    #[serde(default)]
143    pub auto_classifier_model: Option<String>,
144    /// Headless escape hatch: when true, non-replayable tools (web/mcp/
145    /// subagent/computer_use) are allowed to PROCEED on an `Ask` decision in a
146    /// headless run (no approval UI) instead of being blocked. Default `false`
147    /// — `mermaid run` in `ask` mode otherwise refuses these. Set via
148    /// `--allow-untrusted-tools` or config for CI that needs them.
149    #[serde(default)]
150    pub allow_untrusted_headless_tools: bool,
151}
152
153impl Default for SafetyConfig {
154    fn default() -> Self {
155        Self {
156            // Safe-by-default: the first run prompts for approval on
157            // mutations / shell / network rather than silently auto-allowing
158            // everything. FullAccess remains available via config.
159            mode: SafetyMode::Ask,
160            checkpoint_on_mutation: true,
161            overrides: Vec::new(),
162            auto_classifier_model: None,
163            allow_untrusted_headless_tools: false,
164        }
165    }
166}
167
168/// Durable semantic memory settings (v0.10.0).
169#[derive(Debug, Clone, Serialize, Deserialize)]
170#[serde(default)]
171pub struct MemoryConfig {
172    /// Master switch for agent memory (the tool, the always-loaded index, and
173    /// the slash commands). On by default.
174    pub enabled: bool,
175    /// Byte cap on the always-loaded memory index before it's truncated.
176    pub index_cap_bytes: usize,
177}
178
179impl Default for MemoryConfig {
180    fn default() -> Self {
181        Self {
182            enabled: true,
183            index_cap_bytes: crate::constants::MAX_MEMORY_INDEX_BYTES,
184        }
185    }
186}
187
188/// Computer-use (desktop control) preferences.
189#[derive(Debug, Clone, Serialize, Deserialize)]
190#[serde(default)]
191pub struct ComputerUseConfig {
192    /// After a successful click / type_text / press_key, auto-capture the
193    /// focused window and attach it inline so the model can verify the result.
194    /// On by default (non-breaking); set false to cut the per-action capture
195    /// cost + image tokens when visual feedback isn't needed. The model can
196    /// still call `screenshot` explicitly.
197    pub auto_screenshot: bool,
198}
199
200impl Default for ComputerUseConfig {
201    fn default() -> Self {
202        Self {
203            auto_screenshot: true,
204        }
205    }
206}
207
208/// User-supplied OpenAI-compatible provider configuration. All fields are
209/// optional — when matching a built-in registry entry, only the supplied
210/// fields override; the rest fall back to the registry defaults. For
211/// fully custom providers, `base_url` and `api_key_env` are required.
212#[derive(Debug, Clone, Default, Serialize, Deserialize)]
213pub struct UserProviderConfig {
214    /// Override base URL for `/chat/completions` (None = use built-in
215    /// registry default; required for fully custom providers).
216    #[serde(default)]
217    pub base_url: Option<String>,
218    /// Env var name to read the API key from (None = use the built-in
219    /// registry default like `GROQ_API_KEY`; required for fully custom
220    /// providers).
221    #[serde(default)]
222    pub api_key_env: Option<String>,
223    /// Extra HTTP headers sent on every request to this provider.
224    #[serde(default)]
225    pub extra_headers: HashMap<String, String>,
226    /// For fully custom providers (no built-in registry entry), declares
227    /// which OpenAI-compatible shape the endpoint speaks. Ignored when
228    /// the provider name matches a built-in registry entry. Values:
229    /// `"openai"` (no reasoning), `"openai-effort"` (`reasoning_effort`
230    /// field), `"openrouter"` (nested `reasoning: {effort}` object).
231    #[serde(default)]
232    pub compat: Option<String>,
233    /// Optional preferred model — surfaced by `mermaid status` and used
234    /// as the default when the user picks this provider with no model
235    /// suffix.
236    #[serde(default)]
237    pub default_model: Option<String>,
238}
239
240/// MCP server configuration
241#[derive(Debug, Clone, Serialize, Deserialize)]
242pub struct McpServerConfig {
243    /// Command to execute (e.g., "npx", "node", "python")
244    pub command: String,
245    /// Command-line arguments
246    #[serde(default)]
247    pub args: Vec<String>,
248    /// Environment variables for the server process
249    #[serde(default)]
250    pub env: HashMap<String, String>,
251}
252
253/// Default model settings
254#[derive(Debug, Clone, Serialize, Deserialize)]
255#[serde(default)]
256pub struct ModelSettings {
257    /// Model provider (ollama, openai, anthropic)
258    pub provider: String,
259    /// Model name
260    pub name: String,
261    /// Temperature for generation
262    pub temperature: f32,
263    /// Maximum tokens to generate
264    pub max_tokens: usize,
265    /// Default reasoning depth used for new sessions when no `--reasoning`
266    /// flag is given. Each adapter snaps this onto the closest level the
267    /// model actually supports via `nearest_effort()`.
268    pub reasoning: ReasoningLevel,
269}
270
271impl Default for ModelSettings {
272    fn default() -> Self {
273        Self {
274            provider: String::new(),
275            name: String::new(),
276            temperature: DEFAULT_TEMPERATURE,
277            max_tokens: DEFAULT_MAX_TOKENS,
278            reasoning: ReasoningLevel::default(),
279        }
280    }
281}
282
283/// Ollama configuration
284#[derive(Debug, Clone, Serialize, Deserialize)]
285#[serde(default)]
286pub struct OllamaConfig {
287    /// Ollama server host
288    pub host: String,
289    /// Ollama server port
290    pub port: u16,
291    /// Number of GPU layers to offload (None = auto, 0 = CPU only, positive = specific count)
292    /// Lower values free up VRAM for larger models at the cost of speed
293    pub num_gpu: Option<i32>,
294    /// Number of CPU threads for processing offloaded layers
295    /// Higher values improve CPU inference speed for large models
296    pub num_thread: Option<i32>,
297    /// Context window size (number of tokens)
298    /// Larger values allow longer conversations but use more memory
299    pub num_ctx: Option<i32>,
300    /// Enable NUMA optimization for multi-CPU systems
301    pub numa: Option<bool>,
302}
303
304impl Default for OllamaConfig {
305    fn default() -> Self {
306        Self {
307            host: String::from("localhost"),
308            port: DEFAULT_OLLAMA_PORT,
309            num_gpu: None,    // Let Ollama auto-detect
310            num_thread: None, // Let Ollama auto-detect
311            num_ctx: None,    // Use model default
312            numa: None,       // Auto-detect
313        }
314    }
315}
316
317/// Non-interactive mode configuration
318#[derive(Debug, Clone, Serialize, Deserialize)]
319#[serde(default)]
320pub struct NonInteractiveConfig {
321    /// Output format (text, json, markdown)
322    pub output_format: String,
323    /// Maximum tokens to generate
324    pub max_tokens: usize,
325    /// Don't execute agent actions (dry run)
326    pub no_execute: bool,
327}
328
329impl Default for NonInteractiveConfig {
330    fn default() -> Self {
331        Self {
332            output_format: String::from("text"),
333            max_tokens: DEFAULT_MAX_TOKENS,
334            no_execute: false,
335        }
336    }
337}
338
339/// Load configuration from single config file
340/// Priority: config file > defaults (that's it - no merging, no env vars)
341pub fn load_config() -> Result<Config> {
342    let config_path = get_config_path()?;
343
344    if config_path.exists() {
345        let toml_str = std::fs::read_to_string(&config_path)
346            .with_context(|| format!("Failed to read {}", config_path.display()))?;
347        let config: Config = toml::from_str(&toml_str).with_context(|| {
348            format!(
349                "Failed to parse {}. Run 'mermaid init' to regenerate.",
350                config_path.display()
351            )
352        })?;
353        Ok(config)
354    } else {
355        Ok(Config::default())
356    }
357}
358
359/// Like [`load_config`] but never fails: if a config file exists yet is
360/// malformed, warn on stderr and fall back to defaults — instead of silently
361/// swallowing the error (#111). An *absent* file is not an error (`load_config`
362/// returns defaults for it), so the warning fires only for a genuine
363/// read/parse failure the user should know about.
364pub fn load_config_or_warn() -> Config {
365    match load_config() {
366        Ok(config) => config,
367        Err(e) => {
368            eprintln!("mermaid: {e:#}");
369            Config::default()
370        },
371    }
372}
373
374/// Get the path to the single config file
375pub fn get_config_path() -> Result<PathBuf> {
376    Ok(get_config_dir()?.join("config.toml"))
377}
378
379/// Get the configuration directory
380pub fn get_config_dir() -> Result<PathBuf> {
381    if let Some(proj_dirs) = ProjectDirs::from("", "", "mermaid") {
382        let config_dir = proj_dirs.config_dir();
383        std::fs::create_dir_all(config_dir)?;
384        Ok(config_dir.to_path_buf())
385    } else {
386        // Fallback to home directory
387        let home = std::env::var("HOME")
388            .or_else(|_| std::env::var("USERPROFILE"))
389            .context("Could not determine home directory")?;
390        let config_dir = PathBuf::from(home).join(".config").join("mermaid");
391        std::fs::create_dir_all(&config_dir)?;
392        Ok(config_dir)
393    }
394}
395
396/// Save configuration to file
397pub fn save_config(config: &Config, path: Option<PathBuf>) -> Result<()> {
398    let path = if let Some(p) = path {
399        p
400    } else {
401        get_config_dir()?.join("config.toml")
402    };
403
404    let toml_string = toml::to_string_pretty(config)?;
405    std::fs::write(&path, toml_string)
406        .with_context(|| format!("Failed to write config to {}", path.display()))?;
407
408    Ok(())
409}
410
411/// Create a default configuration file if it doesn't exist
412pub fn init_config() -> Result<()> {
413    let config_file = get_config_path()?;
414
415    if config_file.exists() {
416        println!("Configuration already exists at: {}", config_file.display());
417    } else {
418        let default_config = Config::default();
419        save_config(&default_config, Some(config_file.clone()))?;
420        println!("Created configuration at: {}", config_file.display());
421    }
422
423    Ok(())
424}
425
426/// Persist the last used model to config file. On a malformed config it
427/// propagates the error (the caller drops it) rather than clobbering the file
428/// with defaults — the three `persist_*` helpers all do this (#111).
429pub fn persist_last_model(model: &str) -> Result<()> {
430    let mut config = load_config()?;
431    config.last_used_model = Some(model.to_string());
432    save_config(&config, None)
433}
434
435/// Persist the user's default reasoning level to config file. Mirrors
436/// `persist_last_model` — used by the `/reasoning` slash command and the
437/// Alt+T cycle handler so the choice survives across sessions.
438pub fn persist_default_reasoning(level: ReasoningLevel) -> Result<()> {
439    let mut config = load_config()?;
440    config.default_model.reasoning = level;
441    save_config(&config, None)
442}
443
444/// Persist a reasoning level for a specific model ID
445/// (e.g. `anthropic/claude-sonnet-4-6`). The TUI calls this from Alt+T,
446/// `/reasoning <level>`, and the does-not-support-thinking auto-snap so
447/// the choice sticks per-model rather than bleeding into other models on
448/// next session start.
449pub fn persist_reasoning_for_model(model_id: &str, level: ReasoningLevel) -> Result<()> {
450    let mut config = load_config()?;
451    config
452        .reasoning_per_model
453        .insert(model_id.to_string(), level);
454    save_config(&config, None)
455}
456
457/// Resolve which model to use: CLI arg > last_used > default_model > any available
458pub async fn resolve_model_id(cli_model: Option<&str>, config: &Config) -> anyhow::Result<String> {
459    if let Some(model) = cli_model {
460        if let Some(resolved) = resolve_model_profile_alias(model, config)? {
461            return Ok(resolved);
462        }
463        return Ok(model.to_string());
464    }
465    if let Some(last_model) = &config.last_used_model {
466        if let Some(resolved) = resolve_model_profile_alias(last_model, config)? {
467            return Ok(resolved);
468        }
469        return Ok(last_model.clone());
470    }
471    if !config.default_model.provider.is_empty() && !config.default_model.name.is_empty() {
472        return Ok(format!(
473            "{}/{}",
474            config.default_model.provider, config.default_model.name
475        ));
476    }
477    let available = crate::ollama::require_any_model(config).await?;
478    // `require_any_model` already errors on empty, so this `.first()` is
479    // never `None` in practice. Use `.first()` over `[0]` so the precondition
480    // is enforced by the type system instead of by a comment.
481    let first = available
482        .first()
483        .ok_or_else(|| anyhow::anyhow!("require_any_model returned empty list"))?;
484    Ok(format!("ollama/{}", first))
485}
486
487fn resolve_model_profile_alias(requested: &str, config: &Config) -> anyhow::Result<Option<String>> {
488    let profile = requested.strip_prefix("profile:").unwrap_or(requested);
489    if let Some(model) = config.model_profiles.get(profile) {
490        anyhow::ensure!(
491            !model.trim().is_empty(),
492            "model profile `{}` is configured with an empty model id",
493            profile
494        );
495        return Ok(Some(model.clone()));
496    }
497    if requested.starts_with("profile:") {
498        anyhow::bail!(
499            "model profile `{}` is not configured; add it under [model_profiles]",
500            profile
501        );
502    }
503    Ok(None)
504}
505
506#[cfg(test)]
507mod tests {
508    use super::*;
509
510    /// Configs persisted before Step 4 don't have a `reasoning` field on
511    /// `[default_model]`. Loading them must succeed and yield the
512    /// `Medium` default — otherwise existing user configs break on
513    /// upgrade.
514    #[test]
515    fn model_settings_deserializes_without_reasoning_field() {
516        let toml_blob = r#"
517            provider = "ollama"
518            name = "qwen3-coder:30b"
519            temperature = 0.7
520            max_tokens = 4096
521        "#;
522        let settings: ModelSettings = toml::from_str(toml_blob).expect("backward compat");
523        assert_eq!(settings.reasoning, ReasoningLevel::Medium);
524        assert_eq!(settings.provider, "ollama");
525    }
526
527    #[test]
528    fn model_settings_round_trips_reasoning_high() {
529        let original = ModelSettings {
530            provider: "anthropic".to_string(),
531            name: "claude-sonnet-4-6".to_string(),
532            temperature: 0.5,
533            max_tokens: 8192,
534            reasoning: ReasoningLevel::High,
535        };
536        let toml_blob = toml::to_string(&original).expect("serialize");
537        let back: ModelSettings = toml::from_str(&toml_blob).expect("deserialize");
538        assert_eq!(back.reasoning, ReasoningLevel::High);
539        assert_eq!(back.name, "claude-sonnet-4-6");
540    }
541
542    #[test]
543    fn configured_model_profile_resolves_explicit_alias() {
544        let mut config = Config::default();
545        config
546            .model_profiles
547            .insert("fast".to_string(), "ollama/qwen3-coder:14b".to_string());
548        assert_eq!(
549            resolve_model_profile_alias("fast", &config).unwrap(),
550            Some("ollama/qwen3-coder:14b".to_string())
551        );
552        assert_eq!(
553            resolve_model_profile_alias("profile:fast", &config).unwrap(),
554            Some("ollama/qwen3-coder:14b".to_string())
555        );
556    }
557
558    #[test]
559    fn profile_prefix_requires_configuration() {
560        let config = Config::default();
561        assert!(resolve_model_profile_alias("profile:vision", &config).is_err());
562        assert_eq!(
563            resolve_model_profile_alias("vision", &config).unwrap(),
564            None
565        );
566    }
567
568    /// `persist_default_reasoning` writes to the real config path, so
569    /// this test goes through `save_config(_, Some(path))` directly to
570    /// avoid clobbering the user's actual `~/.config/mermaid/config.toml`.
571    /// Uses `std::env::temp_dir` (matching the pattern in
572    /// `session::conversation` and `utils::logger`) — no external
573    /// `tempfile` crate dependency.
574    #[test]
575    fn save_and_reload_preserves_reasoning_field() {
576        let dir = std::env::temp_dir().join("mermaid_test_config_reasoning");
577        std::fs::create_dir_all(&dir).expect("create temp dir");
578        let path = dir.join("config.toml");
579
580        let mut cfg = Config::default();
581        cfg.default_model.provider = "ollama".to_string();
582        cfg.default_model.name = "qwen3-coder:30b".to_string();
583        cfg.default_model.reasoning = ReasoningLevel::Low;
584
585        save_config(&cfg, Some(path.clone())).expect("save");
586
587        let blob = std::fs::read_to_string(&path).expect("read");
588        let loaded: Config = toml::from_str(&blob).expect("parse back");
589        assert_eq!(loaded.default_model.reasoning, ReasoningLevel::Low);
590
591        let _ = std::fs::remove_dir_all(&dir);
592    }
593
594    /// Per-model entries serialize as a TOML table with quoted keys (the
595    /// model IDs contain `/`). This test verifies the round-trip works
596    /// through both serialization and deserialization, matching what
597    /// `persist_reasoning_for_model` would produce in real use.
598    #[test]
599    fn save_and_reload_preserves_reasoning_per_model_table() {
600        let dir = std::env::temp_dir().join("mermaid_test_config_per_model_reasoning");
601        std::fs::create_dir_all(&dir).expect("create temp dir");
602        let path = dir.join("config.toml");
603
604        let mut cfg = Config::default();
605        cfg.reasoning_per_model.insert(
606            "anthropic/claude-sonnet-4-6".to_string(),
607            ReasoningLevel::High,
608        );
609        cfg.reasoning_per_model
610            .insert("ollama/qwen3-coder:30b".to_string(), ReasoningLevel::Low);
611
612        save_config(&cfg, Some(path.clone())).expect("save");
613
614        let blob = std::fs::read_to_string(&path).expect("read");
615        let loaded: Config = toml::from_str(&blob).expect("parse back");
616        assert_eq!(
617            loaded
618                .reasoning_per_model
619                .get("anthropic/claude-sonnet-4-6"),
620            Some(&ReasoningLevel::High)
621        );
622        assert_eq!(
623            loaded.reasoning_per_model.get("ollama/qwen3-coder:30b"),
624            Some(&ReasoningLevel::Low)
625        );
626
627        let _ = std::fs::remove_dir_all(&dir);
628    }
629
630    /// Configs from before Step 5b don't have a `reasoning_per_model`
631    /// section. Loading them must succeed with an empty map — otherwise
632    /// upgrade breaks every existing user.
633    #[test]
634    fn config_deserializes_without_reasoning_per_model() {
635        let toml_blob = r#"
636            last_used_model = "ollama/qwen3-coder:30b"
637
638            [default_model]
639            provider = "ollama"
640            name = "qwen3-coder:30b"
641            temperature = 0.7
642            max_tokens = 4096
643        "#;
644        let cfg: Config = toml::from_str(toml_blob).expect("backward compat");
645        assert!(cfg.reasoning_per_model.is_empty());
646        assert!(!cfg.prompt.is_customized());
647    }
648
649    #[test]
650    fn config_defaults_computer_use_auto_screenshot_on() {
651        // An empty/legacy config must keep the auto-screenshot behavior (#98).
652        let cfg: Config = toml::from_str("").expect("empty config");
653        assert!(cfg.computer_use.auto_screenshot);
654    }
655
656    #[test]
657    fn prompt_config_replaces_and_appends_without_persisting() {
658        let mut cfg = Config::default();
659        cfg.prompt.system_prompt = Some("base".to_string());
660        cfg.prompt
661            .append_system_prompt
662            .push("extra instructions".to_string());
663
664        assert_eq!(
665            cfg.prompt.render_system_prompt("default"),
666            "base\n\nextra instructions"
667        );
668
669        let blob = toml::to_string(&cfg).expect("serialize");
670        assert!(!blob.contains("extra instructions"));
671        let loaded: Config = toml::from_str(&blob).expect("deserialize");
672        assert!(!loaded.prompt.is_customized());
673    }
674}