Update: 将子项目从 submodule 转为完整内容
- 移除 GovAI, nomifun-tauri, 算力盒子 的 submodule 引用 - 添加所有子项目的完整源代码 - 保留原始 .git 为 .git.bak 备份
This commit is contained in:
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user