Skip to main content

zeph_core/
metrics.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4use std::collections::VecDeque;
5use std::sync::Arc;
6
7use tokio::sync::watch;
8use zeph_common::SecurityEventCategory;
9
10pub use zeph_llm::{ClassifierMetricsSnapshot, TaskMetricsSnapshot};
11pub use zeph_memory::{CategoryScore, ProbeCategory, ProbeVerdict};
12
13/// A single security event record for TUI display.
14#[derive(Debug, Clone)]
15pub struct SecurityEvent {
16    /// Unix timestamp (seconds since epoch).
17    pub timestamp: u64,
18    pub category: SecurityEventCategory,
19    /// Source that triggered the event (e.g., `web_scrape`, `mcp_response`).
20    pub source: String,
21    /// Short description, capped at 128 chars.
22    pub detail: String,
23}
24
25impl SecurityEvent {
26    #[must_use]
27    pub fn new(
28        category: SecurityEventCategory,
29        source: impl Into<String>,
30        detail: impl Into<String>,
31    ) -> Self {
32        // IMP-1: cap source at 64 chars and strip ASCII control chars.
33        let source: String = source
34            .into()
35            .chars()
36            .filter(|c| !c.is_ascii_control())
37            .take(64)
38            .collect();
39        // CR-1: UTF-8 safe truncation using floor_char_boundary (stable since Rust 1.82).
40        let detail = detail.into();
41        let detail = if detail.len() > 128 {
42            let end = detail.floor_char_boundary(127);
43            format!("{}…", &detail[..end])
44        } else {
45            detail
46        };
47        Self {
48            timestamp: std::time::SystemTime::now()
49                .duration_since(std::time::UNIX_EPOCH)
50                .unwrap_or_default()
51                .as_secs(),
52            category,
53            source,
54            detail,
55        }
56    }
57}
58
59/// Ring buffer capacity for security events.
60pub const SECURITY_EVENT_CAP: usize = 100;
61
62/// Lightweight snapshot of a single task row for TUI display.
63///
64/// Captured from the task graph on each metrics tick; kept minimal on purpose.
65#[derive(Debug, Clone)]
66pub struct TaskSnapshotRow {
67    pub id: u32,
68    pub title: String,
69    /// Stringified `TaskStatus` (e.g. `"pending"`, `"running"`, `"completed"`).
70    pub status: String,
71    pub agent: Option<String>,
72    pub duration_ms: u64,
73    /// Truncated error message (first 80 chars) when the task failed.
74    pub error: Option<String>,
75}
76
77/// Lightweight snapshot of a `TaskGraph` for TUI display.
78#[derive(Debug, Clone, Default)]
79pub struct TaskGraphSnapshot {
80    pub graph_id: String,
81    pub goal: String,
82    /// Stringified `GraphStatus` (e.g. `"created"`, `"running"`, `"completed"`).
83    pub status: String,
84    pub tasks: Vec<TaskSnapshotRow>,
85    pub completed_at: Option<std::time::Instant>,
86}
87
88impl TaskGraphSnapshot {
89    /// Returns `true` if this snapshot represents a terminal plan that finished
90    /// more than 30 seconds ago and should no longer be shown in the TUI.
91    #[must_use]
92    pub fn is_stale(&self) -> bool {
93        self.completed_at
94            .is_some_and(|t| t.elapsed().as_secs() > 30)
95    }
96}
97
98/// Counters for the task orchestration subsystem.
99///
100/// Always present in [`MetricsSnapshot`]; zero-valued when orchestration is inactive.
101#[derive(Debug, Clone, Default)]
102pub struct OrchestrationMetrics {
103    pub plans_total: u64,
104    pub tasks_total: u64,
105    pub tasks_completed: u64,
106    pub tasks_failed: u64,
107    pub tasks_skipped: u64,
108    /// Number of times ensemble-verified plan verification fell back to the single-provider
109    /// path because fewer than quorum members responded (spec `073-orch-ensemble-merge`).
110    /// Always `0` when `[orchestration.ensemble]` is disabled.
111    pub ensemble_degraded_total: u64,
112    /// `agreement_ratio` from the most recent successfully merged ensemble verification.
113    /// `None` until the first ensemble merge completes, or when ensemble verification is
114    /// disabled. Telemetry only — never used for `should_replan` (see `MergeOutcome`).
115    pub ensemble_last_agreement_ratio: Option<f64>,
116    /// Per-member `(name, EMA agreement score, observation count)` snapshot from the
117    /// `EnsembleTracker`, for CLI/TUI stats surfacing. Empty when ensemble verification is
118    /// disabled or no member has been observed yet.
119    pub ensemble_member_stats: Vec<(String, f64, u64)>,
120}
121
122#[non_exhaustive]
123/// Connection status of a single MCP server for TUI display.
124#[derive(Debug, Clone, PartialEq, Eq)]
125pub enum McpServerConnectionStatus {
126    Connected,
127    Failed,
128}
129
130/// Per-server MCP status snapshot for TUI display.
131#[derive(Debug, Clone)]
132pub struct McpServerStatus {
133    pub id: String,
134    pub status: McpServerConnectionStatus,
135    /// Number of tools provided by this server (0 when failed).
136    pub tool_count: usize,
137    /// Human-readable failure reason. Empty when connected.
138    pub error: String,
139    /// Number of `input_schema`s dropped for exceeding the sanitizer's recursion depth cap.
140    pub input_schemas_dropped: usize,
141    /// Number of `output_schema`s dropped for injection or exceeding the depth cap.
142    pub output_schemas_dropped: usize,
143}
144
145/// Read-only, secret-free summary of a single `[[llm.providers]]` entry for the TUI
146/// settings view (issue #6024).
147///
148/// Built by **explicit whitelist-copying** of safe fields from `ProviderEntry` — never
149/// by cloning the entry and redacting afterward — so a future secret field added to
150/// `ProviderEntry` cannot silently leak through this struct (NFR-002/FR-010 of
151/// `/specs/061-tui-settings-editor-parity/spec.md`). Secret-bearing fields
152/// (`api_key`, `cocoon_access_hash`, `candle.hf_token`) are intentionally absent.
153#[derive(Debug, Clone, Default)]
154pub struct ProviderSummary {
155    /// Effective provider name (`ProviderEntry::effective_name`).
156    pub name: String,
157    /// Provider backend type as a lowercase string (e.g. `"claude"`, `"openai"`).
158    pub provider_type: String,
159    /// Configured model identifier, if any.
160    pub model: Option<String>,
161    /// API base URL with any embedded userinfo credentials redacted.
162    pub base_url: Option<String>,
163    /// Configured max output tokens, if any.
164    pub max_tokens: Option<u32>,
165    /// Configured embedding model, if any.
166    pub embedding_model: Option<String>,
167    /// Configured STT model, if any.
168    pub stt_model: Option<String>,
169    /// Whether this entry is the configured default chat provider.
170    pub default: bool,
171    /// Whether this entry is the currently active provider for the running agent.
172    pub active: bool,
173}
174
175impl ProviderSummary {
176    /// Build the whitelist-copied, secret-free summary list for the settings view.
177    ///
178    /// Explicitly copies only the safe field whitelist from each `ProviderEntry` rather
179    /// than cloning the entry and redacting it afterward, so a future secret field added
180    /// to `ProviderEntry` cannot silently leak through this struct — see the type-level
181    /// doc for the full rationale. `base_url` has any embedded userinfo credentials
182    /// (`https://user:pass@host`) stripped via [`zeph_db::redact_url`].
183    ///
184    /// `active_provider_name` should already be resolved to the effective active name
185    /// (callers typically fall back to the running provider's own name when the config's
186    /// `active_provider_name` field is empty, mirroring `provider_cmd.rs`'s own pattern).
187    ///
188    /// # Examples
189    ///
190    /// ```
191    /// use zeph_config::ProviderEntry;
192    /// use zeph_core::metrics::ProviderSummary;
193    ///
194    /// let entry = ProviderEntry {
195    ///     name: Some("fast".to_owned()),
196    ///     model: Some("gpt-4o-mini".to_owned()),
197    ///     default: true,
198    ///     api_key: Some("sk-should-never-appear".to_owned()),
199    ///     ..ProviderEntry::default()
200    /// };
201    /// let summaries = ProviderSummary::build_pool(&[entry], "fast");
202    /// assert_eq!(summaries[0].name, "fast");
203    /// assert!(summaries[0].active);
204    /// ```
205    #[must_use]
206    pub fn build_pool(
207        pool: &[zeph_config::ProviderEntry],
208        active_provider_name: &str,
209    ) -> Arc<[Self]> {
210        pool.iter()
211            .map(|entry| {
212                let name = entry.effective_name();
213                let active = name.eq_ignore_ascii_case(active_provider_name);
214                Self {
215                    provider_type: entry.provider_type.as_str().to_owned(),
216                    model: entry.model.clone(),
217                    base_url: entry
218                        .base_url
219                        .as_ref()
220                        .map(|u| zeph_db::redact_url(u).unwrap_or_else(|| u.clone())),
221                    max_tokens: entry.max_tokens,
222                    embedding_model: entry.embedding_model.clone(),
223                    stt_model: entry.stt_model.clone(),
224                    default: entry.default,
225                    active,
226                    name,
227                }
228            })
229            .collect()
230    }
231}
232
233/// Read-only summary of a sub-agent **definition** (template) for the TUI settings
234/// view (issue #6024), distinct from a runtime spawned instance ([`SubAgentMetrics`]).
235#[derive(Debug, Clone, Default)]
236pub struct AgentDefSummary {
237    /// Agent definition name.
238    pub name: String,
239    /// Human-readable description from the definition's frontmatter.
240    pub description: String,
241    /// Effective model spec as a string (`"inherit"` or a named provider), if any.
242    pub model: Option<String>,
243    /// Definition source, e.g. `"project/my-agent.md"`.
244    pub source: Option<String>,
245    /// Stringified memory scope (`"user"`, `"project"`, `"local"`), if any.
246    pub memory_scope: Option<String>,
247    /// Human-readable summary of the tool access policy (e.g. `"allow: shell, Read"`).
248    pub tools_summary: String,
249}
250
251impl AgentDefSummary {
252    /// Build the summary list for the settings view's Agents tab from the loaded
253    /// sub-agent **definitions** (`.zeph/agents/*.md` templates), not runtime instances.
254    ///
255    /// # Examples
256    ///
257    /// ```
258    /// use zeph_subagent::SubAgentDef;
259    /// use zeph_core::metrics::AgentDefSummary;
260    ///
261    /// let def = SubAgentDef::for_test("reviewer");
262    /// let summaries = AgentDefSummary::build_all(&[def]);
263    /// assert_eq!(summaries[0].name, "reviewer");
264    /// ```
265    #[must_use]
266    pub fn build_all(defs: &[zeph_subagent::SubAgentDef]) -> Arc<[Self]> {
267        defs.iter().map(Self::from_def).collect()
268    }
269
270    fn from_def(def: &zeph_subagent::SubAgentDef) -> Self {
271        Self {
272            name: def.name.clone(),
273            description: def.description.clone(),
274            model: def.model.as_ref().map(|m| m.as_str().to_owned()),
275            source: def.source.clone(),
276            memory_scope: def.memory.map(|scope| {
277                match scope {
278                    zeph_config::MemoryScope::User => "user",
279                    zeph_config::MemoryScope::Project => "project",
280                    zeph_config::MemoryScope::Local => "local",
281                    // MemoryScope is #[non_exhaustive]; fall back to Debug for any future variant.
282                    other => return format!("{other:?}").to_lowercase(),
283                }
284                .to_owned()
285            }),
286            tools_summary: tools_summary(&def.tools, &def.disallowed_tools),
287        }
288    }
289}
290
291/// Render a [`zeph_config::ToolPolicy`] plus its extra denylist as a short human-readable
292/// string for the settings view (e.g. `"allow: shell, Read (except: Write)"`).
293fn tools_summary(policy: &zeph_config::ToolPolicy, disallowed: &[String]) -> String {
294    use std::fmt::Write as _;
295
296    let mut summary = match policy {
297        zeph_config::ToolPolicy::InheritAll => "inherit all".to_owned(),
298        zeph_config::ToolPolicy::AllowList(list) => format!("allow: {}", list.join(", ")),
299        zeph_config::ToolPolicy::DenyList(list) => format!("deny: {}", list.join(", ")),
300        // ToolPolicy is #[non_exhaustive]; fall back to Debug for any future variant.
301        other => format!("{other:?}"),
302    };
303    if !disallowed.is_empty() {
304        let _ = write!(summary, " (except: {})", disallowed.join(", "));
305    }
306    summary
307}
308
309/// Bayesian confidence data for a single skill, used by TUI confidence bar.
310#[derive(Debug, Clone, Default)]
311pub struct SkillConfidence {
312    pub name: String,
313    pub posterior: f64,
314    pub total_uses: u32,
315}
316
317/// Snapshot of a single sub-agent's runtime status.
318#[derive(Debug, Clone, Default)]
319pub struct SubAgentMetrics {
320    pub id: String,
321    pub name: String,
322    /// Stringified `TaskState`: "working", "completed", "failed", "canceled", etc.
323    pub state: String,
324    pub turns_used: u32,
325    pub max_turns: u32,
326    pub background: bool,
327    pub elapsed_secs: u64,
328    /// Stringified `PermissionMode`: `"default"`, `"accept_edits"`, `"dont_ask"`,
329    /// `"bypass_permissions"`, `"plan"`. Empty string when mode is `Default`.
330    pub permission_mode: String,
331    /// Path to the directory containing this agent's JSONL transcript file.
332    /// `None` when transcript writing is disabled for this agent.
333    pub transcript_dir: Option<String>,
334}
335
336/// Per-turn latency breakdown for the four agent hot-path phases.
337///
338/// Populated with `Instant`-based measurements at each phase boundary.
339/// All values are in milliseconds.
340#[derive(Debug, Clone, Default)]
341pub struct TurnTimings {
342    pub prepare_context_ms: u64,
343    pub llm_chat_ms: u64,
344    pub tool_exec_ms: u64,
345    pub persist_message_ms: u64,
346}
347
348/// Live snapshot of agent metrics broadcast via a [`tokio::sync::watch`] channel.
349///
350/// Fields are updated at different rates: some once at startup (static), others every turn
351/// (dynamic). For fields that are known at agent startup and do not change during the session,
352/// use [`StaticMetricsInit`] and `AgentBuilder::with_static_metrics` instead of
353/// adding a raw `send_modify` call in the runner.
354#[derive(Debug, Clone, Default)]
355#[allow(clippy::struct_excessive_bools)] // independent boolean flags; bitflags or enum would obscure semantics without reducing complexity
356pub struct MetricsSnapshot {
357    pub prompt_tokens: u64,
358    pub completion_tokens: u64,
359    pub total_tokens: u64,
360    /// Reasoning tokens from the last turn (`OpenAI` o-series only).
361    ///
362    /// This is a **subset** of `completion_tokens` and must not be added to cost separately.
363    pub reasoning_tokens: u64,
364    pub context_tokens: u64,
365    pub api_calls: u64,
366    pub active_skills: Vec<String>,
367    pub total_skills: usize,
368    /// Total configured MCP servers (connected + failed).
369    pub mcp_server_count: usize,
370    pub mcp_tool_count: usize,
371    /// Number of successfully connected MCP servers.
372    pub mcp_connected_count: usize,
373    /// Per-server connection status list.
374    pub mcp_servers: Vec<McpServerStatus>,
375    pub active_mcp_tools: Vec<String>,
376    pub sqlite_message_count: u64,
377    pub sqlite_conversation_id: Option<zeph_memory::ConversationId>,
378    pub qdrant_available: bool,
379    pub vector_backend: String,
380    pub embeddings_generated: u64,
381    pub last_llm_latency_ms: u64,
382    pub uptime_seconds: u64,
383    pub provider_name: String,
384    pub model_name: String,
385    pub summaries_count: u64,
386    pub context_compactions: u64,
387    /// Number of times the agent entered the Hard compaction tier, including cooldown-skipped
388    /// turns. Not equal to the actual LLM summarization count — reflects pressure, not action.
389    pub compaction_hard_count: u64,
390    /// User-message turns elapsed after each hard compaction event.
391    /// Entry i = turns between hard compaction i and hard compaction i+1 (or session end).
392    /// Empty when no hard compaction occurred during the session.
393    pub compaction_turns_after_hard: Vec<u64>,
394    pub compression_events: u64,
395    pub compression_tokens_saved: u64,
396    /// Tool results compressed by Acon (#4021) this session.
397    pub acon_results_compressed: u64,
398    /// Tokens saved by Acon tool-result compression (#4021) this session.
399    pub acon_tokens_saved: u64,
400    pub tool_output_prunes: u64,
401    /// Compaction probe outcomes (#1609).
402    pub compaction_probe_passes: u64,
403    /// Compaction probe soft failures (summary borderline — compaction proceeded with warning).
404    pub compaction_probe_soft_failures: u64,
405    /// Compaction probe hard failures (compaction blocked due to lossy summary).
406    pub compaction_probe_failures: u64,
407    /// Compaction probe errors (LLM/timeout — non-blocking, compaction proceeded).
408    pub compaction_probe_errors: u64,
409    /// Last compaction probe verdict. `None` before the first probe completes.
410    pub last_probe_verdict: Option<zeph_memory::ProbeVerdict>,
411    /// Last compaction probe score in [0.0, 1.0]. `None` before the first probe
412    /// completes or after an Error verdict (errors produce no score).
413    pub last_probe_score: Option<f32>,
414    /// Per-category scores from the last completed probe.
415    pub last_probe_category_scores: Option<Vec<zeph_memory::CategoryScore>>,
416    /// Configured pass threshold for the compaction probe. Used by TUI for category color-coding.
417    pub compaction_probe_threshold: f32,
418    /// Configured hard-fail threshold for the compaction probe.
419    pub compaction_probe_hard_fail_threshold: f32,
420    pub cache_read_tokens: u64,
421    pub cache_creation_tokens: u64,
422    pub cost_spent_cents: f64,
423    /// Cost per successful task in cents. `None` until at least one task completes.
424    pub cost_cps_cents: Option<f64>,
425    /// Number of successful tasks recorded today.
426    pub cost_successful_tasks: u64,
427    /// Per-provider cost breakdown, sorted by cost descending.
428    pub provider_cost_breakdown: Vec<(String, crate::cost::ProviderUsage)>,
429    pub filter_raw_tokens: u64,
430    pub filter_saved_tokens: u64,
431    pub filter_applications: u64,
432    pub filter_total_commands: u64,
433    pub filter_filtered_commands: u64,
434    pub filter_confidence_full: u64,
435    pub filter_confidence_partial: u64,
436    pub filter_confidence_fallback: u64,
437    pub cancellations: u64,
438    pub server_compaction_events: u64,
439    pub sanitizer_runs: u64,
440    pub sanitizer_injection_flags: u64,
441    /// Injection pattern hits on `ToolResult` (local) sources — likely false positives.
442    ///
443    /// Counts regex hits that fired on content from `shell`, `read_file`, `search_code`, etc.
444    /// These sources are user-owned and not adversarial; a non-zero value indicates a pattern
445    /// that needs tightening or a source reclassification.
446    pub sanitizer_injection_fp_local: u64,
447    pub sanitizer_truncations: u64,
448    pub quarantine_invocations: u64,
449    pub quarantine_failures: u64,
450    /// ML classifier hard-blocked tool outputs (`enforcement_mode=block` only).
451    pub classifier_tool_blocks: u64,
452    /// ML classifier suspicious tool outputs (both enforcement modes).
453    pub classifier_tool_suspicious: u64,
454    /// `TurnCausalAnalyzer` flags: behavioral deviation detected at tool-return boundary.
455    pub causal_ipi_flags: u64,
456    /// VIGIL pre-sanitizer flags: tool outputs matched injection patterns (any action).
457    pub vigil_flags_total: u64,
458    /// VIGIL pre-sanitizer blocks: tool outputs replaced with sentinel (`strict_mode=true`).
459    pub vigil_blocks_total: u64,
460    pub exfiltration_images_blocked: u64,
461    pub exfiltration_tool_urls_flagged: u64,
462    pub exfiltration_memory_guards: u64,
463    pub pii_scrub_count: u64,
464    /// Number of times the PII NER classifier timed out; input fell back to regex-only.
465    pub pii_ner_timeouts: u64,
466    /// Number of times the PII NER circuit breaker tripped (disabled NER for the session).
467    pub pii_ner_circuit_breaker_trips: u64,
468    pub memory_validation_failures: u64,
469    pub rate_limit_trips: u64,
470    pub pre_execution_blocks: u64,
471    pub pre_execution_warnings: u64,
472    /// `true` when a guardrail filter is active for this session.
473    pub guardrail_enabled: bool,
474    /// `true` when guardrail is in warn-only mode (action = warn).
475    pub guardrail_warn_mode: bool,
476    /// `true` when the SONAR NLI entailment stage is attached for this session.
477    pub nli_enabled: bool,
478    /// Number of NLI entailment checks performed (excludes circuit-breaker skips).
479    pub nli_checks: u64,
480    /// Number of NLI checks that returned a flagged verdict (observe-only, never blocks).
481    pub nli_flags: u64,
482    /// `true` when the PAAC secret masking registry is active for this session.
483    pub secret_masking_enabled: bool,
484    /// Number of vault secrets registered for masking this session.
485    pub secret_mask_registrations: u64,
486    /// Number of outbound LLM chat calls (across every dispatch site, not just the primary
487    /// turn-loop call) that had at least one secret masked.
488    pub secret_mask_applied: u64,
489    /// Number of secret placeholder tokens in tool arguments that failed to unmask (S1): the
490    /// model did not reproduce a `<SECRET:...>` token byte-for-byte, so the affected tool call
491    /// ran with the literal placeholder text instead of the real secret. Fail-safe (no leak),
492    /// but a non-zero count indicates a legitimate tool flow silently broke.
493    pub secret_unmask_misses: u64,
494    pub sub_agents: Vec<SubAgentMetrics>,
495    pub skill_confidence: Vec<SkillConfidence>,
496    /// Scheduled task summaries: `[name, kind, mode, next_run]`.
497    pub scheduled_tasks: Vec<[String; 4]>,
498    /// Thompson Sampling distribution snapshots: `(provider, alpha, beta)`.
499    pub router_thompson_stats: Vec<(String, f64, f64)>,
500    /// Ring buffer of recent security events (cap 100, FIFO eviction).
501    pub security_events: VecDeque<SecurityEvent>,
502    pub orchestration: OrchestrationMetrics,
503    /// Live snapshot of the currently active task graph. `None` when no plan is active.
504    pub orchestration_graph: Option<TaskGraphSnapshot>,
505    pub graph_community_detection_failures: u64,
506    pub graph_entities_total: u64,
507    pub graph_edges_total: u64,
508    pub graph_communities_total: u64,
509    pub graph_extraction_count: u64,
510    pub graph_extraction_failures: u64,
511    /// `true` when `config.llm.cloud.enable_extended_context = true`.
512    /// Never set for other providers to avoid false positives.
513    pub extended_context: bool,
514    /// Latest compression-guidelines version (0 = no guidelines yet).
515    pub guidelines_version: u32,
516    /// ISO 8601 timestamp of the latest guidelines update (empty if none).
517    pub guidelines_updated_at: String,
518    pub tool_cache_hits: u64,
519    pub tool_cache_misses: u64,
520    pub tool_cache_entries: usize,
521    /// Number of semantic-tier facts in memory (0 when tier promotion disabled).
522    pub semantic_fact_count: u64,
523    /// STT model name (e.g. "whisper-1"). `None` when STT is not configured.
524    pub stt_model: Option<String>,
525    /// Model used for context compaction/summarization. `None` when no summary provider is set.
526    pub compaction_model: Option<String>,
527    /// Temperature of the active provider when using Candle. `None` for API providers.
528    pub provider_temperature: Option<f32>,
529    /// Top-p of the active provider when using Candle. `None` for API providers.
530    pub provider_top_p: Option<f32>,
531    /// Embedding model name (e.g. `"nomic-embed-text"`). Empty when embeddings are disabled.
532    pub embedding_model: String,
533    /// Token budget for context window. `None` when not configured.
534    pub token_budget: Option<u64>,
535    /// Token threshold that triggers soft compaction. `None` when not configured.
536    pub compaction_threshold: Option<u32>,
537    /// Vault backend identifier: "age", "env", or "none".
538    pub vault_backend: String,
539    /// Active I/O channel name: `"cli"`, `"telegram"`, `"tui"`, `"discord"`, `"slack"`.
540    pub active_channel: String,
541    /// Background supervisor: inflight tasks across all classes.
542    pub bg_inflight: u64,
543    /// Background supervisor: total tasks dropped due to concurrency limit (all classes).
544    pub bg_dropped: u64,
545    /// Background supervisor: total tasks completed (all classes).
546    pub bg_completed: u64,
547    /// Background supervisor: inflight enrichment tasks.
548    pub bg_enrichment_inflight: u64,
549    /// Background supervisor: inflight telemetry tasks.
550    pub bg_telemetry_inflight: u64,
551    /// In-flight background shell runs. Empty when none are running or no `ShellExecutor` is wired.
552    pub shell_background_runs: Vec<ShellBackgroundRunRow>,
553    /// Whether self-learning (skill evolution) is enabled.
554    pub self_learning_enabled: bool,
555    /// Whether the semantic response cache is enabled.
556    pub semantic_cache_enabled: bool,
557    /// Whether semantic response caching is enabled (alias for `semantic_cache_enabled`).
558    pub cache_enabled: bool,
559    /// Whether assistant messages are auto-saved to memory.
560    pub autosave_enabled: bool,
561    /// Classifier p50/p95 latency metrics per task (injection, pii, feedback).
562    pub classifier: ClassifierMetricsSnapshot,
563    /// Latency breakdown for the most recently completed agent turn.
564    pub last_turn_timings: TurnTimings,
565    /// Bitmask of [`TurnTimings`] fields freshly written into `last_turn_timings` by
566    /// `MetricsBridge`'s span-derived timing since the last `flush_turn_timings` call
567    /// (bit `n` per `metrics_bridge::TimingField` ordinal: `prepare_context`=0, `llm_chat`=1,
568    /// `tool_exec`=2, `persist_message`=3).
569    ///
570    /// `flush_turn_timings` consults this per-field so it only falls back to the manual
571    /// `Instant::now()` timing for fields the bridge did not populate this turn, instead of
572    /// unconditionally clobbering every field (#5946). Cleared (taken) on every flush. Only
573    /// ever set when the `profiling` feature is compiled in.
574    pub bridge_timings_written: u8,
575    /// Rolling average of per-phase latency over the last 10 turns.
576    pub avg_turn_timings: TurnTimings,
577    /// Maximum per-phase latency observed within the rolling window (tail-latency visibility).
578    ///
579    /// M3: exposes `max_in_window` alongside the rolling average for operational monitoring.
580    pub max_turn_timings: TurnTimings,
581    /// Number of turns included in `avg_turn_timings` and `max_turn_timings` (capped at 10).
582    pub timing_sample_count: u64,
583    /// Total egress (outbound HTTP) requests attempted this session.
584    pub egress_requests_total: u64,
585    /// Egress events dropped due to bounded channel backpressure.
586    pub egress_dropped_total: u64,
587    /// Egress requests blocked by scheme/domain/SSRF policy.
588    pub egress_blocked_total: u64,
589    /// Runtime-resolved context window limit (tokens).
590    ///
591    /// Populated from `resolve_context_budget` after provider pool construction and refreshed on
592    /// every `/provider` switch. `0` means unknown (pre-init or provider has no declared window);
593    /// the TUI gauge renders `"—"` in this case to avoid divide-by-zero.
594    pub context_max_tokens: u64,
595    /// Token count at the time the most recent compaction was triggered. `0` = never compacted.
596    pub compaction_last_before: u64,
597    /// Token count after the most recent compaction completed. `0` = never compacted.
598    pub compaction_last_after: u64,
599    /// Unix epoch milliseconds when the most recent compaction occurred. `0` = never compacted.
600    pub compaction_last_at_ms: u64,
601    /// Active long-horizon goal for TUI display. `None` when no goal is active.
602    pub active_goal: Option<crate::goal::GoalSnapshot>,
603    /// Cocoon sidecar connection state. `None` when Cocoon is not configured.
604    /// `Some(true)` = proxy connected, `Some(false)` = unreachable or disconnected.
605    pub cocoon_connected: Option<bool>,
606    /// Worker count reported by the Cocoon sidecar. `0` when not connected or not configured.
607    pub cocoon_worker_count: u32,
608    /// Number of models available through the Cocoon sidecar.
609    pub cocoon_model_count: usize,
610    /// TON wallet balance in TON units. `None` when unknown or Cocoon not configured.
611    pub cocoon_ton_balance: Option<f64>,
612    /// Secret-free summaries of configured `[[llm.providers]]` entries, for the TUI
613    /// settings view's Providers tab (issue #6024). Refreshed at startup, on `/provider`
614    /// switch, and on config hot-reload — never derived from `update_mcp_metrics`, whose
615    /// MCP-lifecycle-only trigger would leave this stale or empty when no MCP servers are
616    /// configured. `Arc<[T]>` keeps this snapshot cheap to clone on the metrics watch channel.
617    pub providers: Arc<[ProviderSummary]>,
618    /// Summaries of configured sub-agent **definitions** (templates), for the TUI settings
619    /// view's Agents tab (issue #6024) — distinct from `sub_agents` (runtime instances).
620    /// Refreshed at the same sites as `providers`.
621    pub agent_definitions: Arc<[AgentDefSummary]>,
622}
623
624/// Snapshot of a single in-flight background shell run for TUI display.
625///
626/// Populated from [`zeph_tools::BackgroundRunSnapshot`] during the per-turn metrics update in
627/// `reap_background_tasks_and_update_metrics`. The `run_id` is truncated to 8 hex chars for
628/// compact TUI rendering.
629#[derive(Debug, Clone, Default, serde::Serialize)]
630pub struct ShellBackgroundRunRow {
631    /// First 8 hex characters of the run's UUID, sufficient for unique identification in TUI.
632    pub run_id: String,
633    /// Original command, truncated to 80 characters.
634    pub command: String,
635    /// Wall-clock elapsed seconds since spawn.
636    pub elapsed_secs: u64,
637}
638
639/// Configuration-derived fields of [`MetricsSnapshot`] that are known at agent startup and do
640/// not change during the session.
641///
642/// Pass this struct to `AgentBuilder::with_static_metrics` immediately after
643/// `AgentBuilder::with_metrics` to initialize all static fields in one place rather than
644/// through scattered `send_modify` calls in the runner.
645///
646/// # Examples
647///
648/// ```no_run
649/// use zeph_core::metrics::StaticMetricsInit;
650///
651/// let init = StaticMetricsInit {
652///     active_channel: "cli".to_owned(),
653///     ..StaticMetricsInit::default()
654/// };
655/// ```
656#[derive(Debug, Default)]
657pub struct StaticMetricsInit {
658    /// STT model name (e.g. `"whisper-1"`). `None` when STT is not configured.
659    pub stt_model: Option<String>,
660    /// Model used for context compaction/summarization. `None` when no summary provider is set.
661    pub compaction_model: Option<String>,
662    /// Whether the semantic response cache is enabled.
663    ///
664    /// This value is also written to [`MetricsSnapshot::cache_enabled`] which is an alias for the
665    /// same concept.
666    pub semantic_cache_enabled: bool,
667    /// Embedding model name (e.g. `"nomic-embed-text"`). Empty when embeddings are disabled.
668    pub embedding_model: String,
669    /// Whether self-learning (skill evolution) is enabled.
670    pub self_learning_enabled: bool,
671    /// Active I/O channel name: `"cli"`, `"telegram"`, `"tui"`, `"discord"`, `"slack"`.
672    pub active_channel: String,
673    /// Token budget for context window. `None` when not configured.
674    pub token_budget: Option<u64>,
675    /// Token threshold that triggers soft compaction. `None` when not configured.
676    pub compaction_threshold: Option<u32>,
677    /// Vault backend identifier: `"age"`, `"env"`, or `"none"`.
678    pub vault_backend: String,
679    /// Whether assistant messages are auto-saved to memory.
680    pub autosave_enabled: bool,
681    /// Override for the active model name. When `Some`, replaces the model name set by the
682    /// builder from `runtime.model_name` (which may be a placeholder) with the effective model
683    /// resolved from the LLM provider configuration.
684    pub model_name_override: Option<String>,
685}
686
687/// Strip ASCII control characters and ANSI escape sequences from a string for safe TUI display.
688///
689/// Allows tab, LF, and CR; removes everything else in the `0x00–0x1F` range including full
690/// ANSI CSI sequences (`ESC[...`). This prevents escape-sequence injection from LLM planner
691/// output into the TUI.
692fn strip_ctrl(s: &str) -> String {
693    let mut out = String::with_capacity(s.len());
694    let mut chars = s.chars().peekable();
695    while let Some(c) = chars.next() {
696        if c == '\x1b' {
697            // Consume an ANSI CSI sequence: ESC [ <params> <final-byte in 0x40–0x7E>
698            if chars.peek() == Some(&'[') {
699                chars.next(); // consume '['
700                for inner in chars.by_ref() {
701                    if ('\x40'..='\x7e').contains(&inner) {
702                        break;
703                    }
704                }
705            }
706            // Drop ESC and any consumed sequence — write nothing.
707        } else if c.is_control() && c != '\t' && c != '\n' && c != '\r' {
708            // drop other control chars
709        } else {
710            out.push(c);
711        }
712    }
713    out
714}
715
716/// Convert a live `TaskGraph` into a lightweight snapshot for TUI display.
717impl From<&zeph_orchestration::TaskGraph> for TaskGraphSnapshot {
718    fn from(graph: &zeph_orchestration::TaskGraph) -> Self {
719        let tasks = graph
720            .tasks
721            .iter()
722            .map(|t| {
723                let error = t
724                    .result
725                    .as_ref()
726                    .filter(|_| t.status == zeph_orchestration::TaskStatus::Failed)
727                    .and_then(|r| {
728                        if r.output.is_empty() {
729                            None
730                        } else {
731                            // Strip control chars, then truncate at 80 chars (SEC-P6-01).
732                            let s = strip_ctrl(&r.output);
733                            if s.len() > 80 {
734                                let end = s.floor_char_boundary(79);
735                                Some(format!("{}…", &s[..end]))
736                            } else {
737                                Some(s)
738                            }
739                        }
740                    });
741                let duration_ms = t.result.as_ref().map_or(0, |r| r.duration_ms);
742                TaskSnapshotRow {
743                    id: t.id.as_u32(),
744                    title: strip_ctrl(&t.title),
745                    status: t.status.to_string(),
746                    agent: t.assigned_agent.as_deref().map(strip_ctrl),
747                    duration_ms,
748                    error,
749                }
750            })
751            .collect();
752        Self {
753            graph_id: graph.id.to_string(),
754            goal: strip_ctrl(&graph.goal),
755            status: graph.status.to_string(),
756            tasks,
757            completed_at: None,
758        }
759    }
760}
761
762pub struct MetricsCollector {
763    tx: watch::Sender<MetricsSnapshot>,
764}
765
766impl MetricsCollector {
767    #[must_use]
768    pub fn new() -> (Self, watch::Receiver<MetricsSnapshot>) {
769        let (tx, rx) = watch::channel(MetricsSnapshot::default());
770        (Self { tx }, rx)
771    }
772
773    pub fn update(&self, f: impl FnOnce(&mut MetricsSnapshot)) {
774        self.tx.send_modify(f);
775    }
776
777    /// Publish the runtime-resolved context window limit.
778    ///
779    /// Call after the provider pool is constructed (builder) and on every successful
780    /// `/provider` switch so the TUI context gauge reflects the active provider's window.
781    ///
782    /// # Examples
783    ///
784    /// ```rust
785    /// use zeph_core::metrics::MetricsCollector;
786    ///
787    /// let (collector, rx) = MetricsCollector::new();
788    /// collector.set_context_max_tokens(128_000);
789    /// assert_eq!(rx.borrow().context_max_tokens, 128_000);
790    /// ```
791    pub fn set_context_max_tokens(&self, max_tokens: u64) {
792        self.tx.send_modify(|m| m.context_max_tokens = max_tokens);
793    }
794
795    /// Record the outcome of the most recent compaction event.
796    ///
797    /// Sets all three `compaction_last_*` fields atomically. `at_ms` is the Unix epoch in
798    /// milliseconds — obtain via `SystemTime::UNIX_EPOCH.elapsed().unwrap_or_default().as_millis()`.
799    ///
800    /// # Examples
801    ///
802    /// ```rust
803    /// use zeph_core::metrics::MetricsCollector;
804    ///
805    /// let (collector, rx) = MetricsCollector::new();
806    /// collector.record_compaction(50_000, 12_000, 1_700_000_000_000);
807    /// let snap = rx.borrow();
808    /// assert_eq!(snap.compaction_last_before, 50_000);
809    /// assert_eq!(snap.compaction_last_after, 12_000);
810    /// assert_eq!(snap.compaction_last_at_ms, 1_700_000_000_000);
811    /// ```
812    pub fn record_compaction(&self, before: u64, after: u64, at_ms: u64) {
813        self.tx.send_modify(|m| {
814            m.compaction_last_before = before;
815            m.compaction_last_after = after;
816            m.compaction_last_at_ms = at_ms;
817        });
818    }
819
820    /// Returns a clone of the underlying [`watch::Sender`].
821    ///
822    /// Use this to pass the sender to code that requires a raw
823    /// `watch::Sender<MetricsSnapshot>` while the [`MetricsCollector`] is
824    /// also shared (e.g., passed to a `MetricsBridge` layer).
825    #[must_use]
826    pub fn sender(&self) -> watch::Sender<MetricsSnapshot> {
827        self.tx.clone()
828    }
829}
830
831// ---------------------------------------------------------------------------
832// HistogramRecorder
833// ---------------------------------------------------------------------------
834
835/// Per-event histogram recording contract for the agent loop.
836///
837/// Implementors record individual latency observations into Prometheus histograms
838/// (or any other backend). The trait is object-safe: the agent stores an
839/// `Option<Arc<dyn HistogramRecorder>>` and calls these methods at each measurement
840/// point. When `None`, recording is a no-op with zero overhead.
841///
842/// # Contract for implementors
843///
844/// - All methods must be non-blocking; they must not call async code or acquire
845///   mutexes that may block.
846/// - Implementations must be `Send + Sync` — the agent loop runs on the tokio
847///   thread pool and the recorder may be called from multiple tasks.
848///
849/// # Examples
850///
851/// ```rust
852/// use std::sync::Arc;
853/// use std::time::Duration;
854/// use zeph_core::metrics::HistogramRecorder;
855///
856/// struct NoOpRecorder;
857///
858/// impl HistogramRecorder for NoOpRecorder {
859///     fn observe_llm_latency(&self, _: Duration) {}
860///     fn observe_turn_duration(&self, _: Duration) {}
861///     fn observe_tool_execution(&self, _: Duration) {}
862///     fn observe_bg_task(&self, _: &str, _: Duration) {}
863/// }
864///
865/// let recorder: Arc<dyn HistogramRecorder> = Arc::new(NoOpRecorder);
866/// recorder.observe_llm_latency(Duration::from_millis(500));
867/// ```
868pub trait HistogramRecorder: Send + Sync {
869    /// Record a single LLM API call latency observation.
870    fn observe_llm_latency(&self, duration: std::time::Duration);
871
872    /// Record a full agent turn duration observation (context prep + LLM + tools + persist).
873    fn observe_turn_duration(&self, duration: std::time::Duration);
874
875    /// Record a single tool execution latency observation.
876    fn observe_tool_execution(&self, duration: std::time::Duration);
877
878    /// Record a background task completion latency.
879    ///
880    /// `class_label` is `"enrichment"` or `"telemetry"` (from `TaskClass::name()`).
881    fn observe_bg_task(&self, class_label: &str, duration: std::time::Duration);
882}
883
884#[cfg(test)]
885mod tests {
886    #![allow(clippy::field_reassign_with_default)]
887
888    use super::*;
889
890    #[test]
891    fn default_metrics_snapshot() {
892        let m = MetricsSnapshot::default();
893        assert_eq!(m.total_tokens, 0);
894        assert_eq!(m.api_calls, 0);
895        assert!(m.active_skills.is_empty());
896        assert!(m.active_mcp_tools.is_empty());
897        assert_eq!(m.mcp_tool_count, 0);
898        assert_eq!(m.mcp_server_count, 0);
899        assert!(m.provider_name.is_empty());
900        assert_eq!(m.summaries_count, 0);
901        // Phase 2 fields
902        assert!(m.stt_model.is_none());
903        assert!(m.compaction_model.is_none());
904        assert!(m.provider_temperature.is_none());
905        assert!(m.provider_top_p.is_none());
906        assert!(m.active_channel.is_empty());
907        assert!(m.embedding_model.is_empty());
908        assert!(m.token_budget.is_none());
909        assert!(!m.self_learning_enabled);
910        assert!(!m.semantic_cache_enabled);
911    }
912
913    #[test]
914    fn metrics_collector_update_phase2_fields() {
915        let (collector, rx) = MetricsCollector::new();
916        collector.update(|m| {
917            m.stt_model = Some("whisper-1".into());
918            m.compaction_model = Some("haiku".into());
919            m.provider_temperature = Some(0.7);
920            m.provider_top_p = Some(0.95);
921            m.active_channel = "tui".into();
922            m.embedding_model = "nomic-embed-text".into();
923            m.token_budget = Some(200_000);
924            m.self_learning_enabled = true;
925            m.semantic_cache_enabled = true;
926        });
927        let s = rx.borrow();
928        assert_eq!(s.stt_model.as_deref(), Some("whisper-1"));
929        assert_eq!(s.compaction_model.as_deref(), Some("haiku"));
930        assert_eq!(s.provider_temperature, Some(0.7));
931        assert_eq!(s.provider_top_p, Some(0.95));
932        assert_eq!(s.active_channel, "tui");
933        assert_eq!(s.embedding_model, "nomic-embed-text");
934        assert_eq!(s.token_budget, Some(200_000));
935        assert!(s.self_learning_enabled);
936        assert!(s.semantic_cache_enabled);
937    }
938
939    #[test]
940    fn metrics_collector_update() {
941        let (collector, rx) = MetricsCollector::new();
942        collector.update(|m| {
943            m.api_calls = 5;
944            m.total_tokens = 1000;
945        });
946        let snapshot = rx.borrow().clone();
947        assert_eq!(snapshot.api_calls, 5);
948        assert_eq!(snapshot.total_tokens, 1000);
949    }
950
951    #[test]
952    fn metrics_collector_multiple_updates() {
953        let (collector, rx) = MetricsCollector::new();
954        collector.update(|m| m.api_calls = 1);
955        collector.update(|m| m.api_calls += 1);
956        assert_eq!(rx.borrow().api_calls, 2);
957    }
958
959    #[test]
960    fn metrics_snapshot_clone() {
961        let mut m = MetricsSnapshot::default();
962        m.provider_name = "ollama".into();
963        let cloned = m.clone();
964        assert_eq!(cloned.provider_name, "ollama");
965    }
966
967    #[test]
968    fn filter_metrics_tracking() {
969        let (collector, rx) = MetricsCollector::new();
970        collector.update(|m| {
971            m.filter_raw_tokens += 250;
972            m.filter_saved_tokens += 200;
973            m.filter_applications += 1;
974        });
975        collector.update(|m| {
976            m.filter_raw_tokens += 100;
977            m.filter_saved_tokens += 80;
978            m.filter_applications += 1;
979        });
980        let s = rx.borrow();
981        assert_eq!(s.filter_raw_tokens, 350);
982        assert_eq!(s.filter_saved_tokens, 280);
983        assert_eq!(s.filter_applications, 2);
984    }
985
986    #[test]
987    fn filter_confidence_and_command_metrics() {
988        let (collector, rx) = MetricsCollector::new();
989        collector.update(|m| {
990            m.filter_total_commands += 1;
991            m.filter_filtered_commands += 1;
992            m.filter_confidence_full += 1;
993        });
994        collector.update(|m| {
995            m.filter_total_commands += 1;
996            m.filter_confidence_partial += 1;
997        });
998        let s = rx.borrow();
999        assert_eq!(s.filter_total_commands, 2);
1000        assert_eq!(s.filter_filtered_commands, 1);
1001        assert_eq!(s.filter_confidence_full, 1);
1002        assert_eq!(s.filter_confidence_partial, 1);
1003        assert_eq!(s.filter_confidence_fallback, 0);
1004    }
1005
1006    #[test]
1007    fn summaries_count_tracks_summarizations() {
1008        let (collector, rx) = MetricsCollector::new();
1009        collector.update(|m| m.summaries_count += 1);
1010        collector.update(|m| m.summaries_count += 1);
1011        assert_eq!(rx.borrow().summaries_count, 2);
1012    }
1013
1014    #[test]
1015    fn cancellations_counter_increments() {
1016        let (collector, rx) = MetricsCollector::new();
1017        assert_eq!(rx.borrow().cancellations, 0);
1018        collector.update(|m| m.cancellations += 1);
1019        collector.update(|m| m.cancellations += 1);
1020        assert_eq!(rx.borrow().cancellations, 2);
1021    }
1022
1023    #[test]
1024    fn security_event_detail_exact_128_not_truncated() {
1025        let s = "a".repeat(128);
1026        let ev = SecurityEvent::new(SecurityEventCategory::InjectionFlag, "src", s.clone());
1027        assert_eq!(ev.detail, s, "128-char string must not be truncated");
1028    }
1029
1030    #[test]
1031    fn security_event_detail_129_is_truncated() {
1032        let s = "a".repeat(129);
1033        let ev = SecurityEvent::new(SecurityEventCategory::InjectionFlag, "src", s);
1034        assert!(
1035            ev.detail.ends_with('…'),
1036            "129-char string must end with ellipsis"
1037        );
1038        assert!(
1039            ev.detail.len() <= 130,
1040            "truncated detail must be at most 130 bytes"
1041        );
1042    }
1043
1044    #[test]
1045    fn security_event_detail_multibyte_utf8_no_panic() {
1046        // Each '中' is 3 bytes. 43 chars = 129 bytes — triggers truncation at a multi-byte boundary.
1047        let s = "中".repeat(43);
1048        let ev = SecurityEvent::new(SecurityEventCategory::InjectionFlag, "src", s);
1049        assert!(ev.detail.ends_with('…'));
1050    }
1051
1052    #[test]
1053    fn security_event_source_capped_at_64_chars() {
1054        let long_source = "x".repeat(200);
1055        let ev = SecurityEvent::new(SecurityEventCategory::InjectionFlag, long_source, "detail");
1056        assert_eq!(ev.source.len(), 64);
1057    }
1058
1059    #[test]
1060    fn security_event_source_strips_control_chars() {
1061        let source = "tool\x00name\x1b[31m";
1062        let ev = SecurityEvent::new(SecurityEventCategory::InjectionFlag, source, "detail");
1063        assert!(!ev.source.contains('\x00'));
1064        assert!(!ev.source.contains('\x1b'));
1065    }
1066
1067    #[test]
1068    fn security_event_category_as_str() {
1069        assert_eq!(SecurityEventCategory::InjectionFlag.as_str(), "injection");
1070        assert_eq!(SecurityEventCategory::ExfiltrationBlock.as_str(), "exfil");
1071        assert_eq!(SecurityEventCategory::Quarantine.as_str(), "quarantine");
1072        assert_eq!(SecurityEventCategory::Truncation.as_str(), "truncation");
1073        assert_eq!(
1074            SecurityEventCategory::CrossBoundaryMcpToAcp.as_str(),
1075            "cross_boundary_mcp_to_acp"
1076        );
1077    }
1078
1079    #[test]
1080    fn ring_buffer_respects_cap_via_update() {
1081        let (collector, rx) = MetricsCollector::new();
1082        for i in 0..110u64 {
1083            let event = SecurityEvent::new(
1084                SecurityEventCategory::InjectionFlag,
1085                "src",
1086                format!("event {i}"),
1087            );
1088            collector.update(|m| {
1089                if m.security_events.len() >= SECURITY_EVENT_CAP {
1090                    m.security_events.pop_front();
1091                }
1092                m.security_events.push_back(event);
1093            });
1094        }
1095        let snap = rx.borrow();
1096        assert_eq!(snap.security_events.len(), SECURITY_EVENT_CAP);
1097        // FIFO: earliest events evicted, last one present
1098        assert!(snap.security_events.back().unwrap().detail.contains("109"));
1099    }
1100
1101    #[test]
1102    fn security_events_empty_by_default() {
1103        let m = MetricsSnapshot::default();
1104        assert!(m.security_events.is_empty());
1105    }
1106
1107    #[test]
1108    fn orchestration_metrics_default_zero() {
1109        let m = OrchestrationMetrics::default();
1110        assert_eq!(m.plans_total, 0);
1111        assert_eq!(m.tasks_total, 0);
1112        assert_eq!(m.tasks_completed, 0);
1113        assert_eq!(m.tasks_failed, 0);
1114        assert_eq!(m.tasks_skipped, 0);
1115    }
1116
1117    #[test]
1118    fn metrics_snapshot_includes_orchestration_default_zero() {
1119        let m = MetricsSnapshot::default();
1120        assert_eq!(m.orchestration.plans_total, 0);
1121        assert_eq!(m.orchestration.tasks_total, 0);
1122        assert_eq!(m.orchestration.tasks_completed, 0);
1123    }
1124
1125    #[test]
1126    fn orchestration_metrics_update_via_collector() {
1127        let (collector, rx) = MetricsCollector::new();
1128        collector.update(|m| {
1129            m.orchestration.plans_total += 1;
1130            m.orchestration.tasks_total += 5;
1131            m.orchestration.tasks_completed += 3;
1132            m.orchestration.tasks_failed += 1;
1133            m.orchestration.tasks_skipped += 1;
1134        });
1135        let s = rx.borrow();
1136        assert_eq!(s.orchestration.plans_total, 1);
1137        assert_eq!(s.orchestration.tasks_total, 5);
1138        assert_eq!(s.orchestration.tasks_completed, 3);
1139        assert_eq!(s.orchestration.tasks_failed, 1);
1140        assert_eq!(s.orchestration.tasks_skipped, 1);
1141    }
1142
1143    #[test]
1144    fn strip_ctrl_removes_escape_sequences() {
1145        let input = "hello\x1b[31mworld\x00end";
1146        let result = strip_ctrl(input);
1147        assert_eq!(result, "helloworldend");
1148    }
1149
1150    #[test]
1151    fn strip_ctrl_allows_tab_lf_cr() {
1152        let input = "a\tb\nc\rd";
1153        let result = strip_ctrl(input);
1154        assert_eq!(result, "a\tb\nc\rd");
1155    }
1156
1157    #[test]
1158    fn task_graph_snapshot_is_stale_after_30s() {
1159        let mut snap = TaskGraphSnapshot::default();
1160        // Not stale if no completed_at.
1161        assert!(!snap.is_stale());
1162        // Not stale if just completed.
1163        snap.completed_at = Some(std::time::Instant::now());
1164        assert!(!snap.is_stale());
1165        // Stale if completed more than 30s ago.
1166        snap.completed_at = Some(
1167            std::time::Instant::now()
1168                .checked_sub(std::time::Duration::from_secs(31))
1169                .unwrap(),
1170        );
1171        assert!(snap.is_stale());
1172    }
1173
1174    // T1: From<&TaskGraph> correctly maps fields including duration_ms and error truncation.
1175    #[test]
1176    fn task_graph_snapshot_from_task_graph_maps_fields() {
1177        use zeph_orchestration::{GraphStatus, TaskGraph, TaskNode, TaskResult, TaskStatus};
1178
1179        let mut graph = TaskGraph::new("My goal");
1180        let mut task = TaskNode::new(0, "Do work", "description");
1181        task.status = TaskStatus::Failed;
1182        task.assigned_agent = Some("agent-1".into());
1183        task.result = Some(TaskResult {
1184            output: "error occurred here".into(),
1185            artifacts: vec![],
1186            duration_ms: 1234,
1187            agent_id: None,
1188            agent_def: None,
1189        });
1190        graph.tasks.push(task);
1191        graph.status = GraphStatus::Failed;
1192
1193        let snap = TaskGraphSnapshot::from(&graph);
1194        assert_eq!(snap.goal, "My goal");
1195        assert_eq!(snap.status, "failed");
1196        assert_eq!(snap.tasks.len(), 1);
1197        let row = &snap.tasks[0];
1198        assert_eq!(row.title, "Do work");
1199        assert_eq!(row.status, "failed");
1200        assert_eq!(row.agent.as_deref(), Some("agent-1"));
1201        assert_eq!(row.duration_ms, 1234);
1202        assert!(row.error.as_deref().unwrap().contains("error occurred"));
1203    }
1204
1205    // T2: From impl compiles with orchestration feature active.
1206    #[test]
1207    fn task_graph_snapshot_from_compiles_with_feature() {
1208        use zeph_orchestration::TaskGraph;
1209        let graph = TaskGraph::new("feature flag test");
1210        let snap = TaskGraphSnapshot::from(&graph);
1211        assert_eq!(snap.goal, "feature flag test");
1212        assert!(snap.tasks.is_empty());
1213        assert!(!snap.is_stale());
1214    }
1215
1216    // T1-extra: long error is truncated with ellipsis.
1217    #[test]
1218    fn task_graph_snapshot_error_truncated_at_80_chars() {
1219        use zeph_orchestration::{TaskGraph, TaskNode, TaskResult, TaskStatus};
1220
1221        let mut graph = TaskGraph::new("goal");
1222        let mut task = TaskNode::new(0, "t", "d");
1223        task.status = TaskStatus::Failed;
1224        task.result = Some(TaskResult {
1225            output: "e".repeat(100),
1226            artifacts: vec![],
1227            duration_ms: 0,
1228            agent_id: None,
1229            agent_def: None,
1230        });
1231        graph.tasks.push(task);
1232
1233        let snap = TaskGraphSnapshot::from(&graph);
1234        let err = snap.tasks[0].error.as_ref().unwrap();
1235        assert!(err.ends_with('…'), "truncated error must end with ellipsis");
1236        assert!(
1237            err.len() <= 83,
1238            "truncated error must not exceed 80 chars + ellipsis"
1239        );
1240    }
1241
1242    // SEC-P6-01: control chars in task title are stripped.
1243    #[test]
1244    fn task_graph_snapshot_strips_control_chars_from_title() {
1245        use zeph_orchestration::{TaskGraph, TaskNode};
1246
1247        let mut graph = TaskGraph::new("goal\x1b[31m");
1248        let task = TaskNode::new(0, "title\x00injected", "d");
1249        graph.tasks.push(task);
1250
1251        let snap = TaskGraphSnapshot::from(&graph);
1252        assert!(!snap.goal.contains('\x1b'), "goal must not contain escape");
1253        assert!(
1254            !snap.tasks[0].title.contains('\x00'),
1255            "title must not contain null byte"
1256        );
1257    }
1258
1259    #[test]
1260    fn graph_metrics_default_zero() {
1261        let m = MetricsSnapshot::default();
1262        assert_eq!(m.graph_entities_total, 0);
1263        assert_eq!(m.graph_edges_total, 0);
1264        assert_eq!(m.graph_communities_total, 0);
1265        assert_eq!(m.graph_extraction_count, 0);
1266        assert_eq!(m.graph_extraction_failures, 0);
1267    }
1268
1269    #[test]
1270    fn graph_metrics_update_via_collector() {
1271        let (collector, rx) = MetricsCollector::new();
1272        collector.update(|m| {
1273            m.graph_entities_total = 5;
1274            m.graph_edges_total = 10;
1275            m.graph_communities_total = 2;
1276            m.graph_extraction_count = 7;
1277            m.graph_extraction_failures = 1;
1278        });
1279        let snapshot = rx.borrow().clone();
1280        assert_eq!(snapshot.graph_entities_total, 5);
1281        assert_eq!(snapshot.graph_edges_total, 10);
1282        assert_eq!(snapshot.graph_communities_total, 2);
1283        assert_eq!(snapshot.graph_extraction_count, 7);
1284        assert_eq!(snapshot.graph_extraction_failures, 1);
1285    }
1286
1287    #[test]
1288    fn histogram_recorder_trait_is_object_safe() {
1289        use std::sync::Arc;
1290        use std::time::Duration;
1291
1292        struct NoOpRecorder;
1293        impl HistogramRecorder for NoOpRecorder {
1294            fn observe_llm_latency(&self, _: Duration) {}
1295            fn observe_turn_duration(&self, _: Duration) {}
1296            fn observe_tool_execution(&self, _: Duration) {}
1297            fn observe_bg_task(&self, _: &str, _: Duration) {}
1298        }
1299
1300        // Verify the trait can be used as a trait object (object-safe).
1301        let recorder: Arc<dyn HistogramRecorder> = Arc::new(NoOpRecorder);
1302        recorder.observe_llm_latency(Duration::from_millis(500));
1303        recorder.observe_turn_duration(Duration::from_secs(3));
1304        recorder.observe_tool_execution(Duration::from_millis(100));
1305    }
1306
1307    // ── ProviderSummary / AgentDefSummary whitelist-copy (issue #6024) ─────────────
1308
1309    #[test]
1310    fn provider_summary_never_carries_secret_fields() {
1311        // SC-003: seed a provider with every secret-bearing field and assert none of
1312        // their values are reachable anywhere on the resulting ProviderSummary — the
1313        // struct has no fields that could hold them, by construction.
1314        let entry = zeph_config::ProviderEntry {
1315            name: Some("leaky".to_owned()),
1316            api_key: Some("sk-SUPERSECRET".to_owned()),
1317            cocoon_access_hash: Some("hash-SUPERSECRET".to_owned()),
1318            candle: Some(zeph_config::CandleInlineConfig {
1319                hf_token: Some("hf_SUPERSECRET".to_owned()),
1320                ..Default::default()
1321            }),
1322            ..zeph_config::ProviderEntry::default()
1323        };
1324        let summaries = ProviderSummary::build_pool(&[entry], "leaky");
1325        assert_eq!(summaries.len(), 1);
1326        let debug = format!("{:?}", summaries[0]);
1327        assert!(!debug.contains("SUPERSECRET"));
1328    }
1329
1330    #[test]
1331    fn provider_summary_marks_active_case_insensitively() {
1332        let entry = zeph_config::ProviderEntry {
1333            name: Some("Fast".to_owned()),
1334            ..zeph_config::ProviderEntry::default()
1335        };
1336        let summaries = ProviderSummary::build_pool(&[entry], "fast");
1337        assert!(summaries[0].active);
1338    }
1339
1340    #[test]
1341    fn provider_summary_redacts_base_url_userinfo() {
1342        let entry = zeph_config::ProviderEntry {
1343            name: Some("compat".to_owned()),
1344            base_url: Some("https://user:secret@example.com/v1".to_owned()),
1345            ..zeph_config::ProviderEntry::default()
1346        };
1347        let summaries = ProviderSummary::build_pool(&[entry], "compat");
1348        let base_url = summaries[0].base_url.as_deref().unwrap_or_default();
1349        assert!(!base_url.contains("secret"));
1350        assert!(base_url.contains("example.com"));
1351    }
1352
1353    #[test]
1354    fn provider_summary_empty_pool_produces_empty_slice() {
1355        let summaries = ProviderSummary::build_pool(&[], "");
1356        assert!(summaries.is_empty());
1357    }
1358
1359    #[test]
1360    fn agent_def_summary_maps_definition_fields() {
1361        let def = zeph_subagent::SubAgentDef::for_test("reviewer");
1362        let summaries = AgentDefSummary::build_all(&[def]);
1363        assert_eq!(summaries.len(), 1);
1364        assert_eq!(summaries[0].name, "reviewer");
1365        assert_eq!(summaries[0].tools_summary, "inherit all");
1366    }
1367}