Update: 将子项目从 submodule 转为完整内容

- 移除 GovAI, nomifun-tauri, 算力盒子 的 submodule 引用
- 添加所有子项目的完整源代码
- 保留原始 .git 为 .git.bak 备份
This commit is contained in:
freedak
2026-07-04 19:20:46 +08:00
parent 54d6465fa7
commit f7a720204a
3360 changed files with 802660 additions and 3 deletions
@@ -0,0 +1,33 @@
[package]
name = "nomi-config"
description = "Configuration layer for Nomi: Config struct, ProviderCompat, auth, hooks, session config"
version.workspace = true
edition.workspace = true
license.workspace = true
repository.workspace = true
[dependencies]
nomi-types.workspace = true
nomi-compact.workspace = true
serde.workspace = true
serde_json.workspace = true
toml = "1" # pinned: agent code uses toml 1.0 (workspace default is 0.8 for be-rs)
anyhow.workspace = true
thiserror.workspace = true
dirs.workspace = true
chrono.workspace = true
reqwest.workspace = true
glob.workspace = true
tokio.workspace = true
tracing.workspace = true
tracing-appender.workspace = true
tracing-subscriber.workspace = true
[dev-dependencies]
wiremock.workspace = true
tokio-test.workspace = true
tempfile.workspace = true
tracing-subscriber.workspace = true
@@ -0,0 +1,377 @@
// OAuth 2.0 Device Authorization Flow for Claude.ai subscriber accounts.
use std::path::PathBuf;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
/// Stored OAuth credentials
#[derive(Debug, Serialize, Deserialize)]
pub struct OAuthCredentials {
pub access_token: String,
pub refresh_token: Option<String>,
pub expires_at: DateTime<Utc>,
pub token_type: String,
}
/// OAuth device code response
#[derive(Debug, Deserialize)]
pub struct DeviceCodeResponse {
pub device_code: String,
pub user_code: String,
pub verification_uri: String,
pub expires_in: u64,
pub interval: u64,
}
/// OAuth token response
#[derive(Debug, Deserialize)]
struct TokenResponse {
access_token: String,
refresh_token: Option<String>,
expires_in: u64,
token_type: String,
}
/// OAuth token error response (during polling)
#[derive(Debug, Deserialize)]
struct TokenErrorResponse {
error: String,
}
/// Config for OAuth endpoints
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuthConfig {
#[serde(default = "default_auth_url")]
pub auth_url: String,
#[serde(default = "default_token_url")]
pub token_url: String,
#[serde(default = "default_client_id")]
pub client_id: String,
}
impl Default for AuthConfig {
fn default() -> Self {
Self {
auth_url: default_auth_url(),
token_url: default_token_url(),
client_id: default_client_id(),
}
}
}
fn default_auth_url() -> String {
"https://claude.ai/oauth".to_string()
}
fn default_token_url() -> String {
"https://claude.ai/oauth/token".to_string()
}
fn default_client_id() -> String {
"nomi".to_string()
}
pub struct OAuthManager {
client: reqwest::Client,
config: AuthConfig,
credentials_path: PathBuf,
}
impl OAuthManager {
pub fn new(config: AuthConfig) -> Self {
let credentials_path = crate::config::app_config_dir()
.unwrap_or_else(|| PathBuf::from("nomi"))
.join("auth.json");
Self {
client: reqwest::Client::new(),
config,
credentials_path,
}
}
/// Full device authorization flow
pub async fn login(&self) -> anyhow::Result<OAuthCredentials> {
// Step 1: Request device code
let device_code_url = format!("{}/device/code", self.config.auth_url);
let resp = self
.client
.post(&device_code_url)
.form(&[
("client_id", self.config.client_id.as_str()),
("scope", "user:inference"),
])
.send()
.await?;
if !resp.status().is_success() {
let body = resp.text().await.unwrap_or_default();
anyhow::bail!("Failed to request device code: {}", body);
}
let device_resp: DeviceCodeResponse = resp.json().await?;
// Step 2: Display instructions
eprintln!();
eprintln!(" To authenticate, visit:");
eprintln!(" {}", device_resp.verification_uri);
eprintln!();
eprintln!(" Enter code: {}", device_resp.user_code);
eprintln!();
eprintln!(" Waiting for authorization...");
// Step 3: Poll for token
let interval = std::time::Duration::from_secs(device_resp.interval.max(5));
let deadline =
std::time::Instant::now() + std::time::Duration::from_secs(device_resp.expires_in);
loop {
if std::time::Instant::now() > deadline {
anyhow::bail!("Device authorization timed out. Please try again.");
}
tokio::time::sleep(interval).await;
let token_resp = self
.client
.post(&self.config.token_url)
.form(&[
("client_id", self.config.client_id.as_str()),
("device_code", device_resp.device_code.as_str()),
("grant_type", "urn:ietf:params:oauth:grant-type:device_code"),
])
.send()
.await?;
let status = token_resp.status();
let body = token_resp.text().await.unwrap_or_default();
if status.is_success() {
let token: TokenResponse = serde_json::from_str(&body)?;
let credentials = OAuthCredentials {
access_token: token.access_token,
refresh_token: token.refresh_token,
expires_at: Utc::now() + chrono::Duration::seconds(token.expires_in as i64),
token_type: token.token_type,
};
self.save_credentials(&credentials)?;
return Ok(credentials);
}
// Check if we should keep polling
if let Ok(err_resp) = serde_json::from_str::<TokenErrorResponse>(&body) {
match err_resp.error.as_str() {
"authorization_pending" => continue,
"slow_down" => {
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
continue;
}
"expired_token" => {
anyhow::bail!("Device code expired. Please try again.");
}
"access_denied" => {
anyhow::bail!("Authorization denied by user.");
}
other => {
anyhow::bail!("OAuth error: {}", other);
}
}
}
anyhow::bail!("Unexpected OAuth response: {}", body);
}
}
/// Get a valid access token (refresh if expired)
pub async fn get_token(&self) -> anyhow::Result<String> {
let creds = self.load_credentials()?;
if creds.expires_at > Utc::now() + chrono::Duration::minutes(1) {
return Ok(creds.access_token);
}
// Try refresh
if let Some(refresh_token) = &creds.refresh_token {
let new_creds = self.refresh(refresh_token).await?;
self.save_credentials(&new_creds)?;
return Ok(new_creds.access_token);
}
anyhow::bail!("Token expired and no refresh token available. Run 'nomi --login'")
}
/// Refresh the access token
async fn refresh(&self, refresh_token: &str) -> anyhow::Result<OAuthCredentials> {
let resp = self
.client
.post(&self.config.token_url)
.form(&[
("client_id", self.config.client_id.as_str()),
("refresh_token", refresh_token),
("grant_type", "refresh_token"),
])
.send()
.await?;
if !resp.status().is_success() {
let body = resp.text().await.unwrap_or_default();
anyhow::bail!("Token refresh failed: {}", body);
}
let token: TokenResponse = resp.json().await?;
Ok(OAuthCredentials {
access_token: token.access_token,
refresh_token: token.refresh_token.or(Some(refresh_token.to_string())),
expires_at: Utc::now() + chrono::Duration::seconds(token.expires_in as i64),
token_type: token.token_type,
})
}
/// Logout: delete saved credentials
pub fn logout(&self) -> anyhow::Result<()> {
if self.credentials_path.exists() {
std::fs::remove_file(&self.credentials_path)?;
eprintln!("Credentials removed: {}", self.credentials_path.display());
} else {
eprintln!("No saved credentials found.");
}
Ok(())
}
/// Check if credentials exist
pub fn has_credentials(&self) -> bool {
self.credentials_path.exists()
}
fn save_credentials(&self, creds: &OAuthCredentials) -> anyhow::Result<()> {
if let Some(parent) = self.credentials_path.parent() {
std::fs::create_dir_all(parent)?;
}
let json = serde_json::to_string_pretty(creds)?;
std::fs::write(&self.credentials_path, json)?;
Ok(())
}
fn load_credentials(&self) -> anyhow::Result<OAuthCredentials> {
let json = std::fs::read_to_string(&self.credentials_path)
.map_err(|_| anyhow::anyhow!("No saved credentials. Run 'nomi --login'"))?;
let creds: OAuthCredentials = serde_json::from_str(&json)?;
Ok(creds)
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
fn test_manager(dir: &std::path::Path) -> OAuthManager {
OAuthManager {
client: reqwest::Client::new(),
config: AuthConfig::default(),
credentials_path: dir.join("auth.json"),
}
}
fn make_credentials(hours_from_now: i64) -> OAuthCredentials {
OAuthCredentials {
access_token: "test-access-token".to_string(),
refresh_token: Some("test-refresh-token".to_string()),
expires_at: Utc::now() + chrono::Duration::hours(hours_from_now),
token_type: "Bearer".to_string(),
}
}
#[tokio::test]
async fn test_save_and_load_credentials() {
let tmp = TempDir::new().unwrap();
let manager = test_manager(tmp.path());
let creds = make_credentials(1);
manager.save_credentials(&creds).unwrap();
let loaded = manager.load_credentials().unwrap();
assert_eq!(loaded.access_token, "test-access-token");
assert_eq!(loaded.refresh_token, Some("test-refresh-token".to_string()));
assert_eq!(loaded.token_type, "Bearer");
// Allow 1 second tolerance for serialization round-trip
let diff = (loaded.expires_at - creds.expires_at).num_seconds().abs();
assert!(diff <= 1, "expires_at mismatch: diff={diff}s");
}
#[tokio::test]
async fn test_has_credentials_false_when_empty() {
let tmp = TempDir::new().unwrap();
let manager = test_manager(tmp.path());
assert!(!manager.has_credentials());
}
#[tokio::test]
async fn test_logout_deletes_credentials() {
let tmp = TempDir::new().unwrap();
let manager = test_manager(tmp.path());
let creds = make_credentials(1);
manager.save_credentials(&creds).unwrap();
assert!(manager.has_credentials());
manager.logout().unwrap();
assert!(!manager.has_credentials());
assert!(!manager.credentials_path.exists());
}
#[tokio::test]
async fn test_get_token_returns_valid_token() {
let tmp = TempDir::new().unwrap();
let manager = test_manager(tmp.path());
let creds = make_credentials(1);
manager.save_credentials(&creds).unwrap();
let token = manager.get_token().await.unwrap();
assert_eq!(token, "test-access-token");
}
#[tokio::test]
async fn test_get_token_refreshes_expired() {
let tmp = TempDir::new().unwrap();
let mock_server = MockServer::start().await;
let manager = OAuthManager {
client: reqwest::Client::new(),
config: AuthConfig {
auth_url: mock_server.uri(),
token_url: format!("{}/token", mock_server.uri()),
client_id: "test".to_string(),
},
credentials_path: tmp.path().join("auth.json"),
};
// Save expired credentials
let expired_creds = make_credentials(-1);
manager.save_credentials(&expired_creds).unwrap();
Mock::given(method("POST"))
.and(path("/token"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"access_token": "new-token",
"refresh_token": "new-refresh",
"expires_in": 3600,
"token_type": "Bearer"
})))
.mount(&mock_server)
.await;
let token = manager.get_token().await.unwrap();
assert_eq!(token, "new-token");
// Verify new credentials were persisted
let reloaded = manager.load_credentials().unwrap();
assert_eq!(reloaded.access_token, "new-token");
assert_eq!(reloaded.refresh_token, Some("new-refresh".to_string()));
}
}
@@ -0,0 +1,297 @@
use serde::{Deserialize, Serialize};
/// Configuration for the multi-level context compaction system.
///
/// All token-related fields are in tokens (not bytes or characters).
/// The defaults are tuned for Claude models with a 200k context window.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CompactConfig {
/// Context window size in tokens (e.g. 200_000 for Claude).
#[serde(default = "default_context_window")]
pub context_window: usize,
/// Tokens reserved for output generation.
/// Subtracted from `context_window` to get the effective input budget.
#[serde(default = "default_output_reserve")]
pub output_reserve: usize,
/// Buffer below the effective window that triggers autocompact.
/// `threshold = context_window - output_reserve - autocompact_buffer`
#[serde(default = "default_autocompact_buffer")]
pub autocompact_buffer: usize,
/// Tokens from context_window limit to trigger emergency block.
/// `emergency_limit = context_window - emergency_buffer`
#[serde(default = "default_emergency_buffer")]
pub emergency_buffer: usize,
/// Max consecutive autocompact failures before the circuit breaker trips.
#[serde(default = "default_max_failures")]
pub max_failures: u32,
/// Microcompact: keep the N most recent compactable tool results.
#[serde(default = "default_micro_keep_recent")]
pub micro_keep_recent: usize,
/// Microcompact: gap threshold in seconds for time-based trigger.
/// When the last assistant message is older than this, microcompact fires.
#[serde(default = "default_micro_gap_seconds")]
pub micro_gap_seconds: u64,
/// Tool names whose results are eligible for microcompact content clearing.
#[serde(default = "default_compactable_tools")]
pub compactable_tools: Vec<String>,
/// Optional percentage override for the autocompact trigger threshold.
/// When set, threshold = context_window * pct / 100, ignoring
/// output_reserve and autocompact_buffer.
#[serde(default)]
pub autocompact_threshold_pct: Option<u8>,
/// Whether the compaction system is enabled.
/// When false, microcompact and autocompact are skipped
/// (emergency truncation still applies).
#[serde(default = "default_true")]
pub enabled: bool,
/// Enable prompt cache diagnostics output to user.
/// When true, cache hit/miss info is shown via OutputSink.
/// Default: false.
#[serde(default)]
pub cache_diagnostics: bool,
#[serde(default)]
pub compaction: nomi_compact::CompactionLevel,
#[serde(default)]
pub toon: bool,
}
impl Default for CompactConfig {
fn default() -> Self {
Self {
context_window: default_context_window(),
output_reserve: default_output_reserve(),
autocompact_buffer: default_autocompact_buffer(),
emergency_buffer: default_emergency_buffer(),
max_failures: default_max_failures(),
micro_keep_recent: default_micro_keep_recent(),
micro_gap_seconds: default_micro_gap_seconds(),
compactable_tools: default_compactable_tools(),
autocompact_threshold_pct: None,
enabled: default_true(),
cache_diagnostics: false,
compaction: nomi_compact::CompactionLevel::default(),
toon: false,
}
}
}
// --- Default value functions ---
fn default_context_window() -> usize {
200_000
}
fn default_output_reserve() -> usize {
20_000
}
fn default_autocompact_buffer() -> usize {
13_000
}
fn default_emergency_buffer() -> usize {
3_000
}
fn default_max_failures() -> u32 {
3
}
fn default_micro_keep_recent() -> usize {
5
}
fn default_micro_gap_seconds() -> u64 {
3600
}
fn default_compactable_tools() -> Vec<String> {
vec![
"Read".into(),
"Bash".into(),
"Grep".into(),
"Glob".into(),
"Write".into(),
"Edit".into(),
]
}
fn default_true() -> bool {
true
}
/// Resolve the effective context window: an explicit per-provider limit when
/// set and positive, else the engine default. Keeps the gauge denominator and
/// the engine's compaction window in agreement.
pub fn resolve_context_window(context_limit: Option<u64>, default_window: usize) -> usize {
match context_limit {
Some(v) if v > 0 => v as usize,
_ => default_window,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_values_match_spec() {
let cfg = CompactConfig::default();
assert_eq!(cfg.context_window, 200_000);
assert_eq!(cfg.output_reserve, 20_000);
assert_eq!(cfg.autocompact_buffer, 13_000);
assert_eq!(cfg.emergency_buffer, 3_000);
assert_eq!(cfg.max_failures, 3);
assert_eq!(cfg.micro_keep_recent, 5);
assert_eq!(cfg.micro_gap_seconds, 3600);
assert!(cfg.enabled);
assert_eq!(cfg.autocompact_threshold_pct, None);
assert_eq!(
cfg.compactable_tools,
vec!["Read", "Bash", "Grep", "Glob", "Write", "Edit"]
);
}
#[test]
fn toml_full_override() {
let toml_str = r#"
context_window = 128000
output_reserve = 10000
autocompact_buffer = 8000
emergency_buffer = 2000
max_failures = 5
micro_keep_recent = 3
micro_gap_seconds = 1800
compactable_tools = ["Read", "Bash"]
enabled = false
"#;
let cfg: CompactConfig = toml::from_str(toml_str).unwrap();
assert_eq!(cfg.context_window, 128_000);
assert_eq!(cfg.output_reserve, 10_000);
assert_eq!(cfg.autocompact_buffer, 8_000);
assert_eq!(cfg.emergency_buffer, 2_000);
assert_eq!(cfg.max_failures, 5);
assert_eq!(cfg.micro_keep_recent, 3);
assert_eq!(cfg.micro_gap_seconds, 1800);
assert_eq!(cfg.compactable_tools, vec!["Read", "Bash"]);
assert!(!cfg.enabled);
}
#[test]
fn toml_partial_override_uses_defaults() {
let toml_str = r#"
context_window = 128000
"#;
let cfg: CompactConfig = toml::from_str(toml_str).unwrap();
assert_eq!(cfg.context_window, 128_000);
// Everything else should be default
assert_eq!(cfg.output_reserve, 20_000);
assert_eq!(cfg.autocompact_buffer, 13_000);
assert_eq!(cfg.emergency_buffer, 3_000);
assert_eq!(cfg.max_failures, 3);
assert_eq!(cfg.micro_keep_recent, 5);
assert_eq!(cfg.micro_gap_seconds, 3600);
assert!(cfg.enabled);
}
#[test]
fn toml_empty_uses_all_defaults() {
let cfg: CompactConfig = toml::from_str("").unwrap();
let default = CompactConfig::default();
assert_eq!(cfg.context_window, default.context_window);
assert_eq!(cfg.output_reserve, default.output_reserve);
assert_eq!(cfg.autocompact_buffer, default.autocompact_buffer);
assert_eq!(cfg.emergency_buffer, default.emergency_buffer);
assert_eq!(cfg.max_failures, default.max_failures);
assert_eq!(cfg.micro_keep_recent, default.micro_keep_recent);
assert_eq!(cfg.micro_gap_seconds, default.micro_gap_seconds);
assert_eq!(cfg.enabled, default.enabled);
}
#[test]
fn cache_diagnostics_defaults_to_false() {
let cfg = CompactConfig::default();
assert!(!cfg.cache_diagnostics);
}
#[test]
fn toml_cache_diagnostics_override() {
let toml_str = r#"
cache_diagnostics = true
"#;
let cfg: CompactConfig = toml::from_str(toml_str).unwrap();
assert!(cfg.cache_diagnostics);
}
#[test]
fn default_compaction_is_safe() {
let cfg = CompactConfig::default();
assert_eq!(cfg.compaction, nomi_compact::CompactionLevel::Safe);
}
#[test]
fn default_toon_is_false() {
let cfg = CompactConfig::default();
assert!(!cfg.toon);
}
#[test]
fn toml_compaction_level_override() {
let toml_str = r#"compaction = "full""#;
let cfg: CompactConfig = toml::from_str(toml_str).unwrap();
assert_eq!(cfg.compaction, nomi_compact::CompactionLevel::Full);
}
#[test]
fn toml_compaction_off() {
let toml_str = r#"compaction = "off""#;
let cfg: CompactConfig = toml::from_str(toml_str).unwrap();
assert_eq!(cfg.compaction, nomi_compact::CompactionLevel::Off);
}
#[test]
fn toml_toon_enabled() {
let toml_str = r#"toon = true"#;
let cfg: CompactConfig = toml::from_str(toml_str).unwrap();
assert!(cfg.toon);
}
#[test]
fn json_serialization_roundtrip() {
let cfg = CompactConfig {
context_window: 100_000,
output_reserve: 15_000,
..Default::default()
};
let json = serde_json::to_string(&cfg).unwrap();
let back: CompactConfig = serde_json::from_str(&json).unwrap();
assert_eq!(back.context_window, 100_000);
assert_eq!(back.output_reserve, 15_000);
assert_eq!(back.autocompact_buffer, cfg.autocompact_buffer);
}
#[test]
fn toml_autocompact_threshold_pct() {
let toml_str = r#"autocompact_threshold_pct = 50"#;
let cfg: CompactConfig = toml::from_str(toml_str).unwrap();
assert_eq!(cfg.autocompact_threshold_pct, Some(50));
}
#[test]
fn toml_absent_threshold_pct_is_none() {
let toml_str = r#"context_window = 128000"#;
let cfg: CompactConfig = toml::from_str(toml_str).unwrap();
assert_eq!(cfg.autocompact_threshold_pct, None);
}
#[test]
fn resolve_context_window_uses_override_else_default() {
assert_eq!(resolve_context_window(Some(128_000), 200_000), 128_000);
assert_eq!(resolve_context_window(None, 200_000), 200_000);
assert_eq!(resolve_context_window(Some(0), 200_000), 200_000); // 0 treated as unset
}
}
@@ -0,0 +1,428 @@
// Configuration-driven provider compatibility layer.
// Each provider type has default presets; users can override any field via config.
use serde::{Deserialize, Serialize};
use serde_json::Value;
/// Provider-level compatibility settings.
/// Each field is Option — None means "use provider-type default".
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
pub struct ProviderCompat {
/// Field name for max tokens in request body.
/// Default: "max_tokens" for all providers.
pub max_tokens_field: Option<String>,
/// Merge consecutive assistant messages (text concat + tool_calls merge).
/// Default: true for openai.
pub merge_assistant_messages: Option<bool>,
/// Remove tool_use blocks that have no corresponding tool_result.
/// Default: true for openai.
pub clean_orphan_tool_calls: Option<bool>,
/// Deduplicate tool results with same tool_call_id (keep last).
/// Default: true for openai.
pub dedup_tool_results: Option<bool>,
/// Ensure messages alternate user/assistant (insert filler if needed).
/// Default: true for anthropic/bedrock/vertex.
pub ensure_alternation: Option<bool>,
/// Merge consecutive same-role messages into one.
/// Default: true for anthropic/bedrock/vertex.
pub merge_same_role: Option<bool>,
/// Sanitize JSON schemas for strict providers (remove additionalProperties, etc.).
/// Default: true for bedrock.
pub sanitize_schema: Option<bool>,
/// Text patterns to strip from message history before sending.
/// Default: empty.
pub strip_patterns: Option<Vec<String>>,
/// Auto-generate tool IDs when missing.
/// Default: true for anthropic/bedrock/vertex.
pub auto_tool_id: Option<bool>,
/// Custom API path appended to base_url for chat completions.
/// Default: "/v1/chat/completions" for OpenAI provider.
/// Override to "/chat/completions" for providers like Gemini that include
/// version prefix in the base URL itself.
pub api_path: Option<String>,
/// Whether this provider supports extended thinking (Anthropic-style).
/// Default: true for anthropic/bedrock/vertex, false for openai.
pub supports_thinking: Option<bool>,
/// Whether this provider supports reasoning_effort (OpenAI-style).
/// Default: false for anthropic/bedrock/vertex, true for openai.
pub supports_effort: Option<bool>,
/// Available effort levels for this provider (e.g., ["low", "medium", "high"]).
/// Only meaningful when supports_effort is true.
pub effort_levels: Option<Vec<String>>,
}
impl ProviderCompat {
/// Defaults for Anthropic-family providers (Anthropic, Vertex)
pub fn anthropic_defaults() -> Self {
Self {
ensure_alternation: Some(true),
merge_same_role: Some(true),
auto_tool_id: Some(true),
supports_thinking: Some(true),
supports_effort: Some(false),
..Default::default()
}
}
/// Defaults for Bedrock (Anthropic + schema sanitization)
pub fn bedrock_defaults() -> Self {
Self {
ensure_alternation: Some(true),
merge_same_role: Some(true),
auto_tool_id: Some(true),
sanitize_schema: Some(true),
supports_thinking: Some(true),
supports_effort: Some(false),
..Default::default()
}
}
/// Defaults for OpenAI-compatible providers
pub fn openai_defaults() -> Self {
Self {
max_tokens_field: Some("max_tokens".into()),
merge_assistant_messages: Some(true),
clean_orphan_tool_calls: Some(true),
dedup_tool_results: Some(true),
auto_tool_id: Some(true),
supports_thinking: Some(false),
supports_effort: Some(true),
effort_levels: Some(vec!["low".into(), "medium".into(), "high".into()]),
..Default::default()
}
}
/// Merge user config over defaults (user wins on non-None fields)
pub fn merge(defaults: Self, user: Self) -> Self {
Self {
max_tokens_field: user.max_tokens_field.or(defaults.max_tokens_field),
merge_assistant_messages: user
.merge_assistant_messages
.or(defaults.merge_assistant_messages),
clean_orphan_tool_calls: user
.clean_orphan_tool_calls
.or(defaults.clean_orphan_tool_calls),
dedup_tool_results: user.dedup_tool_results.or(defaults.dedup_tool_results),
ensure_alternation: user.ensure_alternation.or(defaults.ensure_alternation),
merge_same_role: user.merge_same_role.or(defaults.merge_same_role),
sanitize_schema: user.sanitize_schema.or(defaults.sanitize_schema),
strip_patterns: user.strip_patterns.or(defaults.strip_patterns),
auto_tool_id: user.auto_tool_id.or(defaults.auto_tool_id),
api_path: user.api_path.or(defaults.api_path),
supports_thinking: user.supports_thinking.or(defaults.supports_thinking),
supports_effort: user.supports_effort.or(defaults.supports_effort),
effort_levels: user.effort_levels.or(defaults.effort_levels),
}
}
// --- Resolved accessors (Option<bool> → bool with false default) ---
pub fn merge_assistant_messages(&self) -> bool {
self.merge_assistant_messages.unwrap_or(false)
}
pub fn clean_orphan_tool_calls(&self) -> bool {
self.clean_orphan_tool_calls.unwrap_or(false)
}
pub fn dedup_tool_results(&self) -> bool {
self.dedup_tool_results.unwrap_or(false)
}
pub fn ensure_alternation(&self) -> bool {
self.ensure_alternation.unwrap_or(false)
}
pub fn merge_same_role(&self) -> bool {
self.merge_same_role.unwrap_or(false)
}
pub fn sanitize_schema(&self) -> bool {
self.sanitize_schema.unwrap_or(false)
}
pub fn auto_tool_id(&self) -> bool {
self.auto_tool_id.unwrap_or(false)
}
pub fn api_path(&self) -> &str {
self.api_path.as_deref().unwrap_or("/v1/chat/completions")
}
pub fn supports_thinking(&self) -> bool {
self.supports_thinking.unwrap_or(false)
}
pub fn supports_effort(&self) -> bool {
self.supports_effort.unwrap_or(false)
}
pub fn effort_levels(&self) -> &[String] {
self.effort_levels.as_deref().unwrap_or(&[])
}
}
/// Sanitize a JSON Schema for strict providers (e.g., Bedrock).
/// - Root type must be "object" (wrap if not)
/// - Recursively remove "additionalProperties"
/// - Normalize array types: ["string", "null"] → "string"
pub fn sanitize_json_schema(schema: &Value) -> Value {
let mut schema = schema.clone();
// Ensure root type is "object"
if schema.get("type").and_then(|t| t.as_str()) != Some("object") {
schema = serde_json::json!({
"type": "object",
"properties": {
"value": schema
},
"required": ["value"]
});
}
strip_additional_properties(&mut schema);
normalize_array_types(&mut schema);
schema
}
fn strip_additional_properties(val: &mut Value) {
if let Some(obj) = val.as_object_mut() {
obj.remove("additionalProperties");
for v in obj.values_mut() {
strip_additional_properties(v);
}
} else if let Some(arr) = val.as_array_mut() {
for v in arr.iter_mut() {
strip_additional_properties(v);
}
}
}
fn normalize_array_types(val: &mut Value) {
if let Some(obj) = val.as_object_mut() {
// Normalize ["string", "null"] → "string"
if let Some(arr) = obj.get("type").and_then(Value::as_array) {
let non_null: Vec<&Value> = arr.iter().filter(|v| v.as_str() != Some("null")).collect();
if non_null.len() == 1 {
obj.insert("type".to_string(), non_null[0].clone());
}
}
for v in obj.values_mut() {
normalize_array_types(v);
}
} else if let Some(arr) = val.as_array_mut() {
for v in arr.iter_mut() {
normalize_array_types(v);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn test_anthropic_defaults() {
let compat = ProviderCompat::anthropic_defaults();
assert!(compat.ensure_alternation());
assert!(compat.merge_same_role());
assert!(compat.auto_tool_id());
assert!(!compat.sanitize_schema());
assert!(!compat.merge_assistant_messages());
assert!(!compat.clean_orphan_tool_calls());
}
#[test]
fn test_bedrock_defaults() {
let compat = ProviderCompat::bedrock_defaults();
assert!(compat.ensure_alternation());
assert!(compat.merge_same_role());
assert!(compat.auto_tool_id());
assert!(compat.sanitize_schema());
}
#[test]
fn test_openai_defaults() {
let compat = ProviderCompat::openai_defaults();
assert!(compat.merge_assistant_messages());
assert!(compat.clean_orphan_tool_calls());
assert!(compat.dedup_tool_results());
assert_eq!(compat.max_tokens_field.as_deref(), Some("max_tokens"));
assert!(!compat.ensure_alternation());
}
#[test]
fn test_merge_user_overrides_defaults() {
let defaults = ProviderCompat::openai_defaults();
let user = ProviderCompat {
max_tokens_field: Some("max_completion_tokens".into()),
merge_assistant_messages: Some(false),
..Default::default()
};
let merged = ProviderCompat::merge(defaults, user);
assert_eq!(
merged.max_tokens_field.as_deref(),
Some("max_completion_tokens")
);
assert!(!merged.merge_assistant_messages());
// Non-overridden fields keep defaults
assert!(merged.clean_orphan_tool_calls());
assert!(merged.dedup_tool_results());
}
#[test]
fn test_merge_empty_user_keeps_defaults() {
let defaults = ProviderCompat::anthropic_defaults();
let user = ProviderCompat::default();
let merged = ProviderCompat::merge(defaults, user);
assert!(merged.ensure_alternation());
assert!(merged.merge_same_role());
assert!(merged.auto_tool_id());
}
#[test]
fn test_sanitize_schema_wraps_non_object_root() {
let schema = json!({"type": "string"});
let sanitized = sanitize_json_schema(&schema);
assert_eq!(sanitized["type"], "object");
assert_eq!(sanitized["properties"]["value"]["type"], "string");
}
#[test]
fn test_sanitize_schema_removes_additional_properties() {
let schema = json!({
"type": "object",
"properties": {
"name": {"type": "string", "additionalProperties": false}
},
"additionalProperties": false
});
let sanitized = sanitize_json_schema(&schema);
assert!(sanitized.get("additionalProperties").is_none());
assert!(
sanitized["properties"]["name"]
.get("additionalProperties")
.is_none()
);
}
#[test]
fn test_sanitize_schema_normalizes_array_types() {
let schema = json!({
"type": "object",
"properties": {
"name": {"type": ["string", "null"]}
}
});
let sanitized = sanitize_json_schema(&schema);
assert_eq!(sanitized["properties"]["name"]["type"], "string");
}
#[test]
fn test_sanitize_schema_no_change_for_valid_object() {
let schema = json!({
"type": "object",
"properties": {
"cmd": {"type": "string"}
},
"required": ["cmd"]
});
let sanitized = sanitize_json_schema(&schema);
assert_eq!(sanitized["type"], "object");
assert_eq!(sanitized["properties"]["cmd"]["type"], "string");
}
#[test]
fn test_anthropic_defaults_capability_fields() {
let compat = ProviderCompat::anthropic_defaults();
assert_eq!(compat.supports_thinking, Some(true));
assert_eq!(compat.supports_effort, Some(false));
assert!(compat.effort_levels.is_none());
}
#[test]
fn test_openai_defaults_capability_fields() {
let compat = ProviderCompat::openai_defaults();
assert_eq!(compat.supports_thinking, Some(false));
assert_eq!(compat.supports_effort, Some(true));
assert_eq!(
compat.effort_levels,
Some(vec![
"low".to_string(),
"medium".to_string(),
"high".to_string()
])
);
}
#[test]
fn test_bedrock_defaults_capability_fields() {
let compat = ProviderCompat::bedrock_defaults();
assert_eq!(compat.supports_thinking, Some(true));
assert_eq!(compat.supports_effort, Some(false));
}
#[test]
fn test_merge_capability_fields_user_overrides() {
let defaults = ProviderCompat::openai_defaults();
let user = ProviderCompat {
supports_thinking: Some(true),
..Default::default()
};
let merged = ProviderCompat::merge(defaults, user);
assert_eq!(merged.supports_thinking, Some(true));
assert_eq!(merged.supports_effort, Some(true));
}
#[test]
fn test_capability_accessors() {
let compat = ProviderCompat::anthropic_defaults();
assert!(compat.supports_thinking());
assert!(!compat.supports_effort());
assert!(compat.effort_levels().is_empty());
let compat2 = ProviderCompat::openai_defaults();
assert!(!compat2.supports_thinking());
assert!(compat2.supports_effort());
assert_eq!(compat2.effort_levels(), &["low", "medium", "high"]);
}
#[test]
fn test_deserialize_from_toml() {
let toml_str = r#"
max_tokens_field = "max_completion_tokens"
merge_assistant_messages = true
strip_patterns = ["__REASONING__"]
"#;
let compat: ProviderCompat = toml::from_str(toml_str).unwrap();
assert_eq!(
compat.max_tokens_field.as_deref(),
Some("max_completion_tokens")
);
assert_eq!(compat.merge_assistant_messages, Some(true));
assert_eq!(
compat.strip_patterns,
Some(vec!["__REASONING__".to_string()])
);
assert!(compat.clean_orphan_tool_calls.is_none());
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,171 @@
//! Feature-flag registry for the nomi-agent overhaul (staged rollout).
//!
//! Every behavioural change in the overhaul ships dark behind a flag so it can
//! be enabled per-profile or at runtime and rolled back instantly. Modelled on
//! Codex's `features` crate, kept minimal. See
//! docs/superpowers/specs/2026-06-21-nomi-agent-overhaul-design.md §6.
use std::collections::BTreeSet;
/// Lifecycle stage of a feature — gates UI exposure and metrics emission.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Stage {
/// Built but not ready for any user; off by default, hidden.
UnderDevelopment,
/// Opt-in, surfaced in advanced settings.
Experimental,
/// On by default, generally available.
Stable,
/// On its way out; warns on use.
Deprecated,
}
/// Stable identifier for a feature flag. New flags are added here.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum Feature {
/// Engine winds down cooperatively via a `CancellationToken` instead of the
/// manager dropping the run future mid-flight (Phase 0 F0.4).
CooperativeCancel,
/// Manager-level terminal-event guarantee guard rollout (Phase 0 F0.2).
TerminationGuard,
}
/// Static declaration of a feature: identity, config key, stage, and default.
#[derive(Debug, Clone, Copy)]
pub struct FeatureSpec {
pub id: Feature,
pub key: &'static str,
pub stage: Stage,
pub default_enabled: bool,
}
/// The registry of every known feature. Single source of truth.
pub const FEATURES: &[FeatureSpec] = &[
FeatureSpec {
id: Feature::CooperativeCancel,
key: "nomi_cooperative_cancel",
stage: Stage::UnderDevelopment,
default_enabled: false,
},
FeatureSpec {
id: Feature::TerminationGuard,
key: "nomi_termination_guard",
stage: Stage::UnderDevelopment,
default_enabled: false,
},
];
/// Resolve a config key string to its `Feature` id, if known.
pub fn feature_for_key(key: &str) -> Option<Feature> {
FEATURES.iter().find(|f| f.key == key).map(|f| f.id)
}
/// A resolved set of enabled features for a session.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Features {
enabled: BTreeSet<Feature>,
}
impl Features {
/// Build from the registry's declared defaults.
pub fn from_defaults() -> Self {
let enabled = FEATURES
.iter()
.filter(|f| f.default_enabled)
.map(|f| f.id)
.collect();
Self { enabled }
}
pub fn is_enabled(&self, id: Feature) -> bool {
self.enabled.contains(&id)
}
pub fn enable(&mut self, id: Feature) {
self.enabled.insert(id);
}
pub fn disable(&mut self, id: Feature) {
self.enabled.remove(&id);
}
pub fn set_enabled(&mut self, id: Feature, on: bool) {
if on {
self.enable(id);
} else {
self.disable(id);
}
}
/// Layer `(key, on)` overrides on top of `base`. Unknown keys are ignored
/// with a warning so config stays forward/backward compatible across builds.
pub fn from_sources(base: Features, overrides: impl IntoIterator<Item = (String, bool)>) -> Self {
let mut f = base;
for (key, on) in overrides {
match feature_for_key(&key) {
Some(id) => f.set_enabled(id, on),
None => tracing::warn!(
target: "nomi_config",
feature_key = %key,
"unknown feature flag key ignored"
),
}
}
f
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn defaults_match_declared_specs() {
let f = Features::from_defaults();
for spec in FEATURES {
assert_eq!(
f.is_enabled(spec.id),
spec.default_enabled,
"flag {} default mismatch",
spec.key
);
}
}
#[test]
fn enable_disable_round_trip() {
let mut f = Features::from_defaults();
f.enable(Feature::CooperativeCancel);
assert!(f.is_enabled(Feature::CooperativeCancel));
f.disable(Feature::CooperativeCancel);
assert!(!f.is_enabled(Feature::CooperativeCancel));
}
#[test]
fn from_sources_applies_known_overrides() {
let f = Features::from_sources(
Features::from_defaults(),
[("nomi_cooperative_cancel".to_string(), true)],
);
assert!(f.is_enabled(Feature::CooperativeCancel));
}
#[test]
fn from_sources_ignores_unknown_keys() {
// Unknown keys (e.g. a flag from a newer/older build) must be ignored,
// not panic, so config stays forward/backward compatible across versions.
let f = Features::from_sources(
Features::from_defaults(),
[("totally_unknown_flag".to_string(), true)],
);
assert_eq!(f, Features::from_defaults());
}
#[test]
fn feature_for_key_round_trips() {
for spec in FEATURES {
assert_eq!(feature_for_key(spec.key), Some(spec.id));
}
assert_eq!(feature_for_key("nope"), None);
}
}
@@ -0,0 +1,87 @@
use serde::{Deserialize, Serialize};
/// Configuration for the file state cache.
///
/// Controls the LRU cache that tracks files the model has seen,
/// enabling dedup detection and staleness checks.
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct FileCacheConfig {
/// Maximum number of cached file entries.
#[serde(default = "default_max_entries")]
pub max_entries: usize,
/// Maximum total cache size in bytes.
#[serde(default = "default_max_size_bytes")]
pub max_size_bytes: usize,
/// Enable file state caching.
#[serde(default = "default_enabled")]
pub enabled: bool,
}
impl Default for FileCacheConfig {
fn default() -> Self {
Self {
max_entries: default_max_entries(),
max_size_bytes: default_max_size_bytes(),
enabled: default_enabled(),
}
}
}
fn default_max_entries() -> usize {
100
}
fn default_max_size_bytes() -> usize {
25 * 1024 * 1024 // 25 MB
}
fn default_enabled() -> bool {
true
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn defaults_are_correct() {
let config = FileCacheConfig::default();
assert_eq!(config.max_entries, 100);
assert_eq!(config.max_size_bytes, 25 * 1024 * 1024);
assert!(config.enabled);
}
#[test]
fn deserialize_from_toml_full() {
let toml_str = r#"
max_entries = 50
max_size_bytes = 10485760
enabled = false
"#;
let config: FileCacheConfig = toml::from_str(toml_str).unwrap();
assert_eq!(config.max_entries, 50);
assert_eq!(config.max_size_bytes, 10_485_760);
assert!(!config.enabled);
}
#[test]
fn deserialize_from_toml_partial_uses_defaults() {
let toml_str = r#"
max_entries = 200
"#;
let config: FileCacheConfig = toml::from_str(toml_str).unwrap();
assert_eq!(config.max_entries, 200);
assert_eq!(config.max_size_bytes, 25 * 1024 * 1024);
assert!(config.enabled);
}
#[test]
fn deserialize_from_empty_toml() {
let config: FileCacheConfig = toml::from_str("").unwrap();
assert_eq!(config.max_entries, 100);
assert_eq!(config.max_size_bytes, 25 * 1024 * 1024);
assert!(config.enabled);
}
}
@@ -0,0 +1,540 @@
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::time::Duration;
use serde::{Deserialize, Serialize};
use crate::shell::shell_command_builder;
/// Hook system configuration
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
pub struct HooksConfig {
#[serde(default)]
pub pre_tool_use: Vec<HookDef>,
#[serde(default)]
pub post_tool_use: Vec<HookDef>,
#[serde(default)]
pub stop: Vec<HookDef>,
}
/// A single hook definition
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct HookDef {
pub name: String,
/// Tool name patterns to match (glob). Empty = match all.
#[serde(default)]
pub tool_match: Vec<String>,
/// File path patterns to match (glob). Empty = match all.
#[serde(default)]
pub file_match: Vec<String>,
/// Shell command to execute. Supports ${VAR} interpolation.
pub command: String,
/// Timeout in ms (default 30000)
#[serde(default = "default_hook_timeout")]
pub timeout_ms: u64,
}
fn default_hook_timeout() -> u64 {
30_000
}
/// Event-driven hook engine
pub struct HookEngine {
config: HooksConfig,
cwd: PathBuf,
}
impl HookEngine {
pub fn new(config: HooksConfig, cwd: PathBuf) -> Self {
Self { config, cwd }
}
/// Run pre-tool-use hooks. Returns Err if any hook blocks execution.
pub async fn run_pre_tool_use(
&self,
tool_name: &str,
tool_input: &serde_json::Value,
) -> Result<(), HookError> {
let matching: Vec<_> = self
.config
.pre_tool_use
.iter()
.filter(|h| matches_tool(h, tool_name, tool_input))
.collect();
for hook in matching {
let env = build_env_vars(tool_name, tool_input);
let result = run_hook_command(&hook.command, &env, hook.timeout_ms, &self.cwd).await?;
if !result.success {
return Err(HookError::Blocked {
hook_name: hook.name.clone(),
output: result.output,
});
}
}
Ok(())
}
/// Run post-tool-use hooks. Errors are logged but don't block.
pub async fn run_post_tool_use(
&self,
tool_name: &str,
tool_input: &serde_json::Value,
tool_output: &str,
) -> Vec<String> {
let matching: Vec<_> = self
.config
.post_tool_use
.iter()
.filter(|h| matches_tool(h, tool_name, tool_input))
.collect();
let mut messages = Vec::new();
for hook in matching {
let mut env = build_env_vars(tool_name, tool_input);
env.insert("TOOL_OUTPUT".to_string(), tool_output.to_string());
match run_hook_command(&hook.command, &env, hook.timeout_ms, &self.cwd).await {
Ok(result) => {
if !result.output.is_empty() {
messages.push(format!("[hook:{}] {}", hook.name, result.output.trim()));
}
}
Err(e) => {
messages.push(format!("[hook:{}] error: {}", hook.name, e));
}
}
}
messages
}
/// Run stop hooks when agent session ends.
pub async fn run_stop(&self) -> Vec<String> {
let mut messages = Vec::new();
for hook in &self.config.stop {
match run_hook_command(&hook.command, &HashMap::new(), hook.timeout_ms, &self.cwd).await
{
Ok(result) => {
if !result.output.is_empty() {
messages.push(format!("[hook:{}] {}", hook.name, result.output.trim()));
}
}
Err(e) => {
messages.push(format!("[hook:{}] error: {}", hook.name, e));
}
}
}
messages
}
/// Check if any hooks are configured
pub fn has_hooks(&self) -> bool {
!self.config.pre_tool_use.is_empty()
|| !self.config.post_tool_use.is_empty()
|| !self.config.stop.is_empty()
}
/// Merge additional hooks into the engine's config, skipping duplicates by name.
/// Used by SkillTool to register skill-specific hooks at invocation time (idempotent).
pub fn merge_hooks(&mut self, additional: HooksConfig) {
merge_vec(&mut self.config.pre_tool_use, additional.pre_tool_use);
merge_vec(&mut self.config.post_tool_use, additional.post_tool_use);
merge_vec(&mut self.config.stop, additional.stop);
}
}
/// Append `incoming` hooks into `existing`, skipping any whose name already exists.
fn merge_vec(existing: &mut Vec<HookDef>, incoming: Vec<HookDef>) {
for hook in incoming {
if !existing.iter().any(|h| h.name == hook.name) {
existing.push(hook);
}
}
}
/// Environment variables available to hook commands
fn build_env_vars(tool_name: &str, tool_input: &serde_json::Value) -> HashMap<String, String> {
let mut env = HashMap::new();
env.insert("TOOL_NAME".to_string(), tool_name.to_string());
env.insert("TOOL_INPUT".to_string(), tool_input.to_string());
// Extract common fields for convenience
if let Some(fp) = tool_input["file_path"].as_str() {
env.insert("TOOL_INPUT_FILE_PATH".to_string(), fp.to_string());
}
if let Some(cmd) = tool_input["command"].as_str() {
env.insert("TOOL_INPUT_COMMAND".to_string(), cmd.to_string());
}
if let Some(pattern) = tool_input["pattern"].as_str() {
env.insert("TOOL_INPUT_PATTERN".to_string(), pattern.to_string());
}
env
}
fn matches_tool(hook: &HookDef, tool_name: &str, tool_input: &serde_json::Value) -> bool {
// Check tool_match
if !hook.tool_match.is_empty() {
let matches = hook
.tool_match
.iter()
.any(|pattern| glob_match(pattern, tool_name));
if !matches {
return false;
}
}
// Check file_match (if tool has a file_path input)
if !hook.file_match.is_empty() {
if let Some(file_path) = tool_input["file_path"].as_str() {
let matches = hook
.file_match
.iter()
.any(|pattern| glob_match(pattern, file_path));
if !matches {
return false;
}
} else {
return false; // file_match specified but tool has no file_path
}
}
true
}
fn glob_match(pattern: &str, value: &str) -> bool {
glob::Pattern::new(pattern)
.map(|p| p.matches(value))
.unwrap_or(false)
}
/// Interpolate ${VAR} in a command string with provided env vars
fn interpolate_command(command: &str, env_vars: &HashMap<String, String>) -> String {
let mut result = command.to_string();
for (key, value) in env_vars {
result = result.replace(&format!("${{{}}}", key), value);
}
result
}
struct HookResult {
success: bool,
output: String,
}
async fn run_hook_command(
command: &str,
env_vars: &HashMap<String, String>,
timeout_ms: u64,
cwd: &Path,
) -> Result<HookResult, HookError> {
let interpolated = interpolate_command(command, env_vars);
let timeout = Duration::from_millis(timeout_ms);
tracing::debug!(cwd = %cwd.display(), command = %interpolated, "hook executing");
let result = tokio::time::timeout(timeout, async {
shell_command_builder(&interpolated)
.envs(env_vars)
.current_dir(cwd)
.output()
.await
})
.await;
match result {
Ok(Ok(output)) => {
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
let combined = if stderr.is_empty() {
stdout
} else if stdout.is_empty() {
stderr
} else {
format!("{}\n{}", stdout, stderr)
};
Ok(HookResult {
success: output.status.success(),
output: combined,
})
}
Ok(Err(e)) => Err(HookError::ExecutionFailed(e.to_string())),
Err(_) => Err(HookError::Timeout(timeout_ms)),
}
}
#[derive(Debug, thiserror::Error)]
pub enum HookError {
#[error("Hook '{hook_name}' blocked execution: {output}")]
Blocked { hook_name: String, output: String },
#[error("Hook execution failed: {0}")]
ExecutionFailed(String),
#[error("Hook timed out after {0}ms")]
Timeout(u64),
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn make_hook(name: &str, tool_match: Vec<&str>, command: &str) -> HookDef {
HookDef {
name: name.to_string(),
tool_match: tool_match.into_iter().map(|s| s.to_string()).collect(),
file_match: vec![],
command: command.to_string(),
timeout_ms: 30_000,
}
}
// --- Pure logic tests ---
#[test]
fn test_hook_matches_exact_tool_name() {
let hook = make_hook("test", vec!["Read"], "echo ok");
let input = json!({});
assert!(matches_tool(&hook, "Read", &input));
}
#[test]
fn test_hook_matches_glob_pattern() {
let hook = make_hook("test", vec!["Read*"], "echo ok");
let input = json!({});
assert!(matches_tool(&hook, "ReadFile", &input));
}
#[test]
fn test_hook_no_match() {
let hook = make_hook("test", vec!["Write"], "echo ok");
let input = json!({});
assert!(!matches_tool(&hook, "Read", &input));
}
#[test]
fn test_has_hooks_empty() {
let engine = HookEngine::new(HooksConfig::default(), std::env::temp_dir());
assert!(!engine.has_hooks());
}
#[test]
fn test_has_hooks_with_config() {
let config = HooksConfig {
pre_tool_use: vec![make_hook("pre", vec!["*"], "echo ok")],
post_tool_use: vec![],
stop: vec![],
};
let engine = HookEngine::new(config, std::env::temp_dir());
assert!(engine.has_hooks());
}
// --- Shell command tests ---
#[tokio::test]
async fn test_pre_hook_allows_execution() {
let config = HooksConfig {
pre_tool_use: vec![make_hook("allow", vec!["Read"], "echo ok")],
post_tool_use: vec![],
stop: vec![],
};
let engine = HookEngine::new(config, std::env::temp_dir());
let result = engine.run_pre_tool_use("Read", &json!({})).await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_pre_hook_blocks_on_nonzero_exit() {
let config = HooksConfig {
pre_tool_use: vec![make_hook("blocker", vec!["Read"], "exit 1")],
post_tool_use: vec![],
stop: vec![],
};
let engine = HookEngine::new(config, std::env::temp_dir());
let result = engine.run_pre_tool_use("Read", &json!({})).await;
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), HookError::Blocked { .. }));
}
#[tokio::test]
async fn test_post_hook_runs_after_tool() {
let config = HooksConfig {
pre_tool_use: vec![],
post_tool_use: vec![make_hook("post", vec!["Read"], "echo done")],
stop: vec![],
};
let engine = HookEngine::new(config, std::env::temp_dir());
let messages = engine.run_post_tool_use("Read", &json!({}), "output").await;
assert!(!messages.is_empty());
assert!(messages[0].contains("done"));
}
#[tokio::test]
async fn test_hook_timeout() {
let config = HooksConfig {
pre_tool_use: vec![HookDef {
name: "slow".to_string(),
tool_match: vec!["Read".to_string()],
file_match: vec![],
command: "sleep 10".to_string(),
timeout_ms: 100,
}],
post_tool_use: vec![],
stop: vec![],
};
let engine = HookEngine::new(config, std::env::temp_dir());
let result = engine.run_pre_tool_use("Read", &json!({})).await;
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), HookError::Timeout(_)));
}
}
// ---------------------------------------------------------------------------
// Phase 11 tests — merge_hooks() (TC-11.30 ~ TC-11.38)
// ---------------------------------------------------------------------------
#[cfg(test)]
mod phase11_tests {
use super::*;
fn make_hook(name: &str) -> HookDef {
HookDef {
name: name.to_string(),
tool_match: vec![],
file_match: vec![],
command: "echo ok".to_string(),
timeout_ms: 30_000,
}
}
fn make_config_pre(names: &[&str]) -> HooksConfig {
HooksConfig {
pre_tool_use: names.iter().map(|n| make_hook(n)).collect(),
post_tool_use: vec![],
stop: vec![],
}
}
// TC-11.30: pre_tool_use count accumulates correctly
#[test]
fn tc_11_30_pre_tool_use_count_accumulates() {
let mut engine = HookEngine::new(make_config_pre(&["pre-a"]), std::env::temp_dir());
let additional = HooksConfig {
pre_tool_use: vec![make_hook("pre-b"), make_hook("pre-c")],
post_tool_use: vec![],
stop: vec![],
};
engine.merge_hooks(additional);
assert_eq!(engine.config.pre_tool_use.len(), 3);
}
// TC-11.31: post_tool_use count accumulates correctly
#[test]
fn tc_11_31_post_tool_use_count_accumulates() {
let mut engine = HookEngine::new(HooksConfig::default(), std::env::temp_dir());
let additional = HooksConfig {
pre_tool_use: vec![],
post_tool_use: vec![make_hook("post-a")],
stop: vec![],
};
engine.merge_hooks(additional);
assert_eq!(engine.config.post_tool_use.len(), 1);
}
// TC-11.32: stop count accumulates correctly
#[test]
fn tc_11_32_stop_count_accumulates() {
let initial = HooksConfig {
pre_tool_use: vec![],
post_tool_use: vec![],
stop: vec![make_hook("stop-a")],
};
let mut engine = HookEngine::new(initial, std::env::temp_dir());
let additional = HooksConfig {
pre_tool_use: vec![],
post_tool_use: vec![],
stop: vec![make_hook("stop-b")],
};
engine.merge_hooks(additional);
assert_eq!(engine.config.stop.len(), 2);
}
// TC-11.33: merging empty config doesn't change existing hooks
#[test]
fn tc_11_33_merge_empty_does_not_change_existing() {
let mut engine =
HookEngine::new(make_config_pre(&["pre-a", "pre-b"]), std::env::temp_dir());
engine.merge_hooks(HooksConfig::default());
assert_eq!(engine.config.pre_tool_use.len(), 2);
}
// TC-11.34: has_hooks() is true after merging
#[test]
fn tc_11_34_has_hooks_true_after_merge() {
let mut engine = HookEngine::new(HooksConfig::default(), std::env::temp_dir());
assert!(
!engine.has_hooks(),
"precondition: engine starts with no hooks"
);
engine.merge_hooks(make_config_pre(&["pre-a"]));
assert!(
engine.has_hooks(),
"TC-11.34: has_hooks must be true after merge"
);
}
// TC-11.35: multiple successive merges accumulate correctly (different names)
#[test]
fn tc_11_35_successive_merges_accumulate() {
let mut engine = HookEngine::new(HooksConfig::default(), std::env::temp_dir());
engine.merge_hooks(make_config_pre(&["a"]));
engine.merge_hooks(make_config_pre(&["b"]));
engine.merge_hooks(make_config_pre(&["c"]));
assert_eq!(engine.config.pre_tool_use.len(), 3);
}
// TC-11.36: merging stop hooks does not affect pre_tool_use
#[test]
fn tc_11_36_merge_stop_does_not_affect_pre() {
let mut engine = HookEngine::new(make_config_pre(&["pre-a"]), std::env::temp_dir());
let additional = HooksConfig {
pre_tool_use: vec![],
post_tool_use: vec![],
stop: vec![make_hook("stop-x")],
};
engine.merge_hooks(additional);
assert_eq!(
engine.config.pre_tool_use.len(),
1,
"TC-11.36: pre unchanged"
);
assert_eq!(engine.config.stop.len(), 1, "TC-11.36: stop added");
}
// TC-11.37: same-name hook not duplicated (idempotent dedup — C-4)
#[test]
fn tc_11_37_same_name_hook_not_duplicated() {
let mut engine = HookEngine::new(HooksConfig::default(), std::env::temp_dir());
let config = make_config_pre(&["skill:my-skill:pre_tool_use:0"]);
engine.merge_hooks(config.clone());
engine.merge_hooks(config);
assert_eq!(
engine.config.pre_tool_use.len(),
1,
"TC-11.37: same-name hook must not be duplicated"
);
}
// TC-11.38: different-name hooks both appended (no false dedup — C-4)
#[test]
fn tc_11_38_different_name_hooks_both_appended() {
let mut engine = HookEngine::new(HooksConfig::default(), std::env::temp_dir());
engine.merge_hooks(make_config_pre(&["hook-a"]));
engine.merge_hooks(make_config_pre(&["hook-b"]));
assert_eq!(
engine.config.pre_tool_use.len(),
2,
"TC-11.38: different-name hooks must both be appended"
);
}
}
@@ -0,0 +1,12 @@
// Configuration layer: runtime Config, ProviderCompat, auth, hooks, provider-specific configs.
pub mod auth;
pub mod compact;
pub mod compat;
pub mod config;
pub mod features;
pub mod file_cache;
pub mod hooks;
pub mod logging;
pub mod plan;
pub mod shell;
@@ -0,0 +1,266 @@
use std::path::PathBuf;
use serde::Deserialize;
#[derive(Debug, Clone, Deserialize, serde::Serialize, Default)]
pub struct LoggingConfig {
#[serde(default)]
pub enabled: Option<bool>,
#[serde(default)]
pub level: Option<String>,
#[serde(default)]
pub dir: Option<String>,
}
#[derive(Debug, Clone)]
pub struct ResolvedLogging {
pub enabled: bool,
pub level: String,
pub dir: PathBuf,
}
#[derive(Debug, thiserror::Error)]
pub enum LoggingError {
#[error("failed to create log directory '{path}': {source}")]
CreateDir {
path: PathBuf,
source: std::io::Error,
},
#[error("failed to build log file appender: {0}")]
AppenderInit(String),
#[error("invalid log level filter '{filter}': {reason}")]
InvalidFilter { filter: String, reason: String },
}
pub fn default_log_dir() -> PathBuf {
#[cfg(target_os = "macos")]
{
dirs::home_dir()
.map(|h| h.join("Library").join("Logs").join("nomi"))
.unwrap_or_else(|| PathBuf::from("nomi/logs"))
}
#[cfg(target_os = "linux")]
{
dirs::state_dir()
.map(|d| d.join("nomi").join("logs"))
.unwrap_or_else(|| {
dirs::home_dir()
.map(|h| h.join(".local").join("state").join("nomi").join("logs"))
.unwrap_or_else(|| PathBuf::from("nomi/logs"))
})
}
#[cfg(target_os = "windows")]
{
dirs::data_dir()
.map(|d| d.join("nomi").join("logs"))
.unwrap_or_else(|| PathBuf::from("nomi/logs"))
}
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
{
PathBuf::from("nomi/logs")
}
}
pub use tracing_appender::non_blocking::WorkerGuard as LoggingGuard;
use tracing::Subscriber;
use tracing_appender::non_blocking::WorkerGuard;
use tracing_subscriber::EnvFilter;
use tracing_subscriber::Layer;
use tracing_subscriber::fmt;
use tracing_subscriber::registry::LookupSpan;
pub fn create_file_layer<S>(
config: &ResolvedLogging,
) -> Result<(Box<dyn Layer<S> + Send + Sync>, WorkerGuard), LoggingError>
where
S: Subscriber + for<'a> LookupSpan<'a>,
{
std::fs::create_dir_all(&config.dir).map_err(|source| LoggingError::CreateDir {
path: config.dir.clone(),
source,
})?;
let file_appender = tracing_appender::rolling::RollingFileAppender::builder()
.rotation(tracing_appender::rolling::Rotation::DAILY)
.filename_suffix("nomi.log")
.build(&config.dir)
.map_err(|e| LoggingError::AppenderInit(e.to_string()))?;
let (non_blocking, guard) = tracing_appender::non_blocking(file_appender);
let filter = EnvFilter::try_new(&config.level).map_err(|e| LoggingError::InvalidFilter {
filter: config.level.clone(),
reason: e.to_string(),
})?;
let layer = fmt::layer()
.json()
.with_writer(non_blocking)
.with_ansi(false)
.with_target(true)
.with_filter(filter);
Ok((Box::new(layer), guard))
}
impl LoggingConfig {
pub fn merge(global: Self, project: Self) -> Self {
Self {
enabled: project.enabled.or(global.enabled),
level: project.level.or(global.level),
dir: project.dir.or(global.dir),
}
}
pub fn resolve(
&self,
cli_log_dir: Option<&str>,
cli_log_level: Option<&str>,
) -> ResolvedLogging {
let dir = cli_log_dir
.map(PathBuf::from)
.or_else(|| self.dir.as_ref().map(PathBuf::from))
.unwrap_or_else(default_log_dir);
let has_explicit_dir = cli_log_dir.is_some() || self.dir.is_some();
let enabled = self.enabled.unwrap_or(has_explicit_dir);
let level = cli_log_level
.map(String::from)
.or_else(|| self.level.clone())
.unwrap_or_else(|| "info".to_string());
ResolvedLogging {
enabled,
level,
dir,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_is_all_none() {
let cfg = LoggingConfig::default();
assert!(cfg.enabled.is_none());
assert!(cfg.level.is_none());
assert!(cfg.dir.is_none());
}
#[test]
fn toml_with_all_fields() {
let toml_str = r#"
enabled = true
level = "debug"
dir = "/tmp/nomi-logs"
"#;
let cfg: LoggingConfig = toml::from_str(toml_str).unwrap();
assert_eq!(cfg.enabled, Some(true));
assert_eq!(cfg.level.as_deref(), Some("debug"));
assert_eq!(cfg.dir.as_deref(), Some("/tmp/nomi-logs"));
}
#[test]
fn toml_empty_uses_defaults() {
let cfg: LoggingConfig = toml::from_str("").unwrap();
assert!(cfg.enabled.is_none());
assert!(cfg.level.is_none());
assert!(cfg.dir.is_none());
}
#[test]
fn merge_project_overrides_global() {
let global = LoggingConfig {
enabled: Some(false),
level: Some("warn".into()),
dir: Some("/global/logs".into()),
};
let project = LoggingConfig {
enabled: Some(true),
level: Some("debug".into()),
dir: None,
};
let merged = LoggingConfig::merge(global, project);
assert_eq!(merged.enabled, Some(true));
assert_eq!(merged.level.as_deref(), Some("debug"));
assert_eq!(merged.dir.as_deref(), Some("/global/logs"));
}
#[test]
fn merge_falls_back_to_global() {
let global = LoggingConfig {
level: Some("info".into()),
..Default::default()
};
let project = LoggingConfig::default();
let merged = LoggingConfig::merge(global, project);
assert_eq!(merged.level.as_deref(), Some("info"));
}
#[test]
fn merge_two_empty_configs() {
let merged = LoggingConfig::merge(LoggingConfig::default(), LoggingConfig::default());
assert!(merged.enabled.is_none());
assert!(merged.level.is_none());
assert!(merged.dir.is_none());
}
#[test]
fn resolve_dir_set_implies_enabled() {
let cfg = LoggingConfig {
dir: Some("/tmp/logs".into()),
..Default::default()
};
let resolved = cfg.resolve(None, None);
assert!(resolved.enabled);
assert_eq!(resolved.dir, PathBuf::from("/tmp/logs"));
assert_eq!(resolved.level, "info");
}
#[test]
fn resolve_nothing_set_means_disabled() {
let cfg = LoggingConfig::default();
let resolved = cfg.resolve(None, None);
assert!(!resolved.enabled);
}
#[test]
fn resolve_cli_overrides_config() {
let cfg = LoggingConfig {
level: Some("warn".into()),
dir: Some("/config/logs".into()),
..Default::default()
};
let resolved = cfg.resolve(Some("/cli/logs"), Some("debug"));
assert_eq!(resolved.dir, PathBuf::from("/cli/logs"));
assert_eq!(resolved.level, "debug");
assert!(resolved.enabled);
}
#[test]
fn resolve_level_defaults_to_info() {
let cfg = LoggingConfig {
enabled: Some(true),
..Default::default()
};
let resolved = cfg.resolve(None, None);
assert_eq!(resolved.level, "info");
}
#[test]
fn default_log_dir_returns_nonempty_path() {
let dir = default_log_dir();
assert!(!dir.as_os_str().is_empty());
}
#[test]
fn default_log_dir_contains_nomi() {
let dir = default_log_dir();
let s = dir.to_string_lossy();
assert!(s.contains("nomi"), "expected 'nomi' in path: {s}");
}
}
@@ -0,0 +1,88 @@
use serde::{Deserialize, Serialize};
/// Configuration for Plan Mode.
///
/// Plan Mode restricts the agent to read-only tools while it builds
/// an implementation plan. After the user approves the plan the agent
/// exits plan mode and regains full tool access.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PlanConfig {
/// Whether Plan Mode tools (EnterPlanMode / ExitPlanMode) are registered.
#[serde(default = "default_true")]
pub enabled: bool,
/// Directory for plan files, relative to the project root.
#[serde(default = "default_plan_directory")]
pub plan_directory: String,
}
impl Default for PlanConfig {
fn default() -> Self {
Self {
enabled: default_true(),
plan_directory: default_plan_directory(),
}
}
}
// --- Default value functions ---
fn default_true() -> bool {
true
}
fn default_plan_directory() -> String {
".nomi/plans".to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_values_match_spec() {
let cfg = PlanConfig::default();
assert!(cfg.enabled);
assert_eq!(cfg.plan_directory, ".nomi/plans");
}
#[test]
fn toml_full_override() {
let toml_str = r#"
enabled = false
plan_directory = "/custom/plans"
"#;
let cfg: PlanConfig = toml::from_str(toml_str).unwrap();
assert!(!cfg.enabled);
assert_eq!(cfg.plan_directory, "/custom/plans");
}
#[test]
fn toml_partial_override_uses_defaults() {
let toml_str = r#"
enabled = false
"#;
let cfg: PlanConfig = toml::from_str(toml_str).unwrap();
assert!(!cfg.enabled);
assert_eq!(cfg.plan_directory, ".nomi/plans");
}
#[test]
fn toml_empty_uses_all_defaults() {
let cfg: PlanConfig = toml::from_str("").unwrap();
assert!(cfg.enabled);
assert_eq!(cfg.plan_directory, ".nomi/plans");
}
#[test]
fn json_serialization_roundtrip() {
let cfg = PlanConfig {
enabled: false,
plan_directory: "/tmp/plans".to_string(),
};
let json = serde_json::to_string(&cfg).unwrap();
let back: PlanConfig = serde_json::from_str(&json).unwrap();
assert!(!back.enabled);
assert_eq!(back.plan_directory, "/tmp/plans");
}
}
@@ -0,0 +1,65 @@
use tokio::process::Command;
pub struct ShellInfo {
pub program: &'static str,
pub flag: &'static str,
}
pub fn shell_info() -> ShellInfo {
if cfg!(windows) {
ShellInfo {
program: "cmd",
flag: "/C",
}
} else {
ShellInfo {
program: "sh",
flag: "-c",
}
}
}
pub fn shell_command_builder(command_str: &str) -> Command {
let info = shell_info();
let mut cmd = Command::new(info.program);
cmd.arg(info.flag).arg(command_str);
// CREATE_NO_WINDOW: don't flash a console window when the host is a GUI app.
#[cfg(windows)]
cmd.creation_flags(0x0800_0000);
cmd
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn shell_info_returns_platform_appropriate_values() {
let info = shell_info();
if cfg!(windows) {
assert_eq!(info.program, "cmd");
assert_eq!(info.flag, "/C");
} else {
assert_eq!(info.program, "sh");
assert_eq!(info.flag, "-c");
}
}
#[tokio::test]
async fn shell_command_builder_allows_env_and_cwd() {
let tmp = std::env::temp_dir();
let cmd_str = if cfg!(windows) {
"echo %MY_VAR%"
} else {
"echo $MY_VAR"
};
let output = shell_command_builder(cmd_str)
.env("MY_VAR", "test_value")
.current_dir(&tmp)
.output()
.await
.expect("builder failed");
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("test_value"));
}
}
@@ -0,0 +1,86 @@
//! Black-box integration tests for CompactConfig (TC-2.2-01 through TC-2.2-03, TC-2.2-07).
//!
//! These test the public API of CompactConfig from a config-file consumer's
//! perspective: default values, full TOML override, partial override, and
//! Config-level integration.
use nomi_config::compact::CompactConfig;
use nomi_config::config::ConfigFile;
/// TC-2.2-01: CompactConfig default values match spec.
#[test]
fn tc_2_2_01_compact_config_defaults() {
let cfg = CompactConfig::default();
assert_eq!(cfg.context_window, 200_000);
assert_eq!(cfg.output_reserve, 20_000);
assert_eq!(cfg.autocompact_buffer, 13_000);
assert_eq!(cfg.emergency_buffer, 3_000);
assert_eq!(cfg.max_failures, 3);
assert_eq!(cfg.micro_keep_recent, 5);
assert_eq!(cfg.micro_gap_seconds, 3600);
assert!(cfg.enabled);
}
/// TC-2.2-02: CompactConfig full TOML parsing.
#[test]
fn tc_2_2_02_compact_config_toml_full() {
let toml_str = r#"
[compact]
context_window = 128000
output_reserve = 15000
autocompact_buffer = 10000
emergency_buffer = 2000
max_failures = 5
micro_keep_recent = 3
micro_gap_seconds = 1800
compactable_tools = ["Read", "Bash"]
enabled = false
"#;
let config: ConfigFile = toml::from_str(toml_str).unwrap();
assert_eq!(config.compact.context_window, 128_000);
assert_eq!(config.compact.output_reserve, 15_000);
assert_eq!(config.compact.autocompact_buffer, 10_000);
assert_eq!(config.compact.emergency_buffer, 2_000);
assert_eq!(config.compact.max_failures, 5);
assert_eq!(config.compact.micro_keep_recent, 3);
assert_eq!(config.compact.micro_gap_seconds, 1800);
assert_eq!(config.compact.compactable_tools, vec!["Read", "Bash"]);
assert!(!config.compact.enabled);
}
/// TC-2.2-03: partial override — only context_window set, rest are defaults.
#[test]
fn tc_2_2_03_compact_config_partial_override() {
let toml_str = r#"
[compact]
context_window = 128000
"#;
let config: ConfigFile = toml::from_str(toml_str).unwrap();
assert_eq!(config.compact.context_window, 128_000);
// All other fields should be defaults
assert_eq!(config.compact.output_reserve, 20_000);
assert_eq!(config.compact.autocompact_buffer, 13_000);
assert_eq!(config.compact.emergency_buffer, 3_000);
assert_eq!(config.compact.max_failures, 3);
assert_eq!(config.compact.micro_keep_recent, 5);
assert_eq!(config.compact.micro_gap_seconds, 3600);
assert!(config.compact.enabled);
}
/// TC-2.2-07: Config TOML with [compact] section parses completely.
#[test]
fn tc_2_2_07_config_with_compact_section() {
let toml_str = r#"
[default]
provider = "anthropic"
[compact]
context_window = 100000
enabled = true
"#;
let config: ConfigFile = toml::from_str(toml_str).unwrap();
assert_eq!(config.compact.context_window, 100_000);
assert!(config.compact.enabled);
// Other config sections should still parse
assert_eq!(config.default.provider, "anthropic");
}
@@ -0,0 +1,51 @@
use nomi_config::logging::{ResolvedLogging, create_file_layer};
use tracing::info;
use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::util::SubscriberInitExt;
#[test]
fn create_file_layer_writes_json_to_file() {
let tmp = tempfile::tempdir().unwrap();
let config = ResolvedLogging {
enabled: true,
level: "info".to_string(),
dir: tmp.path().to_path_buf(),
};
let (layer, _guard) = create_file_layer(&config).unwrap();
tracing_subscriber::registry().with(layer).init();
info!(target: "nomi_test", key = "value", "test message");
drop(_guard);
let entries: Vec<_> = std::fs::read_dir(tmp.path())
.unwrap()
.filter_map(|e| e.ok())
.filter(|e| e.path().extension().is_some_and(|ext| ext == "log"))
.collect();
assert!(!entries.is_empty(), "expected at least one .log file");
let content = std::fs::read_to_string(entries[0].path()).unwrap();
assert!(
content.contains("test message"),
"log should contain message"
);
assert!(content.contains("nomi_test"), "log should contain target");
}
#[test]
fn create_file_layer_creates_directory() {
let tmp = tempfile::tempdir().unwrap();
let nested = tmp.path().join("sub").join("dir");
let config = ResolvedLogging {
enabled: true,
level: "info".to_string(),
dir: nested.clone(),
};
let result = create_file_layer::<tracing_subscriber::Registry>(&config);
assert!(result.is_ok());
assert!(nested.exists());
}
@@ -0,0 +1,75 @@
//! Black-box integration tests for PlanConfig (TC-3.2-01 through TC-3.2-03).
//!
//! These test the public API of PlanConfig from a config-file consumer's
//! perspective: default values, full TOML override, and partial override.
use nomi_config::config::ConfigFile;
use nomi_config::plan::PlanConfig;
/// TC-3.2-01: PlanConfig default values.
/// Input: no `[plan]` section in config.
/// Expected: enabled = true, plan_directory = ".nomi/plans".
#[test]
fn tc_3_2_01_plan_config_defaults() {
let cfg = PlanConfig::default();
assert!(cfg.enabled);
assert_eq!(cfg.plan_directory, ".nomi/plans");
}
/// TC-3.2-01 (variant): absent [plan] section in ConfigFile yields defaults.
#[test]
fn tc_3_2_01_absent_plan_section_uses_defaults() {
let config: ConfigFile = toml::from_str("").unwrap();
assert!(config.plan.enabled);
assert_eq!(config.plan.plan_directory, ".nomi/plans");
}
/// TC-3.2-02: PlanConfig TOML deserialization with all fields.
/// Input: [plan] section with enabled = false and custom plan_directory.
/// Expected: correct parsing.
#[test]
fn tc_3_2_02_plan_config_toml_full() {
let toml_str = r#"
[plan]
enabled = false
plan_directory = "/custom/plans"
"#;
let config: ConfigFile = toml::from_str(toml_str).unwrap();
assert!(!config.plan.enabled);
assert_eq!(config.plan.plan_directory, "/custom/plans");
}
/// TC-3.2-03: PlanConfig partial field override.
/// Input: [plan] section with only enabled = false (no plan_directory).
/// Expected: enabled = false, plan_directory uses default.
#[test]
fn tc_3_2_03_plan_config_partial_override() {
let toml_str = r#"
[plan]
enabled = false
"#;
let config: ConfigFile = toml::from_str(toml_str).unwrap();
assert!(!config.plan.enabled);
assert_eq!(config.plan.plan_directory, ".nomi/plans");
}
/// ConfigFile with [plan] section alongside other sections parses completely.
#[test]
fn plan_config_coexists_with_other_sections() {
let toml_str = r#"
[default]
provider = "anthropic"
[compact]
context_window = 100000
[plan]
enabled = true
plan_directory = ".nomi/custom-plans"
"#;
let config: ConfigFile = toml::from_str(toml_str).unwrap();
assert!(config.plan.enabled);
assert_eq!(config.plan.plan_directory, ".nomi/custom-plans");
assert_eq!(config.default.provider, "anthropic");
assert_eq!(config.compact.context_window, 100_000);
}