diff --git a/CHANGELOG.md b/CHANGELOG.md index 53ce6c8c1..c08af5cfe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -89,6 +89,34 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Fixed +- `fix(config)`: `Config::validate()` now calls 7 subsystem `validate()` functions that existed + with real invariant checks but were unreachable from the production config-load path — only + their own unit tests exercised them (#5932). `validate_pool()` was the most severe gap: two + code comments elsewhere in the codebase state, verbatim, that it "rejects an empty + `[[llm.providers]]` list at config-validation time" and treat that as a load-bearing guarantee, + but it was never actually invoked outside its own test module — a config with zero providers, + duplicate provider names, or multiple `default = true` entries previously loaded and validated + without error. The other six wired checks: `LlmConfig::validate_stt()` (dangling + `[llm.stt].provider` reference), `TrajectorySentinelConfig::validate()` (inverted risk + thresholds), `GatewayConfig::validate()`, `UtilityScoringConfig::validate()`, + `FidelityConfig::validate()`, and `AconConfig::validate()`. Also added `#[must_use]` to + `LlmConfig::validate_stt()` (missed by the earlier #4943/#4963 sweep since added afterward). + Because `Config::default()` had an empty provider pool, wiring `validate_pool()` made + `Config::default().validate()` fail, which in turn broke `--dump-config-defaults` and the + no-config-file fallback in `zeph --tui --connect`; fixed by seeding `Config::default()` with one + `ProviderEntry::default()` (`type = "ollama"`), matching the shipped `config/default.toml` + reference, which was already self-consistent. +- `fix(config)`: two more `--migrate-config --in-place` steps re-appended their advisory block on + every run instead of converging after one, same defect class as #5945 (#6018). The + `mcp`/`mcp.elicitation`/`mcp` max-connect-attempts/retry-and-tool-timeout steps anchored on a + raw `toml_src.contains("[mcp]\n")` substring, which also matches inside a *commented* `# [mcp]` + stub left by the top-level migrator's catch-all pass when `[mcp]` was absent on a prior run — + now gated by `section_header_present(toml_src, "mcp")`, which correctly excludes commented + headers. `migrate_memory_retrieval_query_bias`'s idempotency guard checked for an *uncommented* + `query_bias_correction` field while the step only ever writes a *commented* advisory line, so + the guard never matched its own prior output; changed to a `contains` check on the raw source + (and switched to `section_header_present` for its section-presence check, for consistency with + the sibling `[memory.hebbian]` steps fixed in #5945). - `fix(tui)`: the durable executions overlay panel (`D` key) no longer bleeds stray glyphs from whatever widget last drew into the same sidebar `Rect` earlier in the frame (e.g. a trailing `e> t` fragment left over from the subagents/plan/security view rendered underneath) — `Clear` diff --git a/crates/zeph-config/src/loader.rs b/crates/zeph-config/src/loader.rs index bccfb0060..9f6feb547 100644 --- a/crates/zeph-config/src/loader.rs +++ b/crates/zeph-config/src/loader.rs @@ -85,6 +85,29 @@ impl Config { self.acp .validate_auth_clients() .map_err(ConfigError::Validation)?; + // Provider pool: empty pool, duplicate names, and multiple `default = true` + // entries. Load-bearing guarantee relied on (verbatim) by + // `Agent::resolve_pool_entry_provider` (tier_loop.rs) and `arise.rs` — both assume + // a genuinely empty pool never occurs for a fully constructed production `Agent`. + crate::providers::validate_pool(&self.llm.providers)?; + self.llm.validate_stt()?; + self.security + .trajectory + .validate() + .map_err(ConfigError::Validation)?; + self.gateway.validate().map_err(ConfigError::Validation)?; + self.tools + .utility + .validate() + .map_err(ConfigError::Validation)?; + if let Some(fidelity) = &self.memory.fidelity { + fidelity.validate().map_err(ConfigError::Validation)?; + } + self.memory + .compression + .acon + .validate() + .map_err(ConfigError::Validation)?; Ok(()) } @@ -1091,4 +1114,167 @@ weight = 0.3 "unexpected error: {err}" ); } + + // ── #5932: 7 previously-dead validate() functions now wired into Config::validate() ────── + + #[test] + fn validate_rejects_empty_provider_pool() { + // This is the most severe gap from #5932: `validate_pool` was documented (verbatim) + // as a load-bearing guarantee by tier_loop.rs/arise.rs but was never actually wired + // in. `Config::default()` itself now seeds one provider (critic S1 follow-up, so + // `--dump-config-defaults` output stays self-consistent) — clear it explicitly to + // exercise the empty-pool branch. + let mut cfg = Config::default(); + cfg.llm.providers.clear(); + let err = cfg.validate().unwrap_err().to_string(); + assert!( + err.contains("at least one LLM provider"), + "expected empty-pool error, got: {err}" + ); + } + + #[test] + fn validate_rejects_duplicate_provider_names() { + let mut cfg = Config::default(); + cfg.llm.providers.push(crate::providers::ProviderEntry { + provider_type: crate::providers::ProviderKind::Ollama, + ..Default::default() + }); + let err = cfg.validate().unwrap_err().to_string(); + assert!( + err.contains("duplicate provider name"), + "expected duplicate-name error, got: {err}" + ); + } + + #[test] + fn validate_rejects_multiple_default_providers() { + let mut cfg = Config::default(); + cfg.llm.providers[0].default = true; + cfg.llm.providers.push(crate::providers::ProviderEntry { + provider_type: crate::providers::ProviderKind::Ollama, + name: Some("second".into()), + default: true, + ..Default::default() + }); + let err = cfg.validate().unwrap_err().to_string(); + assert!( + err.contains("default = true"), + "expected multiple-default error, got: {err}" + ); + } + + #[test] + fn validate_rejects_stt_provider_pointing_at_nonexistent_provider() { + let mut cfg = Config::default(); + cfg.llm.stt = Some(crate::providers::SttConfig { + provider: crate::providers::ProviderName::new("ghost"), + language: crate::providers::default_stt_language(), + }); + let err = cfg.validate().unwrap_err().to_string(); + assert!( + err.contains("[llm.stt].provider") && err.contains("ghost"), + "expected stt-provider-mismatch error, got: {err}" + ); + } + + #[test] + fn validate_accepts_stt_provider_matching_existing_provider() { + let mut cfg = Config::default(); + cfg.llm.stt = Some(crate::providers::SttConfig { + provider: crate::providers::ProviderName::new("ollama"), + language: crate::providers::default_stt_language(), + }); + assert!(cfg.validate().is_ok()); + } + + #[test] + fn validate_rejects_trajectory_sentinel_inverted_thresholds() { + let mut cfg = Config::default(); + cfg.security.trajectory.elevated_at = 0.9; + cfg.security.trajectory.high_at = 0.5; + let err = cfg.validate().unwrap_err().to_string(); + assert!( + err.contains("elevated_at") && err.contains("high_at"), + "expected trajectory threshold-ordering error, got: {err}" + ); + } + + #[test] + fn validate_rejects_gateway_invalid_webhook_timeout() { + // `rate_limit` and `max_body_size` are already covered by `validate_scalar_bounds` + // (runs earlier in the pipeline), so testing those wouldn't prove + // `GatewayConfig::validate()` is actually wired in — it would pass identically on + // pre-#5932 code (critic-flagged shadowing, S2). `webhook_send_timeout_secs` is the + // one field uniquely reachable only through the new call. + let mut cfg = Config::default(); + cfg.gateway.webhook_send_timeout_secs = 0; + let err = cfg.validate().unwrap_err().to_string(); + assert!( + err.contains("webhook_send_timeout_secs"), + "expected gateway webhook_send_timeout_secs error, got: {err}" + ); + } + + #[test] + fn validate_rejects_negative_utility_scoring_weight() { + let mut cfg = Config::default(); + cfg.tools.utility.gain_weight = -1.0; + let err = cfg.validate().unwrap_err().to_string(); + assert!( + err.contains("gain_weight"), + "expected utility-scoring weight error, got: {err}" + ); + } + + #[test] + fn validate_rejects_fidelity_threshold_ordering() { + let mut cfg = Config::default(); + cfg.memory.fidelity = Some(crate::fidelity::FidelityConfig { + full_threshold: 0.2, + compressed_threshold: 0.5, + ..Default::default() + }); + let err = cfg.validate().unwrap_err().to_string(); + assert!( + err.contains("full_threshold") && err.contains("compressed_threshold"), + "expected fidelity threshold-ordering error, got: {err}" + ); + } + + #[test] + fn validate_accepts_absent_fidelity_config() { + let cfg = Config::default(); + assert!(cfg.memory.fidelity.is_none()); + assert!(cfg.validate().is_ok()); + } + + #[test] + fn validate_rejects_acon_inverted_thresholds() { + let mut cfg = Config::default(); + cfg.memory.compression.acon.passthrough_threshold = 5000; + cfg.memory.compression.acon.summarize_threshold = 1000; + let err = cfg.validate().unwrap_err().to_string(); + assert!( + err.contains("passthrough_threshold") && err.contains("summarize_threshold"), + "expected acon threshold-ordering error, got: {err}" + ); + } + + /// Regression test (critic S1): `Config::default()` must itself satisfy `validate_pool` + /// so `--dump-config-defaults` (which serializes `Config::default()` verbatim, + /// `src/runner.rs`) emits a config that `zeph --config ` can actually load and + /// validate, rather than a self-inconsistent onboarding trap. + #[test] + fn dump_defaults_output_is_self_consistent_and_validates() { + assert!(Config::default().validate().is_ok()); + + let dumped = Config::dump_defaults().expect("dump defaults"); + assert!( + dumped.contains("[[llm.providers]]"), + "dumped defaults must include an active provider entry, got:\n{dumped}" + ); + let reparsed: Config = toml::from_str(&dumped).expect("reparse dumped defaults"); + assert!(reparsed.validate().is_ok()); + } } diff --git a/crates/zeph-config/src/migrate/mcp.rs b/crates/zeph-config/src/migrate/mcp.rs index c8f8aaba9..d66b212eb 100644 --- a/crates/zeph-config/src/migrate/mcp.rs +++ b/crates/zeph-config/src/migrate/mcp.rs @@ -7,7 +7,7 @@ //! the [`Migration`](super::Migration) trait, and the [`MIGRATIONS`](super::MIGRATIONS) //! registry remain in the parent module. -use super::{MigrateError, MigrationResult}; +use super::{MigrateError, MigrationResult, section_header_present}; /// Migrate `[[mcp.servers]]` entries to add `trust_level = "trusted"` for any entry /// that lacks an explicit `trust_level`. @@ -79,21 +79,20 @@ pub fn migrate_mcp_trust_levels(toml_src: &str) -> Result Result { - // Idempotency: check for any elicitation key presence. - if toml_src.contains("elicitation_enabled") || toml_src.contains("# elicitation_enabled") { - return Ok(MigrationResult { - output: toml_src.to_owned(), - changed_count: 0, - sections_changed: Vec::new(), - }); - } - - // Only inject under an existing [mcp] section. - if !toml_src.contains("[mcp]") { + // Idempotency: the written form is always commented, so a plain substring check catches + // both the active and commented form of the key. + if toml_src.contains("elicitation_enabled") { return Ok(MigrationResult { output: toml_src.to_owned(), changed_count: 0, @@ -101,8 +100,10 @@ pub fn migrate_mcp_elicitation_config(toml_src: &str) -> Result Result Result Result Result Result { // Already handled by migrate_memory_retrieval_config if the whole section is absent. - // This step only splices the key into an existing [memory.retrieval] section. - if !toml_src.lines().any(|l| l.trim() == "[memory.retrieval]") { + // This step only splices the key into an existing [memory.retrieval] section. Uses + // `section_header_present` (not a bare exact-line match) so an inline-commented header + // (`[memory.retrieval] # note`) is still recognized as active, matching the pattern + // used by the sibling `[memory.hebbian]` steps (critic M3, #5945 lesson). + if !section_header_present(toml_src, "memory.retrieval") { return Ok(MigrationResult { output: toml_src.to_owned(), changed_count: 0, @@ -525,11 +532,9 @@ pub fn migrate_memory_retrieval_query_bias( }); } - // Idempotent: key already present (active or as comment). - if toml_src - .lines() - .any(|l| l.trim().starts_with("query_bias_correction")) - { + // Idempotent: key already present, active or commented — the step only ever writes + // the commented form, so a plain substring check catches both. + if toml_src.contains("query_bias_correction") { return Ok(MigrationResult { output: toml_src.to_owned(), changed_count: 0, diff --git a/crates/zeph-config/src/migrate/tests.rs b/crates/zeph-config/src/migrate/tests.rs index 6792bc659..7ef310c53 100644 --- a/crates/zeph-config/src/migrate/tests.rs +++ b/crates/zeph-config/src/migrate/tests.rs @@ -1357,6 +1357,28 @@ fn migrate_mcp_elicitation_skips_without_trailing_newline() { assert_eq!(result.output, src); } +#[test] +fn migrate_mcp_elicitation_skips_on_commented_mcp_header() { + // A commented `# [mcp]` header (e.g. written by the ConfigMigrator catch-all pass + // when the whole section was absent) must not be treated as an active section — + // a naive substring check on "[mcp]\n" would match inside it and splice the + // advisory comment into the middle of the commented block (#6018). + let src = "# [mcp]\n# allowed_commands = []\n"; + let result = migrate_mcp_elicitation_config(src).expect("migrate"); + assert_eq!(result.changed_count, 0); + assert_eq!(result.output, src); +} + +#[test] +fn migrate_mcp_elicitation_run_twice_is_idempotent() { + let src = "[mcp]\nallowed_commands = []\n"; + let first = migrate_mcp_elicitation_config(src).expect("first migrate"); + assert_eq!(first.changed_count, 1); + let second = migrate_mcp_elicitation_config(&first.output).expect("second migrate"); + assert_eq!(second.changed_count, 0, "second run must be a no-op"); + assert_eq!(second.output, first.output); +} + // ── migrate_quality_config ──────────────────────────────────────────────── #[test] @@ -1551,6 +1573,59 @@ fn migrate_memory_retrieval_idempotent_on_commented_section() { assert_eq!(result.output, src); } +// ── migrate_memory_retrieval_query_bias ─────────────────────────────────── + +#[test] +fn migrate_memory_retrieval_query_bias_adds_comment_when_absent() { + let src = "[memory.retrieval]\ndepth = 40\n"; + let result = migrate_memory_retrieval_query_bias(src).expect("migrate"); + assert_eq!(result.changed_count, 1); + assert!( + result + .sections_changed + .contains(&"memory.retrieval.query_bias_correction".to_owned()) + ); + assert!(result.output.contains("# query_bias_correction = true")); +} + +#[test] +fn migrate_memory_retrieval_query_bias_skips_when_section_absent() { + let src = "[agent]\nname = \"Zeph\"\n"; + let result = migrate_memory_retrieval_query_bias(src).expect("migrate"); + assert_eq!(result.changed_count, 0); + assert_eq!(result.output, src); +} + +#[test] +fn migrate_memory_retrieval_query_bias_idempotent_when_key_active() { + let src = "[memory.retrieval]\ndepth = 40\nquery_bias_correction = true\n"; + let result = migrate_memory_retrieval_query_bias(src).expect("migrate"); + assert_eq!(result.changed_count, 0); + assert_eq!(result.output, src); +} + +/// Regression test for #6018: the guard previously used `starts_with` on trimmed lines, +/// which never matches the `#`-prefixed line this step actually writes, so the block was +/// re-appended on every run (same defect shape as the `[memory.hebbian]` steps fixed in +/// #5945). +#[test] +fn migrate_memory_retrieval_query_bias_run_twice_is_idempotent() { + let src = "[memory.retrieval]\ndepth = 40\n"; + let first = migrate_memory_retrieval_query_bias(src).expect("first migrate"); + assert_eq!(first.changed_count, 1); + let second = migrate_memory_retrieval_query_bias(&first.output).expect("second migrate"); + assert_eq!(second.changed_count, 0, "second run must be a no-op"); + assert_eq!( + second.output, first.output, + "output must not accumulate duplicate advisory blocks" + ); + assert_eq!( + second.output.matches("query_bias_correction").count(), + 1, + "the key must appear exactly once" + ); +} + // ── acp PR4 migration ───────────────────────────────────────────────────── #[test] @@ -2051,6 +2126,26 @@ fn migrate_mcp_max_connect_attempts_skips_when_no_mcp_section() { assert_eq!(result.output, src); } +#[test] +fn migrate_mcp_max_connect_attempts_skips_on_commented_mcp_header() { + // Same anchor defect class as migrate_mcp_elicitation_config (#6018): a commented + // `# [mcp]` header must not be treated as an active section. + let src = "# [mcp]\n# allowed_commands = []\n"; + let result = migrate_mcp_max_connect_attempts(src).expect("migrate"); + assert_eq!(result.changed_count, 0); + assert_eq!(result.output, src); +} + +#[test] +fn migrate_mcp_max_connect_attempts_run_twice_is_idempotent() { + let src = "[mcp]\nallowed_commands = []\n"; + let first = migrate_mcp_max_connect_attempts(src).expect("first migrate"); + assert_eq!(first.changed_count, 1); + let second = migrate_mcp_max_connect_attempts(&first.output).expect("second migrate"); + assert_eq!(second.changed_count, 0, "second run must be a no-op"); + assert_eq!(second.output, first.output); +} + // ── Step 50 — mcp startup_retry_backoff_ms and tool_timeout_secs ────────────────────────────── #[test] @@ -2084,6 +2179,77 @@ fn migrate_mcp_retry_and_tool_timeout_skips_when_no_mcp_section() { assert_eq!(result.output, src); } +#[test] +fn migrate_mcp_retry_and_tool_timeout_skips_on_commented_mcp_header() { + // Same anchor defect class as migrate_mcp_elicitation_config (#6018): a commented + // `# [mcp]` header must not be treated as an active section. + let src = "# [mcp]\n# allowed_commands = []\n"; + let result = migrate_mcp_retry_and_tool_timeout(src).expect("migrate"); + assert_eq!(result.changed_count, 0); + assert_eq!(result.output, src); +} + +#[test] +fn migrate_mcp_retry_and_tool_timeout_run_twice_is_idempotent() { + let src = "[mcp]\nallowed_commands = []\n"; + let first = migrate_mcp_retry_and_tool_timeout(src).expect("first migrate"); + assert_eq!(first.changed_count, 1); + let second = migrate_mcp_retry_and_tool_timeout(&first.output).expect("second migrate"); + assert_eq!(second.changed_count, 0, "second run must be a no-op"); + assert_eq!(second.output, first.output); +} + +/// Regression test for #6018: a config that starts with no `[mcp]` section, run three +/// consecutive times through the full step registry, must stabilize by run 2 and never +/// re-append the elicitation/retry/timeout advisory keys. +#[test] +fn migrate_mcp_steps_full_pipeline_stabilizes_across_runs() { + let mut out = "[agent]\nname = \"Zeph\"\n".to_owned(); + for _ in 0..3 { + out = migrate_mcp_elicitation_config(&out) + .expect("migrate") + .output; + out = migrate_mcp_max_connect_attempts(&out) + .expect("migrate") + .output; + out = migrate_mcp_retry_and_tool_timeout(&out) + .expect("migrate") + .output; + } + assert_eq!( + out.matches("elicitation_enabled").count(), + 0, + "no active [mcp] section ever appears, so elicitation must never be injected" + ); + assert_eq!(out.matches("max_connect_attempts").count(), 0); + assert_eq!(out.matches("startup_retry_backoff_ms").count(), 0); + + // Now seed an active [mcp] section (as ConfigMigrator's catch-all pass would leave + // behind before named steps run in the next `--migrate-config` invocation) and + // confirm three more runs converge without duplicating any advisory block. + out.push_str("\n[mcp]\nallowed_commands = []\n"); + let mut prev = String::new(); + for _ in 0..3 { + out = migrate_mcp_elicitation_config(&out) + .expect("migrate") + .output; + out = migrate_mcp_max_connect_attempts(&out) + .expect("migrate") + .output; + out = migrate_mcp_retry_and_tool_timeout(&out) + .expect("migrate") + .output; + if !prev.is_empty() { + assert_eq!(out, prev, "pipeline must stabilize after the first pass"); + } + prev = out.clone(); + } + assert_eq!(out.matches("elicitation_enabled").count(), 1); + assert_eq!(out.matches("max_connect_attempts").count(), 1); + assert_eq!(out.matches("startup_retry_backoff_ms").count(), 1); + assert_eq!(out.matches("tool_timeout_secs").count(), 1); +} + // ── Step 43 — orchestrator_provider ────────────────────────────────────────────────────────── #[test] diff --git a/crates/zeph-config/src/providers/llm.rs b/crates/zeph-config/src/providers/llm.rs index db3584094..c7cf5dcf1 100644 --- a/crates/zeph-config/src/providers/llm.rs +++ b/crates/zeph-config/src/providers/llm.rs @@ -449,6 +449,7 @@ impl LlmConfig { /// # Errors /// /// Returns `ConfigError::Validation` when the referenced STT provider does not exist. + #[must_use = "validation result must be checked"] pub fn validate_stt(&self) -> Result<(), crate::error::ConfigError> { use crate::error::ConfigError; diff --git a/crates/zeph-config/src/root.rs b/crates/zeph-config/src/root.rs index cc499b074..dc9be36b0 100644 --- a/crates/zeph-config/src/root.rs +++ b/crates/zeph-config/src/root.rs @@ -32,7 +32,7 @@ use crate::memory::{ use crate::metrics::MetricsConfig; use crate::notifications::NotificationsConfig; use crate::providers::{ - LlmConfig, get_default_embedding_model, get_default_response_cache_ttl_secs, + LlmConfig, ProviderEntry, get_default_embedding_model, get_default_response_cache_ttl_secs, get_default_router_ema_alpha, get_default_router_reorder_interval, }; use crate::security::TrustConfig; @@ -216,7 +216,12 @@ impl Default for Config { supervisor: crate::agent::TaskSupervisorConfig::default(), }, llm: LlmConfig { - providers: Vec::new(), + // #5932/#6018-followup (critic S1): `Config::default()` must itself satisfy + // `validate_pool` (non-empty pool), since it is what `--dump-config-defaults` + // serializes and what `Config::load()`/`run_tui_remote` fall back to when no + // config file exists — mirrors the single active `[[llm.providers]]` entry the + // shipped `config/default.toml` reference ships. + providers: vec![ProviderEntry::default()], routing: crate::providers::LlmRoutingStrategy::None, embedding_model: get_default_embedding_model(), candle: None, diff --git a/src/acp.rs b/src/acp.rs index e8068c025..f1eab0878 100644 --- a/src/acp.rs +++ b/src/acp.rs @@ -4280,7 +4280,11 @@ mod tests { #[test] fn acp_provider_names_empty_providers_returns_empty_vec() { - let config = zeph_core::config::Config::default(); + // `Config::default()` now seeds one provider so `--dump-config-defaults` output + // stays self-consistent with `validate_pool` (#5932 critic follow-up) — clear it + // explicitly to exercise the empty-providers branch. + let mut config = zeph_core::config::Config::default(); + config.llm.providers.clear(); assert!(acp_provider_names(&config).is_empty()); }