use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use imp_llm::ThinkingLevel;
use serde::{Deserialize, Serialize};
use crate::error::Result;
use crate::guardrails::GuardrailConfig;
use crate::hooks::HookDef;
use crate::personality::PersonalityConfig;
use crate::roles::RoleDef;
use crate::storage;
use crate::tools::web::types::WebConfig;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "kebab-case")]
pub enum AgentMode {
#[default]
Full,
Worker,
Orchestrator,
Planner,
Reviewer,
Auditor,
}
const WORKER_TOOLS: &[&str] = &[
"read", "scan", "web", "recall", "write", "edit", "bash", "git", "mana", "ask_user",
];
const ORCHESTRATOR_TOOLS: &[&str] = &["read", "scan", "web", "recall", "mana", "git", "ask_user"];
const PLANNER_TOOLS: &[&str] = &["read", "scan", "web", "recall", "git", "mana", "ask_user"];
const REVIEWER_TOOLS: &[&str] = &["read", "scan", "web", "recall", "git", "ask_user"];
const AUDITOR_TOOLS: &[&str] = &["read", "scan", "web", "recall", "git", "mana"];
const WORKER_MANA_ACTIONS: &[&str] = &[
"show",
"update",
"status",
"list",
"logs",
"next",
"verify",
"notes_append",
];
const ORCHESTRATOR_MANA_ACTIONS: &[&str] = &[
"status",
"list",
"show",
"create",
"close",
"update",
"run",
"run_state",
"evaluate",
"claim",
"release",
"logs",
"agents",
"next",
"tree",
"reopen",
"verify",
"fail",
"delete",
"dep_add",
"dep_remove",
"fact_create",
"fact_verify",
"notes_append",
"decision_add",
"decision_resolve",
];
const PLANNER_MANA_ACTIONS: &[&str] = &[
"status",
"list",
"show",
"create",
"update",
"next",
"tree",
"dep_add",
"dep_remove",
"fact_create",
"notes_append",
"decision_add",
"decision_resolve",
];
const AUDITOR_MANA_ACTIONS: &[&str] = &[
"status",
"list",
"show",
"logs",
"agents",
"next",
"tree",
"verify",
"fact_verify",
];
impl AgentMode {
pub fn allowed_tool_names(&self) -> &'static [&'static str] {
match self {
AgentMode::Full => &[],
AgentMode::Worker => WORKER_TOOLS,
AgentMode::Orchestrator => ORCHESTRATOR_TOOLS,
AgentMode::Planner => PLANNER_TOOLS,
AgentMode::Reviewer => REVIEWER_TOOLS,
AgentMode::Auditor => AUDITOR_TOOLS,
}
}
pub fn allows_tool(&self, name: &str) -> bool {
match self {
AgentMode::Full => true,
_ => self.allowed_tool_names().contains(&name),
}
}
pub fn allowed_mana_actions(&self) -> &'static [&'static str] {
match self {
AgentMode::Full | AgentMode::Reviewer => &[],
AgentMode::Worker => WORKER_MANA_ACTIONS,
AgentMode::Orchestrator => ORCHESTRATOR_MANA_ACTIONS,
AgentMode::Planner => PLANNER_MANA_ACTIONS,
AgentMode::Auditor => AUDITOR_MANA_ACTIONS,
}
}
pub fn allows_mana_action(&self, action: &str) -> bool {
match self {
AgentMode::Full => true,
AgentMode::Reviewer => false,
_ => self.allowed_mana_actions().contains(&action),
}
}
pub fn from_name(s: &str) -> Option<Self> {
match s.to_lowercase().as_str() {
"full" => Some(AgentMode::Full),
"worker" => Some(AgentMode::Worker),
"orchestrator" => Some(AgentMode::Orchestrator),
"planner" => Some(AgentMode::Planner),
"reviewer" => Some(AgentMode::Reviewer),
"auditor" => Some(AgentMode::Auditor),
_ => None,
}
}
pub fn instructions(&self) -> Option<&'static str> {
match self {
AgentMode::Full => None,
AgentMode::Worker => Some(
"You are a worker agent. Your job is to implement the assigned unit as specified and stay within its scope. \
You may read files, write files, and run shell commands. Inspect the relevant files before making claims or changes, \
use fast scoped checks for local feedback while implementing, and record meaningful progress or failure context with `mana update`. \
Do not declare success if commands or checks fail; report the exact blocker and the next useful action. \
Treat mana units as execution contracts: use their scope, dependencies, acceptance criteria, and verify gate before broadening the work. \
You may not create, run, or close mana units — final verification and closure belong to the orchestrator workflow.",
),
AgentMode::Orchestrator => Some(
"You are an orchestrator agent. Use mana as your primary execution substrate for non-trivial work. \
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. \
Write detailed units, split larger efforts into child units with dependencies, dispatch workers through mana, and own the final verification, retry, and closure workflow. \
Use the full mana unit vocabulary when it helps: acceptance criteria, labels, dependencies, paths, requires, produces, decisions, and feature boundaries. \
Encode unresolved questions as decisions instead of burying ambiguity in prose. \
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. \
Prefer native mana actions, including scope-aware and append-style updates, over shell or direct file edits for maintaining the work graph. \
You may not read or write files directly — create and dispatch mana units for all file work. \
Update units with concrete failure context and do not retry unchanged failed plans. \
You are responsible for unit structure, completeness, and verify quality.",
),
AgentMode::Planner => Some(
"You are a planner agent. Your job is to decompose work into mana units. \
Read enough code and context to ground the plan, cite concrete files or constraints when they matter, \
and make dependencies, sequencing, acceptance criteria, and verify commands explicit. \
Write worker-ready unit descriptions that include current state, concrete steps, file paths with intent, embedded context, scope boundaries, and what not to do. \
Record unresolved questions as decisions when autonomous execution would otherwise require guessing. \
Externalize durable planning structure into mana during the conversation, not only after the plan is complete. \
Prefer append-style mana updates to keep the graph current as ideas sharpen. \
You may read files and create units, but you may not run them — \
a human or orchestrator will approve execution.",
),
AgentMode::Reviewer => Some(
"You are a reviewer agent. Your job is to read code and report findings. \
Ground findings in inspected code, cite exact files or symbols when useful, and distinguish confirmed issues from possible concerns. \
You may not write files, run commands, or use mana.",
),
AgentMode::Auditor => Some(
"You are an auditor agent. Your job is to inspect code and mana state \
and produce structured reports. Ground conclusions in inspected evidence, cite the relevant files or mana objects, \
and clearly separate facts, risks, and open questions. You may read files and mana status, \
but you may not modify anything.",
),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum WriteOverwritePolicy {
#[default]
Warn,
RequireRead,
BlockStale,
Deny,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
pub struct WriteConfig {
#[serde(default)]
pub overwrite_policy: WriteOverwritePolicy,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
#[serde(rename_all = "kebab-case")]
pub enum ShellBackend {
#[default]
Sh,
Rush,
RushDaemon,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ShellConfig {
#[serde(default)]
pub backend: ShellBackend,
#[serde(default)]
pub command: Option<String>,
}
impl Default for ShellConfig {
fn default() -> Self {
Self {
backend: ShellBackend::Sh,
command: None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LuaCapabilityPolicy {
pub allow_native_tool_calls: bool,
pub allow_shell_exec: bool,
pub allow_http: bool,
pub allow_secrets: bool,
pub allowed_env: HashSet<String>,
}
impl Default for LuaCapabilityPolicy {
fn default() -> Self {
Self {
allow_native_tool_calls: true,
allow_shell_exec: false,
allow_http: false,
allow_secrets: false,
allowed_env: HashSet::new(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
pub struct LuaConfig {
pub allow_native_tool_calls: Option<bool>,
pub allow_shell_exec: Option<bool>,
pub allow_http: Option<bool>,
pub allow_secrets: Option<bool>,
pub allowed_env: Option<Vec<String>>,
}
impl LuaConfig {
#[must_use]
pub fn resolve_policy(&self, mode: AgentMode) -> LuaCapabilityPolicy {
let mut policy = LuaCapabilityPolicy::default();
if matches!(mode, AgentMode::Worker) {
policy.allow_secrets = self.allow_secrets.unwrap_or(false);
}
if let Some(value) = self.allow_native_tool_calls {
policy.allow_native_tool_calls = value;
}
if let Some(value) = self.allow_shell_exec {
policy.allow_shell_exec = value;
}
if let Some(value) = self.allow_http {
policy.allow_http = value;
}
if let Some(value) = self.allow_secrets {
policy.allow_secrets = value;
}
if let Some(values) = &self.allowed_env {
policy.allowed_env = values.iter().cloned().collect();
}
policy
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct SecretsConfig {
#[serde(default)]
pub commands: CommandSecretsConfig,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct CommandSecretsConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub allowed: Vec<AllowedCommandSecret>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct AllowedCommandSecret {
pub name: String,
}
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
pub enum ManaScopePreference {
#[default]
Project,
Root,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ManaRunConfig {
#[serde(default = "default_enabled")]
pub background: bool,
#[serde(default = "default_mana_run_jobs")]
pub max_workers: u32,
#[serde(default)]
pub continue_after_failure: bool,
#[serde(default)]
pub review_after_run: bool,
}
impl Default for ManaRunConfig {
fn default() -> Self {
Self {
background: true,
max_workers: 4,
continue_after_failure: false,
review_after_run: false,
}
}
}
impl ManaRunConfig {
fn is_default(&self) -> bool {
self == &Self::default()
}
}
fn default_enabled() -> bool {
true
}
fn default_mana_run_jobs() -> u32 {
4
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ManaConfig {
#[serde(default)]
pub scope: ManaScopePreference,
#[serde(default)]
pub auto_commit: bool,
#[serde(default = "default_true")]
pub auto_close_parent: bool,
#[serde(default)]
pub verify_timeout: Option<u64>,
#[serde(default, skip_serializing_if = "ManaRunConfig::is_default")]
pub run: ManaRunConfig,
}
impl Default for ManaConfig {
fn default() -> Self {
Self {
scope: ManaScopePreference::Project,
auto_commit: false,
auto_close_parent: true,
verify_timeout: None,
run: ManaRunConfig::default(),
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Config {
pub model: Option<String>,
pub thinking: Option<ThinkingLevel>,
pub max_tokens: Option<u32>,
pub max_turns: Option<u32>,
pub tools: Option<Vec<String>>,
#[serde(default)]
pub roles: HashMap<String, RoleDef>,
#[serde(default)]
pub hooks: Vec<HookDef>,
#[serde(default)]
pub context: ContextConfig,
#[serde(default)]
pub shell: ShellConfig,
#[serde(default)]
pub write: WriteConfig,
#[serde(default)]
pub guardrails: GuardrailConfig,
#[serde(default)]
pub mode: AgentMode,
#[serde(default)]
pub enabled_models: Option<Vec<String>>,
pub theme: Option<String>,
#[serde(default)]
pub learning: LearningConfig,
#[serde(default)]
pub ui: UiConfig,
#[serde(default)]
pub web: WebConfig,
#[serde(default)]
pub mana: ManaConfig,
#[serde(default)]
pub lua: LuaConfig,
#[serde(default)]
pub secrets: SecretsConfig,
#[serde(default)]
pub personality: PersonalityConfig,
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum SidebarStyle {
#[default]
Inspector,
Stream,
Split,
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ToolOutputDisplay {
Full,
#[default]
Compact,
Collapsed,
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum ChatToolDisplay {
Interleaved,
#[default]
Summary,
Hidden,
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum AnimationLevel {
None,
Spinner,
#[default]
#[serde(alias = "full")]
Minimal,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "kebab-case")]
pub enum ContinuePolicy {
#[default]
Disabled,
Conservative,
Balanced,
Aggressive,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct UiConfig {
#[serde(default)]
pub sidebar_style: SidebarStyle,
#[serde(default)]
pub tool_output: ToolOutputDisplay,
#[serde(default = "default_tool_output_lines")]
pub tool_output_lines: usize,
#[serde(default = "default_read_max_lines")]
pub read_max_lines: usize,
#[serde(default = "default_sidebar_width")]
pub sidebar_width: u16,
#[serde(default = "default_true")]
pub word_wrap: bool,
#[serde(default)]
pub animations: AnimationLevel,
#[serde(default)]
pub hide_tools_in_chat: bool,
#[serde(default)]
pub chat_tool_display: ChatToolDisplay,
#[serde(default = "default_true")]
pub auto_open_sidebar: bool,
#[serde(default = "default_sidebar_auto_open_width")]
pub sidebar_auto_open_width: u16,
#[serde(default = "default_thinking_lines")]
pub thinking_lines: usize,
#[serde(default = "default_streaming_lines")]
pub streaming_lines: usize,
#[serde(default = "default_mouse_scroll_lines")]
pub mouse_scroll_lines: usize,
#[serde(default = "default_keyboard_scroll_lines")]
pub keyboard_scroll_lines: usize,
#[serde(default)]
#[doc(hidden)]
pub mouse_capture: bool,
#[serde(default)]
pub show_timestamps: bool,
#[serde(default = "default_true")]
pub show_cost: bool,
#[serde(default = "default_true")]
pub show_context_usage: bool,
#[serde(default = "default_true")]
pub notify_on_agent_complete: bool,
#[serde(default)]
pub continue_policy: ContinuePolicy,
#[serde(default = "default_build_auto_turn_budget")]
pub build_auto_turn_budget: u32,
#[serde(default = "default_improve_auto_turn_budget")]
pub improve_auto_turn_budget: u32,
#[serde(default)]
pub loop_turn_budget: u32,
}
fn default_tool_output_lines() -> usize {
10
}
fn default_read_max_lines() -> usize {
500
}
fn default_sidebar_width() -> u16 {
40
}
fn default_sidebar_auto_open_width() -> u16 {
120
}
fn default_thinking_lines() -> usize {
5
}
fn default_streaming_lines() -> usize {
5
}
fn default_mouse_scroll_lines() -> usize {
3
}
fn default_keyboard_scroll_lines() -> usize {
20
}
fn default_build_auto_turn_budget() -> u32 {
20
}
fn default_improve_auto_turn_budget() -> u32 {
5
}
impl Default for UiConfig {
fn default() -> Self {
Self {
sidebar_style: SidebarStyle::default(),
tool_output: ToolOutputDisplay::default(),
tool_output_lines: default_tool_output_lines(),
read_max_lines: default_read_max_lines(),
sidebar_width: default_sidebar_width(),
word_wrap: default_true(),
animations: AnimationLevel::default(),
hide_tools_in_chat: false,
chat_tool_display: ChatToolDisplay::default(),
auto_open_sidebar: default_true(),
sidebar_auto_open_width: default_sidebar_auto_open_width(),
thinking_lines: default_thinking_lines(),
streaming_lines: default_streaming_lines(),
mouse_scroll_lines: default_mouse_scroll_lines(),
keyboard_scroll_lines: default_keyboard_scroll_lines(),
mouse_capture: false,
show_timestamps: false,
show_cost: true,
show_context_usage: true,
notify_on_agent_complete: true,
continue_policy: ContinuePolicy::Disabled,
build_auto_turn_budget: default_build_auto_turn_budget(),
improve_auto_turn_budget: default_improve_auto_turn_budget(),
loop_turn_budget: 0,
}
}
}
impl UiConfig {
pub fn effective_chat_tool_display(&self) -> ChatToolDisplay {
if self.hide_tools_in_chat && self.sidebar_style != SidebarStyle::Inspector {
ChatToolDisplay::Hidden
} else if self.sidebar_style == SidebarStyle::Inspector {
ChatToolDisplay::Summary
} else {
self.chat_tool_display
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct LearningConfig {
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default = "default_nudge_threshold")]
pub skill_nudge_threshold: u32,
#[serde(default = "default_memory_limit")]
pub memory_char_limit: usize,
#[serde(default = "default_user_limit")]
pub user_char_limit: usize,
}
fn default_true() -> bool {
true
}
fn default_nudge_threshold() -> u32 {
8
}
fn default_memory_limit() -> usize {
2200
}
fn default_user_limit() -> usize {
1400
}
impl Default for LearningConfig {
fn default() -> Self {
Self {
enabled: default_true(),
skill_nudge_threshold: default_nudge_threshold(),
memory_char_limit: default_memory_limit(),
user_char_limit: default_user_limit(),
}
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "kebab-case")]
pub enum AutoCompactionMode {
#[default]
Disabled,
NearThreshold,
Aggressive,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct AutoCompactionConfig {
#[serde(default)]
pub mode: AutoCompactionMode,
}
impl Default for AutoCompactionConfig {
fn default() -> Self {
Self {
mode: AutoCompactionMode::Disabled,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ContextConfig {
pub observation_mask_threshold: f64,
pub mask_window: usize,
#[serde(default)]
pub auto_compaction: AutoCompactionConfig,
}
impl Default for ContextConfig {
fn default() -> Self {
Self {
observation_mask_threshold: 0.6,
mask_window: 10,
auto_compaction: AutoCompactionConfig::default(),
}
}
}
impl Config {
pub fn load(path: &Path) -> Result<Self> {
if !path.exists() {
return Ok(Self::default());
}
let content = std::fs::read_to_string(path)?;
let config: Config = toml::from_str(&content)?;
Ok(config)
}
pub fn resolve(user_config_dir: &Path, project_dir: Option<&Path>) -> Result<Self> {
let mut config = Self::default();
let user_path = user_config_dir.join("config.toml");
if user_path.exists() {
let user = Self::load(&user_path)?;
config.merge(user);
}
if let Some(project) = project_dir {
let project_path = project.join(".imp").join("config.toml");
if project_path.exists() {
let project = Self::load(&project_path)?;
config.merge(project);
}
}
if let Ok(model) = std::env::var("IMP_MODEL") {
config.model = Some(model);
}
if let Ok(thinking) = std::env::var("IMP_THINKING") {
config.thinking = parse_thinking_level(&thinking);
}
if let Ok(max_tokens) = std::env::var("IMP_MAX_TOKENS") {
if let Ok(parsed) = max_tokens.parse::<u32>() {
config.max_tokens = Some(parsed);
}
}
if let Ok(mode) = std::env::var("IMP_MODE") {
if let Some(m) = parse_agent_mode(&mode) {
config.mode = m;
}
}
if let Ok(provider) = std::env::var("IMP_WEB_PROVIDER") {
config.web.search_provider = match provider.to_lowercase().as_str() {
"tavily" => Some(crate::tools::web::types::SearchProvider::Tavily),
"exa" => Some(crate::tools::web::types::SearchProvider::Exa),
"linkup" => Some(crate::tools::web::types::SearchProvider::Linkup),
"perplexity" => Some(crate::tools::web::types::SearchProvider::Perplexity),
_ => config.web.search_provider,
};
}
Ok(config)
}
fn merge(&mut self, other: Config) {
if other.model.is_some() {
self.model = other.model;
}
if other.thinking.is_some() {
self.thinking = other.thinking;
}
if other.max_tokens.is_some() {
self.max_tokens = other.max_tokens;
}
if other.max_turns.is_some() {
self.max_turns = other.max_turns;
}
if other.tools.is_some() {
self.tools = other.tools;
}
if other.context != ContextConfig::default() {
self.context = other.context;
}
if other.shell != ShellConfig::default() {
self.shell = other.shell;
}
self.guardrails.merge(other.guardrails);
if other.mode != AgentMode::default() {
self.mode = other.mode;
}
if other.enabled_models.is_some() {
self.enabled_models = other.enabled_models;
}
if other.theme.is_some() {
self.theme = other.theme;
}
if other.learning != LearningConfig::default() {
self.learning = other.learning;
}
if other.ui != UiConfig::default() {
self.ui = other.ui;
}
if other.web != WebConfig::default() {
self.web = other.web;
}
if other.mana != ManaConfig::default() {
self.mana = other.mana;
}
if other.lua != LuaConfig::default() {
self.lua = other.lua;
}
if other.secrets != SecretsConfig::default() {
self.secrets = other.secrets;
}
if other.personality != PersonalityConfig::default() {
self.personality.merge(other.personality);
}
self.roles.extend(other.roles);
self.hooks.extend(other.hooks);
}
pub fn user_config_dir() -> PathBuf {
storage::global_root()
}
pub fn session_dir() -> PathBuf {
storage::global_sessions_dir()
}
pub fn save(&self, path: &Path) -> Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let content =
toml::to_string_pretty(self).map_err(|e| crate::error::Error::Config(e.to_string()))?;
std::fs::write(path, content)?;
Ok(())
}
pub fn user_config_path() -> PathBuf {
storage::global_config_path()
}
}
fn parse_agent_mode(s: &str) -> Option<AgentMode> {
AgentMode::from_name(s)
}
fn parse_thinking_level(s: &str) -> Option<ThinkingLevel> {
match s.to_lowercase().as_str() {
"off" => Some(ThinkingLevel::Off),
"minimal" => Some(ThinkingLevel::Minimal),
"low" => Some(ThinkingLevel::Low),
"medium" => Some(ThinkingLevel::Medium),
"high" => Some(ThinkingLevel::High),
"xhigh" => Some(ThinkingLevel::XHigh),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
#[test]
fn config_default_values() {
let config = Config::default();
assert!(config.model.is_none());
assert!(config.thinking.is_none());
assert!(config.max_tokens.is_none());
assert!(config.max_turns.is_none());
assert!(config.tools.is_none());
assert_eq!(config.ui.read_max_lines, 500);
assert_eq!(config.ui.sidebar_style, SidebarStyle::Inspector);
assert_eq!(config.ui.chat_tool_display, ChatToolDisplay::Summary);
assert_eq!(config.ui.tool_output, ToolOutputDisplay::Compact);
assert_eq!(config.web, WebConfig::default());
assert_eq!(config.personality, PersonalityConfig::default());
assert!(config.roles.is_empty());
assert!(config.hooks.is_empty());
assert!((config.context.observation_mask_threshold - 0.6).abs() < f64::EPSILON);
assert_eq!(config.context.mask_window, 10);
assert_eq!(
config.context.auto_compaction.mode,
AutoCompactionMode::Disabled
);
assert_eq!(config.guardrails, GuardrailConfig::default());
}
#[test]
fn inspector_sidebar_keeps_tool_calls_in_chat_summary() {
let mut ui = UiConfig {
sidebar_style: SidebarStyle::Inspector,
chat_tool_display: ChatToolDisplay::Interleaved,
..Default::default()
};
assert_eq!(ui.effective_chat_tool_display(), ChatToolDisplay::Summary);
ui.chat_tool_display = ChatToolDisplay::Hidden;
ui.hide_tools_in_chat = true;
assert_eq!(ui.effective_chat_tool_display(), ChatToolDisplay::Summary);
ui.sidebar_style = SidebarStyle::Stream;
assert_eq!(ui.effective_chat_tool_display(), ChatToolDisplay::Hidden);
}
#[test]
fn config_load_from_toml() {
let dir = TempDir::new().unwrap();
let config_path = dir.path().join("config.toml");
fs::write(
&config_path,
r#"
model = "sonnet"
thinking = "high"
max_tokens = 2048
max_turns = 50
tools = ["read", "write", "bash"]
[guardrails]
enabled = true
level = "enforce"
profile = "zig"
critical_paths = ["src/**"]
after_write = ["zig fmt --check ."]
[context]
observation_mask_threshold = 0.5
mask_window = 5
[shell]
command = "zsh"
[web]
search_provider = "exa"
"#,
)
.unwrap();
let config = Config::load(&config_path).unwrap();
assert_eq!(config.model.as_deref(), Some("sonnet"));
assert_eq!(config.thinking, Some(ThinkingLevel::High));
assert_eq!(config.max_tokens, Some(2048));
assert_eq!(config.max_turns, Some(50));
assert_eq!(config.tools.as_ref().unwrap().len(), 3);
assert_eq!(config.guardrails.enabled, Some(true));
assert_eq!(config.ui.read_max_lines, 500);
assert_eq!(
config.guardrails.profile,
Some(crate::guardrails::GuardrailProfile::Zig)
);
assert_eq!(
config.guardrails.after_write,
Some(vec!["zig fmt --check .".into()])
);
assert_eq!(config.shell.command.as_deref(), Some("zsh"));
assert_eq!(
config.web.search_provider,
Some(crate::tools::web::types::SearchProvider::Exa)
);
assert!((config.context.observation_mask_threshold - 0.5).abs() < f64::EPSILON);
assert_eq!(config.context.mask_window, 5);
assert_eq!(
config.context.auto_compaction.mode,
AutoCompactionMode::Disabled
);
}
#[test]
fn config_load_missing_file_returns_default() {
let dir = TempDir::new().unwrap();
let config_path = dir.path().join("nonexistent.toml");
let config = Config::load(&config_path).unwrap();
assert!(config.model.is_none());
}
#[test]
fn config_loads_personality_section() {
let dir = TempDir::new().unwrap();
let config_path = dir.path().join("config.toml");
fs::write(
&config_path,
r#"
[personality.profile.identity]
name = "Nova"
work_style = "careful"
voice = "clear"
focus = "research"
role = "assistant"
[personality.profile.sliders]
autonomy = "low"
verbosity = "high"
caution = "very-high"
warmth = "high"
planning_depth = "very-high"
[personality.profiles]
active = "researcher"
[personality.profiles.saved.researcher.identity]
name = "Nova"
work_style = "careful"
voice = "clear"
focus = "research"
role = "assistant"
"#,
)
.unwrap();
let config = Config::load(&config_path).unwrap();
assert_eq!(config.personality.profile.identity.name, "Nova");
assert_eq!(
config.personality.profile.identity.render_sentence(),
"You are Nova, a careful, clear, research assistant."
);
assert_eq!(
config.personality.profiles.active.as_deref(),
Some("researcher")
);
assert!(config.personality.profiles.saved.contains_key("researcher"));
}
#[test]
fn config_merge_personality_project_overrides_user_and_keeps_saved_profiles() {
let mut user = Config::default();
user.personality.profile.identity.name = "imp".into();
user.personality.profiles.active = Some("builder".into());
user.personality.profiles.saved.insert(
"builder".into(),
crate::personality::PersonalityProfile::default(),
);
let mut project = Config::default();
project.personality.profile.identity.name = "Patch".into();
project.personality.profiles.active = Some("reviewer".into());
project.personality.profiles.saved.insert(
"reviewer".into(),
crate::personality::PersonalityProfile::default(),
);
user.merge(project);
assert_eq!(user.personality.profile.identity.name, "Patch");
assert_eq!(
user.personality.profiles.active.as_deref(),
Some("reviewer")
);
assert!(user.personality.profiles.saved.contains_key("builder"));
assert!(user.personality.profiles.saved.contains_key("reviewer"));
}
#[test]
fn config_merge_project_overrides_user() {
let mut user = Config {
model: Some("haiku".into()),
max_tokens: Some(1024),
max_turns: Some(20),
..Default::default()
};
let project = Config {
model: Some("sonnet".into()),
max_tokens: None,
max_turns: None, ..Default::default()
};
user.merge(project);
assert_eq!(user.model.as_deref(), Some("sonnet"));
assert_eq!(user.max_tokens, Some(1024));
assert_eq!(user.max_turns, Some(20));
}
#[test]
fn config_merge_roles_extend() {
let mut base = Config::default();
base.roles.insert(
"worker".into(),
RoleDef {
model: Some("haiku".into()),
thinking: None,
tools: None,
readonly: false,
instructions: None,
},
);
let overlay = Config {
roles: {
let mut m = HashMap::new();
m.insert(
"reviewer".into(),
RoleDef {
model: Some("sonnet".into()),
thinking: Some(ThinkingLevel::High),
tools: None,
readonly: true,
instructions: None,
},
);
m
},
..Default::default()
};
base.merge(overlay);
assert!(base.roles.contains_key("worker"));
assert!(base.roles.contains_key("reviewer"));
}
#[test]
fn config_merge_hooks_extend() {
let mut base = Config::default();
base.hooks.push(HookDef {
event: "after_file_write".into(),
match_pattern: None,
action: "log".into(),
command: None,
blocking: false,
threshold: None,
});
let overlay = Config {
hooks: vec![HookDef {
event: "before_tool_call".into(),
match_pattern: None,
action: "block".into(),
command: None,
blocking: true,
threshold: None,
}],
..Default::default()
};
base.merge(overlay);
assert_eq!(base.hooks.len(), 2);
}
#[test]
fn config_merge_context_overrides_default() {
let mut base = Config::default();
let overlay = Config {
context: ContextConfig {
observation_mask_threshold: 0.5,
mask_window: 5,
auto_compaction: AutoCompactionConfig {
mode: AutoCompactionMode::NearThreshold,
},
},
..Default::default()
};
base.merge(overlay);
assert!((base.context.observation_mask_threshold - 0.5).abs() < f64::EPSILON);
assert_eq!(base.context.mask_window, 5);
assert_eq!(
base.context.auto_compaction.mode,
AutoCompactionMode::NearThreshold
);
}
#[test]
fn config_merge_includes_theme_learning_and_lua() {
let mut base = Config::default();
let overlay = Config {
theme: Some("light".into()),
learning: LearningConfig {
enabled: false,
skill_nudge_threshold: 3,
memory_char_limit: 1000,
user_char_limit: 700,
},
lua: LuaConfig {
allow_native_tool_calls: Some(false),
allow_shell_exec: Some(true),
allow_http: None,
allow_secrets: None,
allowed_env: Some(vec!["HOME".into()]),
},
..Default::default()
};
base.merge(overlay);
assert_eq!(base.theme.as_deref(), Some("light"));
assert_eq!(base.learning.skill_nudge_threshold, 3);
assert!(!base.learning.enabled);
assert_eq!(base.lua.allow_native_tool_calls, Some(false));
assert_eq!(base.lua.allow_shell_exec, Some(true));
assert_eq!(base.lua.allowed_env, Some(vec!["HOME".into()]));
}
#[test]
fn config_merge_guardrails_preserves_unspecified_fields() {
let mut base = Config::default();
base.guardrails.enabled = Some(true);
base.guardrails.profile = Some(crate::guardrails::GuardrailProfile::Rust);
base.guardrails.critical_paths = Some(vec!["src/**".into()]);
let mut overlay = Config::default();
overlay.guardrails.level = Some(crate::guardrails::GuardrailLevel::Enforce);
overlay.guardrails.after_write = Some(vec!["cargo test".into()]);
base.merge(overlay);
assert_eq!(base.guardrails.enabled, Some(true));
assert_eq!(
base.guardrails.profile,
Some(crate::guardrails::GuardrailProfile::Rust)
);
assert_eq!(base.guardrails.critical_paths, Some(vec!["src/**".into()]));
assert_eq!(
base.guardrails.level,
Some(crate::guardrails::GuardrailLevel::Enforce)
);
assert_eq!(base.guardrails.after_write, Some(vec!["cargo test".into()]));
}
#[test]
fn config_resolve_user_then_project() {
std::env::remove_var("IMP_MODEL");
std::env::remove_var("IMP_THINKING");
let dir = TempDir::new().unwrap();
let user_dir = dir.path().join("user");
let project_dir = dir.path().join("project");
fs::create_dir_all(&user_dir).unwrap();
fs::create_dir_all(project_dir.join(".imp")).unwrap();
fs::write(
user_dir.join("config.toml"),
r#"
model = "haiku"
max_turns = 20
[context]
observation_mask_threshold = 0.55
mask_window = 9
[context.auto_compaction]
mode = "disabled"
"#,
)
.unwrap();
fs::write(
project_dir.join(".imp").join("config.toml"),
r#"
model = "sonnet"
[context]
observation_mask_threshold = 0.5
mask_window = 5
[context.auto_compaction]
mode = "disabled"
"#,
)
.unwrap();
let config = Config::resolve(&user_dir, Some(&project_dir)).unwrap();
assert_eq!(config.model.as_deref(), Some("sonnet"));
assert_eq!(config.max_turns, Some(20));
assert!((config.context.observation_mask_threshold - 0.5).abs() < f64::EPSILON);
assert_eq!(config.context.mask_window, 5);
}
#[test]
fn config_resolve_env_overrides() {
let mut config = Config {
model: Some("haiku".into()),
thinking: Some(ThinkingLevel::Low),
max_tokens: Some(2048),
..Default::default()
};
let env_model = "opus";
config.model = Some(env_model.into());
let env_thinking = "high";
config.thinking = parse_thinking_level(env_thinking);
let env_max_tokens = "1024";
config.max_tokens = env_max_tokens.parse::<u32>().ok();
assert_eq!(config.model.as_deref(), Some("opus"));
assert_eq!(config.thinking, Some(ThinkingLevel::High));
assert_eq!(config.max_tokens, Some(1024));
}
#[test]
fn config_resolve_missing_files_uses_defaults() {
let dir = TempDir::new().unwrap();
let config = Config::resolve(dir.path(), None).unwrap();
assert!(config.model.is_none());
assert!(config.thinking.is_none());
assert!(config.max_tokens.is_none());
assert!(config.max_turns.is_none());
}
#[test]
fn config_load_with_roles_and_hooks() {
let dir = TempDir::new().unwrap();
let config_path = dir.path().join("config.toml");
fs::write(
&config_path,
r#"
model = "sonnet"
[roles.coder]
model = "opus"
thinking = "high"
readonly = false
[roles.reader]
readonly = true
[[hooks]]
event = "after_file_write"
action = "log"
blocking = false
"#,
)
.unwrap();
let config = Config::load(&config_path).unwrap();
assert_eq!(config.roles.len(), 2);
assert!(config.roles.contains_key("coder"));
assert!(config.roles.contains_key("reader"));
assert_eq!(config.roles["coder"].model.as_deref(), Some("opus"));
assert!(config.roles["reader"].readonly);
assert_eq!(config.hooks.len(), 1);
assert_eq!(config.hooks[0].event, "after_file_write");
}
#[test]
fn config_parse_thinking_levels() {
assert_eq!(parse_thinking_level("off"), Some(ThinkingLevel::Off));
assert_eq!(
parse_thinking_level("minimal"),
Some(ThinkingLevel::Minimal)
);
assert_eq!(parse_thinking_level("low"), Some(ThinkingLevel::Low));
assert_eq!(parse_thinking_level("medium"), Some(ThinkingLevel::Medium));
assert_eq!(parse_thinking_level("high"), Some(ThinkingLevel::High));
assert_eq!(parse_thinking_level("xhigh"), Some(ThinkingLevel::XHigh));
assert_eq!(parse_thinking_level("OFF"), Some(ThinkingLevel::Off));
assert_eq!(parse_thinking_level("High"), Some(ThinkingLevel::High));
assert_eq!(parse_thinking_level("invalid"), None);
assert_eq!(parse_thinking_level(""), None);
}
#[test]
fn config_partial_toml_fills_defaults() {
let dir = TempDir::new().unwrap();
let config_path = dir.path().join("config.toml");
fs::write(
&config_path,
r#"
model = "sonnet"
"#,
)
.unwrap();
let config = Config::load(&config_path).unwrap();
assert_eq!(config.model.as_deref(), Some("sonnet"));
assert!(config.thinking.is_none());
assert!(config.max_tokens.is_none());
assert!(config.max_turns.is_none());
assert!((config.context.observation_mask_threshold - 0.6).abs() < f64::EPSILON);
}
#[test]
fn agent_mode_default_is_full() {
let config = Config::default();
assert_eq!(config.mode, AgentMode::Full);
assert_eq!(AgentMode::default(), AgentMode::Full);
}
#[test]
fn lua_config_resolves_capability_policy() {
let config = LuaConfig {
allow_native_tool_calls: Some(false),
allow_shell_exec: Some(true),
allow_http: Some(true),
allow_secrets: Some(true),
allowed_env: Some(vec!["OPENAI_API_KEY".to_string(), "HOME".to_string()]),
};
let policy = config.resolve_policy(AgentMode::Worker);
assert!(!policy.allow_native_tool_calls);
assert!(policy.allow_shell_exec);
assert!(policy.allow_http);
assert!(policy.allow_secrets);
assert!(policy.allowed_env.contains("OPENAI_API_KEY"));
assert!(policy.allowed_env.contains("HOME"));
}
#[test]
fn worker_lua_policy_preserves_configured_secret_access() {
let enabled = LuaConfig {
allow_secrets: Some(true),
..Default::default()
};
assert!(enabled.resolve_policy(AgentMode::Worker).allow_secrets);
let disabled = LuaConfig {
allow_secrets: Some(false),
..Default::default()
};
assert!(!disabled.resolve_policy(AgentMode::Worker).allow_secrets);
assert!(
!LuaConfig::default()
.resolve_policy(AgentMode::Worker)
.allow_secrets
);
}
#[test]
fn agent_mode_full_allows_all_tools() {
let mode = AgentMode::Full;
assert!(mode.allows_tool("anything"));
assert!(mode.allows_tool("read"));
assert!(mode.allows_tool("bash"));
assert!(mode.allows_tool("nonexistent_future_tool"));
assert_eq!(mode.allowed_tool_names(), &[] as &[&str]);
}
#[test]
fn agent_mode_orchestrator_allows_read() {
let mode = AgentMode::Orchestrator;
assert!(mode.allows_tool("read"));
assert!(mode.allows_tool("scan"));
assert!(mode.allows_tool("web"));
assert!(mode.allows_tool("git"));
assert!(mode.allows_tool("recall"));
assert!(mode.allows_tool("mana"));
assert!(mode.allows_tool("ask_user"));
}
#[test]
fn agent_mode_orchestrator_blocks_write() {
let mode = AgentMode::Orchestrator;
assert!(!mode.allows_tool("write"));
assert!(!mode.allows_tool("edit"));
assert!(!mode.allows_tool("bash"));
}
#[test]
fn non_full_modes_block_removed_ask_agent() {
for mode in [
AgentMode::Worker,
AgentMode::Orchestrator,
AgentMode::Planner,
AgentMode::Reviewer,
AgentMode::Auditor,
] {
assert!(
!mode.allows_tool("ask_agent"),
"mode {mode:?} should block removed ask_agent"
);
}
}
#[test]
fn agent_mode_planner_allows_mana_create() {
let mode = AgentMode::Planner;
assert!(mode.allows_mana_action("create"));
assert!(mode.allows_mana_action("status"));
assert!(mode.allows_mana_action("list"));
assert!(mode.allows_mana_action("show"));
assert!(mode.allows_tool("git"));
}
#[test]
fn agent_mode_planner_blocks_mana_close_and_run() {
let mode = AgentMode::Planner;
assert!(!mode.allows_mana_action("close"));
assert!(!mode.allows_mana_action("run"));
assert!(mode.allows_mana_action("update"));
assert!(mode.allows_tool("git"));
}
#[test]
fn agent_mode_worker_blocks_mana_create() {
let mode = AgentMode::Worker;
assert!(!mode.allows_mana_action("create"));
assert!(!mode.allows_mana_action("run"));
assert!(!mode.allows_mana_action("close"));
assert!(mode.allows_tool("git"));
}
#[test]
fn agent_mode_worker_allows_mana_update() {
let mode = AgentMode::Worker;
assert!(mode.allows_mana_action("update"));
assert!(mode.allows_mana_action("show"));
assert!(mode.allows_mana_action("status"));
assert!(mode.allows_mana_action("list"));
}
#[test]
fn agent_mode_reviewer_no_mana() {
let mode = AgentMode::Reviewer;
assert!(!mode.allows_mana_action("status"));
assert!(!mode.allows_mana_action("list"));
assert!(!mode.allows_mana_action("show"));
assert!(!mode.allows_mana_action("create"));
assert!(!mode.allows_mana_action("run"));
assert!(!mode.allows_tool("mana"));
assert!(mode.allows_tool("git"));
}
#[test]
fn agent_mode_auditor_mana_readonly() {
let mode = AgentMode::Auditor;
assert!(mode.allows_mana_action("status"));
assert!(mode.allows_mana_action("list"));
assert!(mode.allows_mana_action("show"));
assert!(!mode.allows_mana_action("create"));
assert!(!mode.allows_mana_action("close"));
assert!(!mode.allows_mana_action("run"));
assert!(!mode.allows_mana_action("update"));
assert!(mode.allows_tool("git"));
}
#[test]
fn agent_mode_config_deserialize() {
let dir = TempDir::new().unwrap();
let config_path = dir.path().join("config.toml");
fs::write(&config_path, r#"mode = "orchestrator""#).unwrap();
let config = Config::load(&config_path).unwrap();
assert_eq!(config.mode, AgentMode::Orchestrator);
}
#[test]
fn agent_mode_instructions() {
assert!(AgentMode::Full.instructions().is_none());
assert!(AgentMode::Worker.instructions().is_some());
assert!(AgentMode::Orchestrator.instructions().is_some());
assert!(AgentMode::Planner.instructions().is_some());
assert!(AgentMode::Reviewer.instructions().is_some());
assert!(AgentMode::Auditor.instructions().is_some());
let worker = AgentMode::Worker.instructions().unwrap();
assert!(worker.contains("worker"));
assert!(worker.contains("implement the assigned unit as specified"));
assert!(
worker.contains("final verification and closure belong to the orchestrator workflow")
);
let orchestrator = AgentMode::Orchestrator.instructions().unwrap();
assert!(orchestrator.contains("orchestrator agent"));
assert!(orchestrator.contains("primary execution substrate"));
assert!(orchestrator.contains("final verification, retry, and closure workflow"));
let reviewer = AgentMode::Reviewer.instructions().unwrap();
assert!(reviewer.contains("reviewer") || reviewer.contains("read"));
}
}