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