Skip to main content

imp_core/
config.rs

1use std::collections::{HashMap, HashSet};
2use std::path::{Path, PathBuf};
3
4use imp_llm::ThinkingLevel;
5use serde::{Deserialize, Serialize};
6
7use crate::error::Result;
8use crate::guardrails::GuardrailConfig;
9use crate::hooks::HookDef;
10use crate::personality::PersonalityConfig;
11use crate::roles::RoleDef;
12use crate::storage;
13use crate::tools::web::types::WebConfig;
14
15/// Agent mode — controls which tools and mana actions the agent may use.
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
17#[serde(rename_all = "kebab-case")]
18pub enum AgentMode {
19    /// Default. Full access to all tools. No filtering.
20    #[default]
21    Full,
22    /// Unit executor. Read + write + bash. No mana create/run.
23    Worker,
24    /// Plans and executes via mana. Cannot touch files directly.
25    Orchestrator,
26    /// Decomposes work. Can read and create mana units. Cannot run them.
27    Planner,
28    /// Read-only inspector. No mutations, no mana.
29    Reviewer,
30    /// Batch inspector. Reads code and mana state, produces reports.
31    Auditor,
32}
33
34const WORKER_TOOLS: &[&str] = &[
35    "read", "scan", "web", "recall", "write", "edit", "shell", "git", "mana", "ask",
36];
37const ORCHESTRATOR_TOOLS: &[&str] = &[
38    "read", "scan", "web", "recall", "mana", "git", "ask", "spawn",
39];
40const PLANNER_TOOLS: &[&str] = &["read", "scan", "web", "recall", "git", "mana", "ask"];
41const REVIEWER_TOOLS: &[&str] = &["read", "scan", "web", "recall", "git", "ask"];
42const AUDITOR_TOOLS: &[&str] = &["read", "scan", "web", "recall", "git", "mana"];
43
44const WORKER_MANA_ACTIONS: &[&str] = &[
45    "show",
46    "update",
47    "status",
48    "list",
49    "logs",
50    "next",
51    "verify",
52    "notes_append",
53];
54const ORCHESTRATOR_MANA_ACTIONS: &[&str] = &[
55    "status",
56    "list",
57    "show",
58    "create",
59    "close",
60    "update",
61    "run",
62    "run_state",
63    "evaluate",
64    "claim",
65    "release",
66    "logs",
67    "agents",
68    "next",
69    "tree",
70    "reopen",
71    "verify",
72    "fail",
73    "delete",
74    "dep_add",
75    "dep_remove",
76    "fact_create",
77    "fact_verify",
78    "notes_append",
79    "decision_add",
80    "decision_resolve",
81];
82const PLANNER_MANA_ACTIONS: &[&str] = &[
83    "status",
84    "list",
85    "show",
86    "create",
87    "update",
88    "next",
89    "tree",
90    "dep_add",
91    "dep_remove",
92    "fact_create",
93    "notes_append",
94    "decision_add",
95    "decision_resolve",
96];
97const AUDITOR_MANA_ACTIONS: &[&str] = &[
98    "status",
99    "list",
100    "show",
101    "logs",
102    "agents",
103    "next",
104    "tree",
105    "verify",
106    "fact_verify",
107];
108
109impl AgentMode {
110    /// Tool names this mode permits. An empty slice means "allow all" (Full).
111    pub fn allowed_tool_names(&self) -> &'static [&'static str] {
112        match self {
113            AgentMode::Full => &[],
114            AgentMode::Worker => WORKER_TOOLS,
115            AgentMode::Orchestrator => ORCHESTRATOR_TOOLS,
116            AgentMode::Planner => PLANNER_TOOLS,
117            AgentMode::Reviewer => REVIEWER_TOOLS,
118            AgentMode::Auditor => AUDITOR_TOOLS,
119        }
120    }
121
122    /// Returns true if the mode allows the named tool.
123    pub fn allows_tool(&self, name: &str) -> bool {
124        match self {
125            AgentMode::Full => true,
126            _ => self.allowed_tool_names().contains(&name),
127        }
128    }
129
130    /// Mana sub-actions this mode permits. An empty slice means "allow all" (Full).
131    pub fn allowed_mana_actions(&self) -> &'static [&'static str] {
132        match self {
133            AgentMode::Full | AgentMode::Reviewer => &[],
134            AgentMode::Worker => WORKER_MANA_ACTIONS,
135            AgentMode::Orchestrator => ORCHESTRATOR_MANA_ACTIONS,
136            AgentMode::Planner => PLANNER_MANA_ACTIONS,
137            AgentMode::Auditor => AUDITOR_MANA_ACTIONS,
138        }
139    }
140
141    /// Returns true if the mode allows the named mana action.
142    pub fn allows_mana_action(&self, action: &str) -> bool {
143        match self {
144            AgentMode::Full => true,
145            AgentMode::Reviewer => false,
146            _ => self.allowed_mana_actions().contains(&action),
147        }
148    }
149
150    /// Parse a mode from a string name (e.g. `"worker"`, `"full"`).
151    ///
152    /// Returns `None` for unrecognised names. Used to read `IMP_MODE` from the
153    /// environment without requiring a full `FromStr` implementation.
154    pub fn from_name(s: &str) -> Option<Self> {
155        match s.to_lowercase().as_str() {
156            "full" => Some(AgentMode::Full),
157            "worker" => Some(AgentMode::Worker),
158            "orchestrator" => Some(AgentMode::Orchestrator),
159            "planner" => Some(AgentMode::Planner),
160            "reviewer" => Some(AgentMode::Reviewer),
161            "auditor" => Some(AgentMode::Auditor),
162            _ => None,
163        }
164    }
165
166    /// Mode-specific behavioral instruction for the system prompt, if any.
167    pub fn instructions(&self) -> Option<&'static str> {
168        match self {
169            AgentMode::Full => None,
170            AgentMode::Worker => Some(
171                "You are a worker agent. Your job is to implement the assigned unit as specified and stay within its scope. \
172                You may read files, write files, and run shell commands. Inspect the relevant files before making claims or changes, \
173                use fast scoped checks for local feedback while implementing, and record meaningful progress or failure context with `mana update`. \
174                Do not declare success if commands or checks fail; report the exact blocker and the next useful action. \
175                Treat mana units as execution contracts: use their scope, dependencies, acceptance criteria, and verify gate before broadening the work. \
176                You may not create, run, or close mana units — final verification and closure belong to the orchestrator workflow.",
177            ),
178            AgentMode::Orchestrator => Some(
179                "You are an orchestrator agent. Use mana as your primary execution substrate for non-trivial work. \
180                Inspect mana state before making claims about work status, avoid duplicating or fragmenting existing units, and enrich existing units when that is cleaner than creating new ones. \
181                Write detailed units, split larger efforts into child units with dependencies, dispatch workers through mana, and own the final verification, retry, and closure workflow. \
182                Use the full mana unit vocabulary when it helps: acceptance criteria, labels, dependencies, paths, requires, produces, decisions, and feature boundaries. \
183                Encode unresolved questions as decisions instead of burying ambiguity in prose. \
184                When the conversation itself is producing durable plans, architecture, migrations, or implementation structure, externalize that structure into mana during the conversation rather than waiting until the end. \
185                Prefer native mana actions, including scope-aware and append-style updates, over shell or direct file edits for maintaining the work graph. \
186                You may not read or write files directly — spawn worker agents via mana for all file work. \
187                Update units with concrete failure context and do not retry unchanged failed plans. \
188                You are responsible for unit structure, completeness, and verify quality.",
189            ),
190            AgentMode::Planner => Some(
191                "You are a planner agent. Your job is to decompose work into mana units. \
192                Read enough code and context to ground the plan, cite concrete files or constraints when they matter, \
193                and make dependencies, sequencing, acceptance criteria, and verify commands explicit. \
194                Write worker-ready unit descriptions that include current state, concrete steps, file paths with intent, embedded context, scope boundaries, and what not to do. \
195                Record unresolved questions as decisions when autonomous execution would otherwise require guessing. \
196                Externalize durable planning structure into mana during the conversation, not only after the plan is complete. \
197                Prefer append-style mana updates to keep the graph current as ideas sharpen. \
198                You may read files and create units, but you may not run them — \
199                a human or orchestrator will approve execution.",
200            ),
201            AgentMode::Reviewer => Some(
202                "You are a reviewer agent. Your job is to read code and report findings. \
203                Ground findings in inspected code, cite exact files or symbols when useful, and distinguish confirmed issues from possible concerns. \
204                You may not write files, run commands, or use mana.",
205            ),
206            AgentMode::Auditor => Some(
207                "You are an auditor agent. Your job is to inspect code and mana state \
208                and produce structured reports. Ground conclusions in inspected evidence, cite the relevant files or mana objects, \
209                and clearly separate facts, risks, and open questions. You may read files and mana status, \
210                but you may not modify anything.",
211            ),
212        }
213    }
214}
215
216/// Shell backend selection for the Bash tool.
217#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
218#[serde(rename_all = "kebab-case")]
219pub enum ShellBackend {
220    /// Use a POSIX-compatible shell command. Defaults to `bash -c`.
221    #[default]
222    Sh,
223    /// Use the rush library API (`rush::run`). Falls back to the configured shell if
224    /// the `rush-backend` feature is not compiled in.
225    Rush,
226    /// Connect to a running rush daemon over Unix socket. Falls back to the configured shell
227    /// if the daemon is not reachable.
228    RushDaemon,
229}
230
231/// Shell-related configuration for the Bash tool.
232#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
233pub struct ShellConfig {
234    /// Which shell backend to use. Defaults to the standard command shell.
235    #[serde(default)]
236    pub backend: ShellBackend,
237    /// Shell executable used for command execution. Defaults to `bash`.
238    #[serde(default)]
239    pub command: Option<String>,
240}
241
242impl Default for ShellConfig {
243    fn default() -> Self {
244        Self {
245            backend: ShellBackend::Sh,
246            command: None,
247        }
248    }
249}
250
251/// Concrete capability policy for the shipped Lua extension runtime.
252#[derive(Debug, Clone, PartialEq, Eq)]
253pub struct LuaCapabilityPolicy {
254    pub allow_native_tool_calls: bool,
255    pub allow_shell_exec: bool,
256    pub allow_http: bool,
257    pub allow_secrets: bool,
258    pub allowed_env: HashSet<String>,
259}
260
261impl Default for LuaCapabilityPolicy {
262    fn default() -> Self {
263        Self {
264            allow_native_tool_calls: true,
265            allow_shell_exec: false,
266            allow_http: false,
267            allow_secrets: false,
268            allowed_env: HashSet::new(),
269        }
270    }
271}
272
273/// Configuration for the shipped Lua extension runtime.
274#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
275pub struct LuaConfig {
276    /// Whether `imp.tool()` calls from Lua are allowed.
277    pub allow_native_tool_calls: Option<bool>,
278    /// Whether `imp.exec()` is allowed.
279    pub allow_shell_exec: Option<bool>,
280    /// Whether `imp.http.*` is allowed.
281    pub allow_http: Option<bool>,
282    /// Whether `imp.secret()` / `imp.secret_fields()` are allowed.
283    pub allow_secrets: Option<bool>,
284    /// Env vars Lua extensions may read through `imp.env()`.
285    pub allowed_env: Option<Vec<String>>,
286}
287
288impl LuaConfig {
289    #[must_use]
290    pub fn resolve_policy(&self, mode: AgentMode) -> LuaCapabilityPolicy {
291        let mut policy = LuaCapabilityPolicy::default();
292        // Worker agents inherit the user's explicit Lua secret capability so
293        // agent-invoked extension tools behave the same as the parent session.
294        if matches!(mode, AgentMode::Worker) {
295            policy.allow_secrets = self.allow_secrets.unwrap_or(false);
296        }
297        if let Some(value) = self.allow_native_tool_calls {
298            policy.allow_native_tool_calls = value;
299        }
300        if let Some(value) = self.allow_shell_exec {
301            policy.allow_shell_exec = value;
302        }
303        if let Some(value) = self.allow_http {
304            policy.allow_http = value;
305        }
306        if let Some(value) = self.allow_secrets {
307            policy.allow_secrets = value;
308        }
309        if let Some(values) = &self.allowed_env {
310            policy.allowed_env = values.iter().cloned().collect();
311        }
312        policy
313    }
314}
315
316/// Native command secret-injection policy.
317#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
318pub struct SecretsConfig {
319    #[serde(default)]
320    pub commands: CommandSecretsConfig,
321}
322
323#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
324pub struct CommandSecretsConfig {
325    #[serde(default)]
326    pub enabled: bool,
327    #[serde(default)]
328    pub allowed: Vec<SecretEnvBindingPolicy>,
329}
330
331#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
332pub struct SecretEnvBindingPolicy {
333    pub provider: String,
334    pub field: String,
335    pub env: String,
336}
337
338/// Top-level configuration.
339#[derive(Debug, Clone, Default, Serialize, Deserialize)]
340pub struct Config {
341    /// Default model (alias or full ID).
342    pub model: Option<String>,
343
344    /// Default thinking level.
345    pub thinking: Option<ThinkingLevel>,
346
347    /// Default max output tokens per response.
348    pub max_tokens: Option<u32>,
349
350    /// Maximum agent turns.
351    pub max_turns: Option<u32>,
352
353    /// Active tool names (None = all).
354    pub tools: Option<Vec<String>>,
355
356    /// Named roles.
357    #[serde(default)]
358    pub roles: HashMap<String, RoleDef>,
359
360    /// Hook definitions.
361    #[serde(default)]
362    pub hooks: Vec<HookDef>,
363
364    /// Context management settings.
365    #[serde(default)]
366    pub context: ContextConfig,
367
368    /// Shell backend settings.
369    #[serde(default)]
370    pub shell: ShellConfig,
371
372    /// Engineering guardrails — profile-aware guidance and post-write checks.
373    #[serde(default)]
374    pub guardrails: GuardrailConfig,
375
376    /// Agent mode — controls tool and mana action access.
377    #[serde(default)]
378    pub mode: AgentMode,
379
380    /// Enabled models for the model selector (None = show all).
381    /// Entries can be canonical IDs or aliases (e.g. "sonnet", "claude-sonnet-4-6").
382    #[serde(default)]
383    pub enabled_models: Option<Vec<String>>,
384
385    /// Theme name ("default", "light", or custom).
386    pub theme: Option<String>,
387
388    /// Learning loop settings (memory, skill nudges).
389    #[serde(default)]
390    pub learning: LearningConfig,
391
392    /// UI display settings.
393    #[serde(default)]
394    pub ui: UiConfig,
395
396    /// Web tool settings.
397    #[serde(default)]
398    pub web: WebConfig,
399
400    /// Shipped Lua extension runtime policy.
401    #[serde(default)]
402    pub lua: LuaConfig,
403
404    /// Secret injection policy for native command execution.
405    #[serde(default)]
406    pub secrets: SecretsConfig,
407
408    /// Personality settings, including identity sentence and saved profiles.
409    #[serde(default)]
410    pub personality: PersonalityConfig,
411}
412
413// ── UI configuration ────────────────────────────────────────────
414
415/// How the sidebar displays tool calls.
416#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
417#[serde(rename_all = "lowercase")]
418pub enum SidebarStyle {
419    /// Contextual inspector for the selected tool call.
420    #[default]
421    Inspector,
422    /// Chronological stream of tool calls with inline results.
423    Stream,
424    /// Master-detail split: tool list (top) + selected output (bottom).
425    Split,
426}
427
428/// How much tool output to show per tool call.
429#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
430#[serde(rename_all = "lowercase")]
431pub enum ToolOutputDisplay {
432    /// Show all output lines (scrollable).
433    Full,
434    /// Show first N lines per tool (configurable via `tool_output_lines`).
435    #[default]
436    Compact,
437    /// Headers only — expand on click/enter.
438    Collapsed,
439}
440
441/// How tool calls appear inside the chat transcript.
442#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
443#[serde(rename_all = "kebab-case")]
444pub enum ChatToolDisplay {
445    /// Show tool calls inline where they occurred, preserving chronological order.
446    Interleaved,
447    /// Show a compact header in chat and leave details to the sidebar.
448    #[default]
449    Summary,
450    /// Hide tool calls in chat entirely.
451    Hidden,
452}
453
454/// UI animation intensity.
455#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
456#[serde(rename_all = "lowercase")]
457pub enum AnimationLevel {
458    /// No animated motion; show static state labels only.
459    None,
460    /// Basic spinner-only motion.
461    Spinner,
462    /// Restrained motion with concise state-specific labels.
463    #[default]
464    #[serde(alias = "full")]
465    Minimal,
466}
467
468/// Auto-continue policy for imp-local follow-on work.
469#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
470#[serde(rename_all = "kebab-case")]
471pub enum ContinuePolicy {
472    /// Never auto-continue on imp's own.
473    #[default]
474    Disabled,
475    /// Only auto-continue when the runtime evidence is especially strong.
476    Conservative,
477    /// Auto-continue on clear, visible, mana-backed next steps.
478    Balanced,
479    /// More willing to auto-continue when the local heuristic says confidence is high.
480    Aggressive,
481}
482
483/// UI display configuration.
484#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
485pub struct UiConfig {
486    /// Sidebar layout style.
487    #[serde(default)]
488    pub sidebar_style: SidebarStyle,
489
490    /// How much tool output to show.
491    #[serde(default)]
492    pub tool_output: ToolOutputDisplay,
493
494    /// Max lines per tool in compact mode. Default: 10.
495    #[serde(default = "default_tool_output_lines")]
496    pub tool_output_lines: usize,
497
498    /// Max lines the read tool returns before truncating. 0 disables line
499    /// truncation for file reads. Default: 500.
500    #[serde(default = "default_read_max_lines")]
501    pub read_max_lines: usize,
502
503    /// Sidebar width as percentage of screen (20-80). Default: 40.
504    #[serde(default = "default_sidebar_width")]
505    pub sidebar_width: u16,
506
507    /// Word-wrap long lines in tool output. Default: true.
508    #[serde(default = "default_true")]
509    pub word_wrap: bool,
510
511    /// Animation intensity for the TUI. Default: minimal.
512    #[serde(default)]
513    pub animations: AnimationLevel,
514
515    /// Legacy compatibility flag for older configs. Prefer `chat_tool_display`.
516    #[serde(default)]
517    pub hide_tools_in_chat: bool,
518
519    /// How tool calls should appear in the chat transcript.
520    #[serde(default)]
521    pub chat_tool_display: ChatToolDisplay,
522
523    /// Auto-open the sidebar on the first tool call. Default: true.
524    #[serde(default = "default_true")]
525    pub auto_open_sidebar: bool,
526
527    /// Minimum terminal width to auto-open sidebar. Default: 120.
528    #[serde(default = "default_sidebar_auto_open_width")]
529    pub sidebar_auto_open_width: u16,
530
531    /// Number of thinking lines to show in the rolling tail. Default: 5.
532    #[serde(default = "default_thinking_lines")]
533    pub thinking_lines: usize,
534
535    /// Number of streaming tool output lines to retain. Default: 5.
536    #[serde(default = "default_streaming_lines")]
537    pub streaming_lines: usize,
538
539    /// Mouse wheel scroll speed in lines. Default: 3.
540    #[serde(default = "default_mouse_scroll_lines")]
541    pub mouse_scroll_lines: usize,
542
543    /// Keyboard/page scroll speed in lines. Default: 20.
544    #[serde(default = "default_keyboard_scroll_lines")]
545    pub keyboard_scroll_lines: usize,
546
547    /// Deprecated: mouse capture is now always enabled. This field is retained
548    /// only for backwards-compatible deserialization of existing config files.
549    #[serde(default)]
550    #[doc(hidden)]
551    pub mouse_capture: bool,
552
553    /// Show timestamps in chat. Default: false.
554    #[serde(default)]
555    pub show_timestamps: bool,
556
557    /// Show cost in the top bar. Default: true.
558    #[serde(default = "default_true")]
559    pub show_cost: bool,
560
561    /// Show context usage in the top bar. Default: true.
562    #[serde(default = "default_true")]
563    pub show_context_usage: bool,
564
565    /// Emit a terminal bell when an agent run fully completes in the TUI.
566    /// Default: true.
567    #[serde(default = "default_true")]
568    pub notify_on_agent_complete: bool,
569
570    /// Policy for imp-local automatic continuation after a visible, high-confidence turn.
571    /// Default: disabled.
572    #[serde(default)]
573    pub continue_policy: ContinuePolicy,
574}
575
576fn default_tool_output_lines() -> usize {
577    10
578}
579fn default_read_max_lines() -> usize {
580    500
581}
582fn default_sidebar_width() -> u16 {
583    40
584}
585fn default_sidebar_auto_open_width() -> u16 {
586    120
587}
588fn default_thinking_lines() -> usize {
589    5
590}
591fn default_streaming_lines() -> usize {
592    5
593}
594fn default_mouse_scroll_lines() -> usize {
595    3
596}
597fn default_keyboard_scroll_lines() -> usize {
598    20
599}
600
601impl Default for UiConfig {
602    fn default() -> Self {
603        Self {
604            sidebar_style: SidebarStyle::default(),
605            tool_output: ToolOutputDisplay::default(),
606            tool_output_lines: default_tool_output_lines(),
607            read_max_lines: default_read_max_lines(),
608            sidebar_width: default_sidebar_width(),
609            word_wrap: default_true(),
610            animations: AnimationLevel::default(),
611            hide_tools_in_chat: false,
612            chat_tool_display: ChatToolDisplay::default(),
613            auto_open_sidebar: default_true(),
614            sidebar_auto_open_width: default_sidebar_auto_open_width(),
615            thinking_lines: default_thinking_lines(),
616            streaming_lines: default_streaming_lines(),
617            mouse_scroll_lines: default_mouse_scroll_lines(),
618            keyboard_scroll_lines: default_keyboard_scroll_lines(),
619            mouse_capture: false,
620            show_timestamps: false,
621            show_cost: true,
622            show_context_usage: true,
623            notify_on_agent_complete: true,
624            continue_policy: ContinuePolicy::Disabled,
625        }
626    }
627}
628
629impl UiConfig {
630    pub fn effective_chat_tool_display(&self) -> ChatToolDisplay {
631        if self.hide_tools_in_chat && self.sidebar_style != SidebarStyle::Inspector {
632            ChatToolDisplay::Hidden
633        } else if self.sidebar_style == SidebarStyle::Inspector {
634            ChatToolDisplay::Summary
635        } else {
636            self.chat_tool_display
637        }
638    }
639}
640
641/// Learning loop configuration.
642#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
643pub struct LearningConfig {
644    /// Master switch for memory + skill nudges. Default: true.
645    #[serde(default = "default_true")]
646    pub enabled: bool,
647
648    /// Tool call count before suggesting skill creation. Default: 8.
649    #[serde(default = "default_nudge_threshold")]
650    pub skill_nudge_threshold: u32,
651
652    /// Character limit for memory.md. Default: 2200.
653    #[serde(default = "default_memory_limit")]
654    pub memory_char_limit: usize,
655
656    /// Character limit for user.md. Default: 1400.
657    #[serde(default = "default_user_limit")]
658    pub user_char_limit: usize,
659}
660
661fn default_true() -> bool {
662    true
663}
664fn default_nudge_threshold() -> u32 {
665    8
666}
667fn default_memory_limit() -> usize {
668    2200
669}
670fn default_user_limit() -> usize {
671    1400
672}
673
674impl Default for LearningConfig {
675    fn default() -> Self {
676        Self {
677            enabled: default_true(),
678            skill_nudge_threshold: default_nudge_threshold(),
679            memory_char_limit: default_memory_limit(),
680            user_char_limit: default_user_limit(),
681        }
682    }
683}
684
685#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
686#[serde(rename_all = "kebab-case")]
687pub enum AutoCompactionMode {
688    /// Automatic context compaction is disabled; manual `/compact` only.
689    #[default]
690    Disabled,
691    /// Reserved placeholder for future near-threshold auto-compaction.
692    NearThreshold,
693    /// Reserved placeholder for future aggressive auto-compaction.
694    Aggressive,
695}
696
697#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
698pub struct AutoCompactionConfig {
699    /// Placeholder mode selection for future auto-compaction design.
700    #[serde(default)]
701    pub mode: AutoCompactionMode,
702}
703
704impl Default for AutoCompactionConfig {
705    fn default() -> Self {
706        Self {
707            mode: AutoCompactionMode::Disabled,
708        }
709    }
710}
711
712#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
713pub struct ContextConfig {
714    /// Mask old tool outputs at this ratio (default: 0.6).
715    pub observation_mask_threshold: f64,
716
717    /// Keep last N turns unmasked (default: 10).
718    pub mask_window: usize,
719
720    /// Placeholder auto-compaction settings. Disabled by default.
721    #[serde(default)]
722    pub auto_compaction: AutoCompactionConfig,
723}
724
725impl Default for ContextConfig {
726    fn default() -> Self {
727        Self {
728            observation_mask_threshold: 0.6,
729            mask_window: 10,
730            auto_compaction: AutoCompactionConfig::default(),
731        }
732    }
733}
734
735impl Config {
736    /// Load config from a TOML file.
737    pub fn load(path: &Path) -> Result<Self> {
738        if !path.exists() {
739            return Ok(Self::default());
740        }
741        let content = std::fs::read_to_string(path)?;
742        let config: Config = toml::from_str(&content)?;
743        Ok(config)
744    }
745
746    /// Resolve the full config by merging: defaults < user < project < env < CLI.
747    pub fn resolve(user_config_dir: &Path, project_dir: Option<&Path>) -> Result<Self> {
748        let mut config = Self::default();
749
750        // User config
751        let user_path = user_config_dir.join("config.toml");
752        if user_path.exists() {
753            let user = Self::load(&user_path)?;
754            config.merge(user);
755        }
756
757        // Project config
758        if let Some(project) = project_dir {
759            let project_path = project.join(".imp").join("config.toml");
760            if project_path.exists() {
761                let project = Self::load(&project_path)?;
762                config.merge(project);
763            }
764        }
765
766        // Env overrides
767        if let Ok(model) = std::env::var("IMP_MODEL") {
768            config.model = Some(model);
769        }
770        if let Ok(thinking) = std::env::var("IMP_THINKING") {
771            config.thinking = parse_thinking_level(&thinking);
772        }
773        if let Ok(max_tokens) = std::env::var("IMP_MAX_TOKENS") {
774            if let Ok(parsed) = max_tokens.parse::<u32>() {
775                config.max_tokens = Some(parsed);
776            }
777        }
778        if let Ok(mode) = std::env::var("IMP_MODE") {
779            if let Some(m) = parse_agent_mode(&mode) {
780                config.mode = m;
781            }
782        }
783        if let Ok(provider) = std::env::var("IMP_WEB_PROVIDER") {
784            config.web.search_provider = match provider.to_lowercase().as_str() {
785                "tavily" => Some(crate::tools::web::types::SearchProvider::Tavily),
786                "exa" => Some(crate::tools::web::types::SearchProvider::Exa),
787                "linkup" => Some(crate::tools::web::types::SearchProvider::Linkup),
788                "perplexity" => Some(crate::tools::web::types::SearchProvider::Perplexity),
789                _ => config.web.search_provider,
790            };
791        }
792
793        Ok(config)
794    }
795
796    fn merge(&mut self, other: Config) {
797        if other.model.is_some() {
798            self.model = other.model;
799        }
800        if other.thinking.is_some() {
801            self.thinking = other.thinking;
802        }
803        if other.max_tokens.is_some() {
804            self.max_tokens = other.max_tokens;
805        }
806        if other.max_turns.is_some() {
807            self.max_turns = other.max_turns;
808        }
809        if other.tools.is_some() {
810            self.tools = other.tools;
811        }
812        if other.context != ContextConfig::default() {
813            self.context = other.context;
814        }
815        if other.shell != ShellConfig::default() {
816            self.shell = other.shell;
817        }
818        self.guardrails.merge(other.guardrails);
819        if other.mode != AgentMode::default() {
820            self.mode = other.mode;
821        }
822        if other.enabled_models.is_some() {
823            self.enabled_models = other.enabled_models;
824        }
825        if other.theme.is_some() {
826            self.theme = other.theme;
827        }
828        if other.learning != LearningConfig::default() {
829            self.learning = other.learning;
830        }
831        if other.ui != UiConfig::default() {
832            self.ui = other.ui;
833        }
834        if other.web != WebConfig::default() {
835            self.web = other.web;
836        }
837        if other.lua != LuaConfig::default() {
838            self.lua = other.lua;
839        }
840        if other.secrets != SecretsConfig::default() {
841            self.secrets = other.secrets;
842        }
843        if other.personality != PersonalityConfig::default() {
844            self.personality.merge(other.personality);
845        }
846        self.roles.extend(other.roles);
847        self.hooks.extend(other.hooks);
848    }
849
850    /// Default user config directory.
851    pub fn user_config_dir() -> PathBuf {
852        storage::global_root()
853    }
854
855    /// Default session directory.
856    pub fn session_dir() -> PathBuf {
857        storage::global_sessions_dir()
858    }
859
860    /// Save config to a TOML file. Creates parent directories if needed.
861    pub fn save(&self, path: &Path) -> Result<()> {
862        if let Some(parent) = path.parent() {
863            std::fs::create_dir_all(parent)?;
864        }
865        let content =
866            toml::to_string_pretty(self).map_err(|e| crate::error::Error::Config(e.to_string()))?;
867        std::fs::write(path, content)?;
868        Ok(())
869    }
870
871    /// Path to the user config.toml file.
872    pub fn user_config_path() -> PathBuf {
873        storage::global_config_path()
874    }
875}
876
877fn parse_agent_mode(s: &str) -> Option<AgentMode> {
878    AgentMode::from_name(s)
879}
880
881fn parse_thinking_level(s: &str) -> Option<ThinkingLevel> {
882    match s.to_lowercase().as_str() {
883        "off" => Some(ThinkingLevel::Off),
884        "minimal" => Some(ThinkingLevel::Minimal),
885        "low" => Some(ThinkingLevel::Low),
886        "medium" => Some(ThinkingLevel::Medium),
887        "high" => Some(ThinkingLevel::High),
888        "xhigh" => Some(ThinkingLevel::XHigh),
889        _ => None,
890    }
891}
892
893#[cfg(test)]
894mod tests {
895    use super::*;
896    use std::fs;
897    use tempfile::TempDir;
898
899    #[test]
900    fn config_default_values() {
901        let config = Config::default();
902        assert!(config.model.is_none());
903        assert!(config.thinking.is_none());
904        assert!(config.max_tokens.is_none());
905        assert!(config.max_turns.is_none());
906        assert!(config.tools.is_none());
907        assert_eq!(config.ui.read_max_lines, 500);
908        assert_eq!(config.ui.sidebar_style, SidebarStyle::Inspector);
909        assert_eq!(config.ui.chat_tool_display, ChatToolDisplay::Summary);
910        assert_eq!(config.ui.tool_output, ToolOutputDisplay::Compact);
911        assert_eq!(config.web, WebConfig::default());
912        assert_eq!(config.personality, PersonalityConfig::default());
913        assert!(config.roles.is_empty());
914        assert!(config.hooks.is_empty());
915        assert!((config.context.observation_mask_threshold - 0.6).abs() < f64::EPSILON);
916        assert_eq!(config.context.mask_window, 10);
917        assert_eq!(
918            config.context.auto_compaction.mode,
919            AutoCompactionMode::Disabled
920        );
921        assert_eq!(config.guardrails, GuardrailConfig::default());
922    }
923
924    #[test]
925    fn inspector_sidebar_keeps_tool_calls_in_chat_summary() {
926        let mut ui = UiConfig {
927            sidebar_style: SidebarStyle::Inspector,
928            chat_tool_display: ChatToolDisplay::Interleaved,
929            ..Default::default()
930        };
931        assert_eq!(ui.effective_chat_tool_display(), ChatToolDisplay::Summary);
932
933        ui.chat_tool_display = ChatToolDisplay::Hidden;
934        ui.hide_tools_in_chat = true;
935        assert_eq!(ui.effective_chat_tool_display(), ChatToolDisplay::Summary);
936
937        ui.sidebar_style = SidebarStyle::Stream;
938        assert_eq!(ui.effective_chat_tool_display(), ChatToolDisplay::Hidden);
939    }
940
941    #[test]
942    fn config_load_from_toml() {
943        let dir = TempDir::new().unwrap();
944        let config_path = dir.path().join("config.toml");
945        fs::write(
946            &config_path,
947            r#"
948model = "sonnet"
949thinking = "high"
950max_tokens = 2048
951max_turns = 50
952tools = ["read", "write", "bash"]
953
954[guardrails]
955enabled = true
956level = "enforce"
957profile = "zig"
958critical_paths = ["src/**"]
959after_write = ["zig fmt --check ."]
960
961[context]
962observation_mask_threshold = 0.5
963mask_window = 5
964
965[shell]
966command = "zsh"
967
968[web]
969search_provider = "exa"
970"#,
971        )
972        .unwrap();
973
974        let config = Config::load(&config_path).unwrap();
975        assert_eq!(config.model.as_deref(), Some("sonnet"));
976        assert_eq!(config.thinking, Some(ThinkingLevel::High));
977        assert_eq!(config.max_tokens, Some(2048));
978        assert_eq!(config.max_turns, Some(50));
979        assert_eq!(config.tools.as_ref().unwrap().len(), 3);
980        assert_eq!(config.guardrails.enabled, Some(true));
981        assert_eq!(config.ui.read_max_lines, 500);
982        assert_eq!(
983            config.guardrails.profile,
984            Some(crate::guardrails::GuardrailProfile::Zig)
985        );
986        assert_eq!(
987            config.guardrails.after_write,
988            Some(vec!["zig fmt --check .".into()])
989        );
990        assert_eq!(config.shell.command.as_deref(), Some("zsh"));
991        assert_eq!(
992            config.web.search_provider,
993            Some(crate::tools::web::types::SearchProvider::Exa)
994        );
995        assert!((config.context.observation_mask_threshold - 0.5).abs() < f64::EPSILON);
996        assert_eq!(config.context.mask_window, 5);
997        assert_eq!(
998            config.context.auto_compaction.mode,
999            AutoCompactionMode::Disabled
1000        );
1001    }
1002
1003    #[test]
1004    fn config_load_missing_file_returns_default() {
1005        let dir = TempDir::new().unwrap();
1006        let config_path = dir.path().join("nonexistent.toml");
1007        let config = Config::load(&config_path).unwrap();
1008        assert!(config.model.is_none());
1009    }
1010
1011    #[test]
1012    fn config_loads_personality_section() {
1013        let dir = TempDir::new().unwrap();
1014        let config_path = dir.path().join("config.toml");
1015        fs::write(
1016            &config_path,
1017            r#"
1018[personality.profile.identity]
1019name = "Nova"
1020work_style = "careful"
1021voice = "clear"
1022focus = "research"
1023role = "assistant"
1024
1025[personality.profile.sliders]
1026autonomy = "low"
1027verbosity = "high"
1028caution = "very-high"
1029warmth = "high"
1030planning_depth = "very-high"
1031
1032[personality.profiles]
1033active = "researcher"
1034
1035[personality.profiles.saved.researcher.identity]
1036name = "Nova"
1037work_style = "careful"
1038voice = "clear"
1039focus = "research"
1040role = "assistant"
1041"#,
1042        )
1043        .unwrap();
1044
1045        let config = Config::load(&config_path).unwrap();
1046        assert_eq!(config.personality.profile.identity.name, "Nova");
1047        assert_eq!(
1048            config.personality.profile.identity.render_sentence(),
1049            "You are Nova, a careful, clear, research assistant."
1050        );
1051        assert_eq!(
1052            config.personality.profiles.active.as_deref(),
1053            Some("researcher")
1054        );
1055        assert!(config.personality.profiles.saved.contains_key("researcher"));
1056    }
1057
1058    #[test]
1059    fn config_merge_personality_project_overrides_user_and_keeps_saved_profiles() {
1060        let mut user = Config::default();
1061        user.personality.profile.identity.name = "imp".into();
1062        user.personality.profiles.active = Some("builder".into());
1063        user.personality.profiles.saved.insert(
1064            "builder".into(),
1065            crate::personality::PersonalityProfile::default(),
1066        );
1067
1068        let mut project = Config::default();
1069        project.personality.profile.identity.name = "Patch".into();
1070        project.personality.profiles.active = Some("reviewer".into());
1071        project.personality.profiles.saved.insert(
1072            "reviewer".into(),
1073            crate::personality::PersonalityProfile::default(),
1074        );
1075
1076        user.merge(project);
1077
1078        assert_eq!(user.personality.profile.identity.name, "Patch");
1079        assert_eq!(
1080            user.personality.profiles.active.as_deref(),
1081            Some("reviewer")
1082        );
1083        assert!(user.personality.profiles.saved.contains_key("builder"));
1084        assert!(user.personality.profiles.saved.contains_key("reviewer"));
1085    }
1086
1087    #[test]
1088    fn config_merge_project_overrides_user() {
1089        let mut user = Config {
1090            model: Some("haiku".into()),
1091            max_tokens: Some(1024),
1092            max_turns: Some(20),
1093            ..Default::default()
1094        };
1095
1096        let project = Config {
1097            model: Some("sonnet".into()),
1098            max_tokens: None,
1099            max_turns: None, // not set → user value preserved
1100            ..Default::default()
1101        };
1102
1103        user.merge(project);
1104        assert_eq!(user.model.as_deref(), Some("sonnet"));
1105        assert_eq!(user.max_tokens, Some(1024));
1106        assert_eq!(user.max_turns, Some(20));
1107    }
1108
1109    #[test]
1110    fn config_merge_roles_extend() {
1111        let mut base = Config::default();
1112        base.roles.insert(
1113            "worker".into(),
1114            RoleDef {
1115                model: Some("haiku".into()),
1116                thinking: None,
1117                tools: None,
1118                readonly: false,
1119                instructions: None,
1120                max_turns: None,
1121            },
1122        );
1123
1124        let overlay = Config {
1125            roles: {
1126                let mut m = HashMap::new();
1127                m.insert(
1128                    "reviewer".into(),
1129                    RoleDef {
1130                        model: Some("sonnet".into()),
1131                        thinking: Some(ThinkingLevel::High),
1132                        tools: None,
1133                        readonly: true,
1134                        instructions: None,
1135                        max_turns: None,
1136                    },
1137                );
1138                m
1139            },
1140            ..Default::default()
1141        };
1142
1143        base.merge(overlay);
1144        assert!(base.roles.contains_key("worker"));
1145        assert!(base.roles.contains_key("reviewer"));
1146    }
1147
1148    #[test]
1149    fn config_merge_hooks_extend() {
1150        let mut base = Config::default();
1151        base.hooks.push(HookDef {
1152            event: "after_file_write".into(),
1153            match_pattern: None,
1154            action: "log".into(),
1155            command: None,
1156            blocking: false,
1157            threshold: None,
1158        });
1159
1160        let overlay = Config {
1161            hooks: vec![HookDef {
1162                event: "before_tool_call".into(),
1163                match_pattern: None,
1164                action: "block".into(),
1165                command: None,
1166                blocking: true,
1167                threshold: None,
1168            }],
1169            ..Default::default()
1170        };
1171
1172        base.merge(overlay);
1173        assert_eq!(base.hooks.len(), 2);
1174    }
1175
1176    #[test]
1177    fn config_merge_context_overrides_default() {
1178        let mut base = Config::default();
1179
1180        let overlay = Config {
1181            context: ContextConfig {
1182                observation_mask_threshold: 0.5,
1183                mask_window: 5,
1184                auto_compaction: AutoCompactionConfig {
1185                    mode: AutoCompactionMode::NearThreshold,
1186                },
1187            },
1188            ..Default::default()
1189        };
1190
1191        base.merge(overlay);
1192        assert!((base.context.observation_mask_threshold - 0.5).abs() < f64::EPSILON);
1193        assert_eq!(base.context.mask_window, 5);
1194        assert_eq!(
1195            base.context.auto_compaction.mode,
1196            AutoCompactionMode::NearThreshold
1197        );
1198    }
1199
1200    #[test]
1201    fn config_merge_includes_theme_learning_and_lua() {
1202        let mut base = Config::default();
1203        let overlay = Config {
1204            theme: Some("light".into()),
1205            learning: LearningConfig {
1206                enabled: false,
1207                skill_nudge_threshold: 3,
1208                memory_char_limit: 1000,
1209                user_char_limit: 700,
1210            },
1211            lua: LuaConfig {
1212                allow_native_tool_calls: Some(false),
1213                allow_shell_exec: Some(true),
1214                allow_http: None,
1215                allow_secrets: None,
1216                allowed_env: Some(vec!["HOME".into()]),
1217            },
1218            ..Default::default()
1219        };
1220
1221        base.merge(overlay);
1222
1223        assert_eq!(base.theme.as_deref(), Some("light"));
1224        assert_eq!(base.learning.skill_nudge_threshold, 3);
1225        assert!(!base.learning.enabled);
1226        assert_eq!(base.lua.allow_native_tool_calls, Some(false));
1227        assert_eq!(base.lua.allow_shell_exec, Some(true));
1228        assert_eq!(base.lua.allowed_env, Some(vec!["HOME".into()]));
1229    }
1230
1231    #[test]
1232    fn config_merge_guardrails_preserves_unspecified_fields() {
1233        let mut base = Config::default();
1234        base.guardrails.enabled = Some(true);
1235        base.guardrails.profile = Some(crate::guardrails::GuardrailProfile::Rust);
1236        base.guardrails.critical_paths = Some(vec!["src/**".into()]);
1237
1238        let mut overlay = Config::default();
1239        overlay.guardrails.level = Some(crate::guardrails::GuardrailLevel::Enforce);
1240        overlay.guardrails.after_write = Some(vec!["cargo test".into()]);
1241
1242        base.merge(overlay);
1243
1244        assert_eq!(base.guardrails.enabled, Some(true));
1245        assert_eq!(
1246            base.guardrails.profile,
1247            Some(crate::guardrails::GuardrailProfile::Rust)
1248        );
1249        assert_eq!(base.guardrails.critical_paths, Some(vec!["src/**".into()]));
1250        assert_eq!(
1251            base.guardrails.level,
1252            Some(crate::guardrails::GuardrailLevel::Enforce)
1253        );
1254        assert_eq!(base.guardrails.after_write, Some(vec!["cargo test".into()]));
1255    }
1256
1257    #[test]
1258    fn config_resolve_user_then_project() {
1259        // Clean env to avoid interference from parallel tests
1260        std::env::remove_var("IMP_MODEL");
1261        std::env::remove_var("IMP_THINKING");
1262
1263        let dir = TempDir::new().unwrap();
1264        let user_dir = dir.path().join("user");
1265        let project_dir = dir.path().join("project");
1266        fs::create_dir_all(&user_dir).unwrap();
1267        fs::create_dir_all(project_dir.join(".imp")).unwrap();
1268
1269        // User config: model=haiku, max_turns=20, custom context
1270        fs::write(
1271            user_dir.join("config.toml"),
1272            r#"
1273model = "haiku"
1274max_turns = 20
1275
1276[context]
1277observation_mask_threshold = 0.55
1278mask_window = 9
1279
1280[context.auto_compaction]
1281mode = "disabled"
1282"#,
1283        )
1284        .unwrap();
1285
1286        // Project config: model=sonnet (overrides user), custom context overrides user context
1287        fs::write(
1288            project_dir.join(".imp").join("config.toml"),
1289            r#"
1290model = "sonnet"
1291
1292[context]
1293observation_mask_threshold = 0.5
1294mask_window = 5
1295
1296[context.auto_compaction]
1297mode = "disabled"
1298"#,
1299        )
1300        .unwrap();
1301
1302        let config = Config::resolve(&user_dir, Some(&project_dir)).unwrap();
1303        assert_eq!(config.model.as_deref(), Some("sonnet"));
1304        assert_eq!(config.max_turns, Some(20));
1305        assert!((config.context.observation_mask_threshold - 0.5).abs() < f64::EPSILON);
1306        assert_eq!(config.context.mask_window, 5);
1307    }
1308
1309    #[test]
1310    fn config_resolve_env_overrides() {
1311        // Test env override logic without relying on process-global state
1312        // (env vars are inherently racy in parallel tests).
1313        // We test that the override *mechanism* works by simulating it.
1314        let mut config = Config {
1315            model: Some("haiku".into()),
1316            thinking: Some(ThinkingLevel::Low),
1317            max_tokens: Some(2048),
1318            ..Default::default()
1319        };
1320
1321        // Simulate IMP_MODEL override
1322        let env_model = "opus";
1323        config.model = Some(env_model.into());
1324
1325        // Simulate IMP_THINKING override
1326        let env_thinking = "high";
1327        config.thinking = parse_thinking_level(env_thinking);
1328
1329        // Simulate IMP_MAX_TOKENS override
1330        let env_max_tokens = "1024";
1331        config.max_tokens = env_max_tokens.parse::<u32>().ok();
1332
1333        assert_eq!(config.model.as_deref(), Some("opus"));
1334        assert_eq!(config.thinking, Some(ThinkingLevel::High));
1335        assert_eq!(config.max_tokens, Some(1024));
1336    }
1337
1338    #[test]
1339    fn config_resolve_missing_files_uses_defaults() {
1340        let dir = TempDir::new().unwrap();
1341        let config = Config::resolve(dir.path(), None).unwrap();
1342        assert!(config.model.is_none());
1343        assert!(config.thinking.is_none());
1344        assert!(config.max_tokens.is_none());
1345        assert!(config.max_turns.is_none());
1346    }
1347
1348    #[test]
1349    fn config_load_with_roles_and_hooks() {
1350        let dir = TempDir::new().unwrap();
1351        let config_path = dir.path().join("config.toml");
1352        fs::write(
1353            &config_path,
1354            r#"
1355model = "sonnet"
1356
1357[roles.coder]
1358model = "opus"
1359thinking = "high"
1360readonly = false
1361
1362[roles.reader]
1363readonly = true
1364
1365[[hooks]]
1366event = "after_file_write"
1367action = "log"
1368blocking = false
1369"#,
1370        )
1371        .unwrap();
1372
1373        let config = Config::load(&config_path).unwrap();
1374        assert_eq!(config.roles.len(), 2);
1375        assert!(config.roles.contains_key("coder"));
1376        assert!(config.roles.contains_key("reader"));
1377        assert_eq!(config.roles["coder"].model.as_deref(), Some("opus"));
1378        assert!(config.roles["reader"].readonly);
1379        assert_eq!(config.hooks.len(), 1);
1380        assert_eq!(config.hooks[0].event, "after_file_write");
1381    }
1382
1383    #[test]
1384    fn config_parse_thinking_levels() {
1385        assert_eq!(parse_thinking_level("off"), Some(ThinkingLevel::Off));
1386        assert_eq!(
1387            parse_thinking_level("minimal"),
1388            Some(ThinkingLevel::Minimal)
1389        );
1390        assert_eq!(parse_thinking_level("low"), Some(ThinkingLevel::Low));
1391        assert_eq!(parse_thinking_level("medium"), Some(ThinkingLevel::Medium));
1392        assert_eq!(parse_thinking_level("high"), Some(ThinkingLevel::High));
1393        assert_eq!(parse_thinking_level("xhigh"), Some(ThinkingLevel::XHigh));
1394        assert_eq!(parse_thinking_level("OFF"), Some(ThinkingLevel::Off));
1395        assert_eq!(parse_thinking_level("High"), Some(ThinkingLevel::High));
1396        assert_eq!(parse_thinking_level("invalid"), None);
1397        assert_eq!(parse_thinking_level(""), None);
1398    }
1399
1400    #[test]
1401    fn config_partial_toml_fills_defaults() {
1402        let dir = TempDir::new().unwrap();
1403        let config_path = dir.path().join("config.toml");
1404        fs::write(
1405            &config_path,
1406            r#"
1407model = "sonnet"
1408"#,
1409        )
1410        .unwrap();
1411
1412        let config = Config::load(&config_path).unwrap();
1413        assert_eq!(config.model.as_deref(), Some("sonnet"));
1414        // Unspecified fields use defaults
1415        assert!(config.thinking.is_none());
1416        assert!(config.max_tokens.is_none());
1417        assert!(config.max_turns.is_none());
1418        assert!((config.context.observation_mask_threshold - 0.6).abs() < f64::EPSILON);
1419    }
1420
1421    // --- AgentMode tests ---
1422
1423    #[test]
1424    fn agent_mode_default_is_full() {
1425        let config = Config::default();
1426        assert_eq!(config.mode, AgentMode::Full);
1427        assert_eq!(AgentMode::default(), AgentMode::Full);
1428    }
1429
1430    #[test]
1431    fn lua_config_resolves_capability_policy() {
1432        let config = LuaConfig {
1433            allow_native_tool_calls: Some(false),
1434            allow_shell_exec: Some(true),
1435            allow_http: Some(true),
1436            allow_secrets: Some(true),
1437            allowed_env: Some(vec!["OPENAI_API_KEY".to_string(), "HOME".to_string()]),
1438        };
1439
1440        let policy = config.resolve_policy(AgentMode::Worker);
1441        assert!(!policy.allow_native_tool_calls);
1442        assert!(policy.allow_shell_exec);
1443        assert!(policy.allow_http);
1444        assert!(policy.allow_secrets);
1445        assert!(policy.allowed_env.contains("OPENAI_API_KEY"));
1446        assert!(policy.allowed_env.contains("HOME"));
1447    }
1448
1449    #[test]
1450    fn worker_lua_policy_preserves_configured_secret_access() {
1451        let enabled = LuaConfig {
1452            allow_secrets: Some(true),
1453            ..Default::default()
1454        };
1455        assert!(enabled.resolve_policy(AgentMode::Worker).allow_secrets);
1456
1457        let disabled = LuaConfig {
1458            allow_secrets: Some(false),
1459            ..Default::default()
1460        };
1461        assert!(!disabled.resolve_policy(AgentMode::Worker).allow_secrets);
1462
1463        assert!(
1464            !LuaConfig::default()
1465                .resolve_policy(AgentMode::Worker)
1466                .allow_secrets
1467        );
1468    }
1469
1470    #[test]
1471    fn agent_mode_full_allows_all_tools() {
1472        let mode = AgentMode::Full;
1473        assert!(mode.allows_tool("anything"));
1474        assert!(mode.allows_tool("read"));
1475        assert!(mode.allows_tool("shell"));
1476        assert!(mode.allows_tool("nonexistent_future_tool"));
1477        assert_eq!(mode.allowed_tool_names(), &[] as &[&str]);
1478    }
1479
1480    #[test]
1481    fn agent_mode_orchestrator_allows_read() {
1482        let mode = AgentMode::Orchestrator;
1483        assert!(mode.allows_tool("read"));
1484        assert!(mode.allows_tool("scan"));
1485        assert!(mode.allows_tool("web"));
1486        assert!(mode.allows_tool("git"));
1487        assert!(mode.allows_tool("recall"));
1488        assert!(mode.allows_tool("mana"));
1489        assert!(mode.allows_tool("ask"));
1490        assert!(mode.allows_tool("spawn"));
1491    }
1492
1493    #[test]
1494    fn agent_mode_orchestrator_blocks_write() {
1495        let mode = AgentMode::Orchestrator;
1496        assert!(!mode.allows_tool("write"));
1497        assert!(!mode.allows_tool("edit"));
1498        assert!(!mode.allows_tool("shell"));
1499    }
1500
1501    #[test]
1502    fn non_orchestrator_modes_block_spawn() {
1503        for mode in [
1504            AgentMode::Worker,
1505            AgentMode::Planner,
1506            AgentMode::Reviewer,
1507            AgentMode::Auditor,
1508        ] {
1509            assert!(
1510                !mode.allows_tool("spawn"),
1511                "mode {mode:?} should block spawn"
1512            );
1513        }
1514    }
1515
1516    #[test]
1517    fn agent_mode_planner_allows_mana_create() {
1518        let mode = AgentMode::Planner;
1519        assert!(mode.allows_mana_action("create"));
1520        assert!(mode.allows_mana_action("status"));
1521        assert!(mode.allows_mana_action("list"));
1522        assert!(mode.allows_mana_action("show"));
1523        assert!(mode.allows_tool("git"));
1524    }
1525
1526    #[test]
1527    fn agent_mode_planner_blocks_mana_close_and_run() {
1528        let mode = AgentMode::Planner;
1529        assert!(!mode.allows_mana_action("close"));
1530        assert!(!mode.allows_mana_action("run"));
1531        assert!(mode.allows_mana_action("update"));
1532        assert!(mode.allows_tool("git"));
1533    }
1534
1535    #[test]
1536    fn agent_mode_worker_blocks_mana_create() {
1537        let mode = AgentMode::Worker;
1538        assert!(!mode.allows_mana_action("create"));
1539        assert!(!mode.allows_mana_action("run"));
1540        assert!(!mode.allows_mana_action("close"));
1541        assert!(mode.allows_tool("git"));
1542    }
1543
1544    #[test]
1545    fn agent_mode_worker_allows_mana_update() {
1546        let mode = AgentMode::Worker;
1547        assert!(mode.allows_mana_action("update"));
1548        assert!(mode.allows_mana_action("show"));
1549        assert!(mode.allows_mana_action("status"));
1550        assert!(mode.allows_mana_action("list"));
1551    }
1552
1553    #[test]
1554    fn agent_mode_reviewer_no_mana() {
1555        let mode = AgentMode::Reviewer;
1556        assert!(!mode.allows_mana_action("status"));
1557        assert!(!mode.allows_mana_action("list"));
1558        assert!(!mode.allows_mana_action("show"));
1559        assert!(!mode.allows_mana_action("create"));
1560        assert!(!mode.allows_mana_action("run"));
1561        // Reviewer also has no mana tool access
1562        assert!(!mode.allows_tool("mana"));
1563        assert!(mode.allows_tool("git"));
1564    }
1565
1566    #[test]
1567    fn agent_mode_auditor_mana_readonly() {
1568        let mode = AgentMode::Auditor;
1569        assert!(mode.allows_mana_action("status"));
1570        assert!(mode.allows_mana_action("list"));
1571        assert!(mode.allows_mana_action("show"));
1572        assert!(!mode.allows_mana_action("create"));
1573        assert!(!mode.allows_mana_action("close"));
1574        assert!(!mode.allows_mana_action("run"));
1575        assert!(!mode.allows_mana_action("update"));
1576        assert!(mode.allows_tool("git"));
1577    }
1578
1579    #[test]
1580    fn agent_mode_config_deserialize() {
1581        let dir = TempDir::new().unwrap();
1582        let config_path = dir.path().join("config.toml");
1583        fs::write(&config_path, r#"mode = "orchestrator""#).unwrap();
1584        let config = Config::load(&config_path).unwrap();
1585        assert_eq!(config.mode, AgentMode::Orchestrator);
1586    }
1587
1588    #[test]
1589    fn agent_mode_instructions() {
1590        assert!(AgentMode::Full.instructions().is_none());
1591        assert!(AgentMode::Worker.instructions().is_some());
1592        assert!(AgentMode::Orchestrator.instructions().is_some());
1593        assert!(AgentMode::Planner.instructions().is_some());
1594        assert!(AgentMode::Reviewer.instructions().is_some());
1595        assert!(AgentMode::Auditor.instructions().is_some());
1596
1597        // Spot-check content is mode-specific
1598        let worker = AgentMode::Worker.instructions().unwrap();
1599        assert!(worker.contains("worker"));
1600        assert!(worker.contains("implement the assigned unit as specified"));
1601        assert!(
1602            worker.contains("final verification and closure belong to the orchestrator workflow")
1603        );
1604
1605        let orchestrator = AgentMode::Orchestrator.instructions().unwrap();
1606        assert!(orchestrator.contains("orchestrator agent"));
1607        assert!(orchestrator.contains("primary execution substrate"));
1608        assert!(orchestrator.contains("final verification, retry, and closure workflow"));
1609
1610        let reviewer = AgentMode::Reviewer.instructions().unwrap();
1611        assert!(reviewer.contains("reviewer") || reviewer.contains("read"));
1612    }
1613}