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    pub pre_execution_blocks: u64,
482    pub pre_execution_warnings: u64,
483    /// `true` when a guardrail filter is active for this session.
484    pub guardrail_enabled: bool,
485    /// `true` when guardrail is in warn-only mode (action = warn).
486    pub guardrail_warn_mode: bool,
487    /// `true` when the SONAR NLI entailment stage is attached for this session.
488    pub nli_enabled: bool,
489    /// Number of NLI entailment checks performed (excludes circuit-breaker skips).
490    pub nli_checks: u64,
491    /// Number of NLI checks that returned a flagged verdict (observe-only, never blocks).
492    pub nli_flags: u64,
493    /// `true` when the PAAC secret masking registry is active for this session.
494    pub secret_masking_enabled: bool,
495    /// Number of vault secrets registered for masking this session.
496    pub secret_mask_registrations: u64,
497    /// Number of outbound LLM chat calls (across every dispatch site, not just the primary
498    /// turn-loop call) that had at least one secret masked.
499    pub secret_mask_applied: u64,
500    /// Number of secret placeholder tokens in tool arguments that failed to unmask (S1): the
501    /// model did not reproduce a `<SECRET:...>` token byte-for-byte, so the affected tool call
502    /// ran with the literal placeholder text instead of the real secret. Fail-safe (no leak),
503    /// but a non-zero count indicates a legitimate tool flow silently broke.
504    pub secret_unmask_misses: u64,
505    pub sub_agents: Vec<SubAgentMetrics>,
506    pub skill_confidence: Vec<SkillConfidence>,
507    /// Scheduled task summaries: `[name, kind, mode, next_run]`.
508    pub scheduled_tasks: Vec<[String; 4]>,
509    /// Thompson Sampling distribution snapshots: `(provider, alpha, beta)`.
510    pub router_thompson_stats: Vec<(String, f64, f64)>,
511    /// Ring buffer of recent security events (cap 100, FIFO eviction).
512    pub security_events: VecDeque<SecurityEvent>,
513    pub orchestration: OrchestrationMetrics,
514    /// Live snapshot of the currently active task graph. `None` when no plan is active.
515    pub orchestration_graph: Option<TaskGraphSnapshot>,
516    pub graph_community_detection_failures: u64,
517    pub graph_entities_total: u64,
518    pub graph_edges_total: u64,
519    pub graph_communities_total: u64,
520    pub graph_extraction_count: u64,
521    pub graph_extraction_failures: u64,
522    /// `true` when `config.llm.cloud.enable_extended_context = true`.
523    /// Never set for other providers to avoid false positives.
524    pub extended_context: bool,
525    /// Latest compression-guidelines version (0 = no guidelines yet).
526    pub guidelines_version: u32,
527    /// ISO 8601 timestamp of the latest guidelines update (empty if none).
528    pub guidelines_updated_at: String,
529    pub tool_cache_hits: u64,
530    pub tool_cache_misses: u64,
531    pub tool_cache_entries: usize,
532    /// Number of semantic-tier facts in memory (0 when tier promotion disabled).
533    pub semantic_fact_count: u64,
534    /// STT model name (e.g. "whisper-1"). `None` when STT is not configured.
535    pub stt_model: Option<String>,
536    /// Model used for context compaction/summarization. `None` when no summary provider is set.
537    pub compaction_model: Option<String>,
538    /// Temperature of the active provider when using Candle. `None` for API providers.
539    pub provider_temperature: Option<f32>,
540    /// Top-p of the active provider when using Candle. `None` for API providers.
541    pub provider_top_p: Option<f32>,
542    /// Embedding model name (e.g. `"nomic-embed-text"`). Empty when embeddings are disabled.
543    pub embedding_model: String,
544    /// Token budget for context window. `None` when not configured.
545    pub token_budget: Option<u64>,
546    /// Token threshold that triggers soft compaction. `None` when not configured.
547    pub compaction_threshold: Option<u32>,
548    /// Vault backend identifier: "age", "env", or "none".
549    pub vault_backend: String,
550    /// Active I/O channel name: `"cli"`, `"telegram"`, `"tui"`, `"discord"`, `"slack"`.
551    pub active_channel: String,
552    /// Background supervisor: inflight tasks across all classes.
553    pub bg_inflight: u64,
554    /// Background supervisor: total tasks dropped due to concurrency limit (all classes).
555    pub bg_dropped: u64,
556    /// Background supervisor: total tasks completed (all classes).
557    pub bg_completed: u64,
558    /// Background supervisor: inflight enrichment tasks.
559    pub bg_enrichment_inflight: u64,
560    /// Background supervisor: inflight telemetry tasks.
561    pub bg_telemetry_inflight: u64,
562    /// In-flight background shell runs. Empty when none are running or no `ShellExecutor` is wired.
563    pub shell_background_runs: Vec<ShellBackgroundRunRow>,
564    /// Whether self-learning (skill evolution) is enabled.
565    pub self_learning_enabled: bool,
566    /// Whether the semantic response cache is enabled.
567    pub semantic_cache_enabled: bool,
568    /// Whether semantic response caching is enabled (alias for `semantic_cache_enabled`).
569    pub cache_enabled: bool,
570    /// Whether assistant messages are auto-saved to memory.
571    pub autosave_enabled: bool,
572    /// Classifier p50/p95 latency metrics per task (injection, pii, feedback).
573    pub classifier: ClassifierMetricsSnapshot,
574    /// Latency breakdown for the most recently completed agent turn.
575    pub last_turn_timings: TurnTimings,
576    /// Bitmask of [`TurnTimings`] fields freshly written into `last_turn_timings` by
577    /// `MetricsBridge`'s span-derived timing since the last `flush_turn_timings` call
578    /// (bit `n` per `metrics_bridge::TimingField` ordinal: `prepare_context`=0, `llm_chat`=1,
579    /// `tool_exec`=2, `persist_message`=3).
580    ///
581    /// `flush_turn_timings` consults this per-field so it only falls back to the manual
582    /// `Instant::now()` timing for fields the bridge did not populate this turn, instead of
583    /// unconditionally clobbering every field (#5946). Cleared (taken) on every flush. Only
584    /// ever set when the `profiling` feature is compiled in.
585    pub bridge_timings_written: u8,
586    /// Rolling average of per-phase latency over the last 10 turns.
587    pub avg_turn_timings: TurnTimings,
588    /// Maximum per-phase latency observed within the rolling window (tail-latency visibility).
589    ///
590    /// M3: exposes `max_in_window` alongside the rolling average for operational monitoring.
591    pub max_turn_timings: TurnTimings,
592    /// Number of turns included in `avg_turn_timings` and `max_turn_timings` (capped at 10).
593    pub timing_sample_count: u64,
594    /// Total egress (outbound HTTP) requests attempted this session.
595    pub egress_requests_total: u64,
596    /// Egress events dropped due to bounded channel backpressure.
597    pub egress_dropped_total: u64,
598    /// Egress requests blocked by scheme/domain/SSRF policy.
599    pub egress_blocked_total: u64,
600    /// Runtime-resolved context window limit (tokens).
601    ///
602    /// Populated from `resolve_context_budget` after provider pool construction and refreshed on
603    /// every `/provider` switch. `0` means unknown (pre-init or provider has no declared window);
604    /// the TUI gauge renders `"—"` in this case to avoid divide-by-zero.
605    pub context_max_tokens: u64,
606    /// Token count at the time the most recent compaction was triggered. `0` = never compacted.
607    pub compaction_last_before: u64,
608    /// Token count after the most recent compaction completed. `0` = never compacted.
609    pub compaction_last_after: u64,
610    /// Unix epoch milliseconds when the most recent compaction occurred. `0` = never compacted.
611    pub compaction_last_at_ms: u64,
612    /// Active long-horizon goal for TUI display. `None` when no goal is active.
613    pub active_goal: Option<crate::goal::GoalSnapshot>,
614    /// Cocoon sidecar connection state. `None` when Cocoon is not configured.
615    /// `Some(true)` = proxy connected, `Some(false)` = unreachable or disconnected.
616    pub cocoon_connected: Option<bool>,
617    /// Worker count reported by the Cocoon sidecar. `0` when not connected or not configured.
618    pub cocoon_worker_count: u32,
619    /// Number of models available through the Cocoon sidecar.
620    pub cocoon_model_count: usize,
621    /// TON wallet balance in TON units. `None` when unknown or Cocoon not configured.
622    pub cocoon_ton_balance: Option<f64>,
623    /// Secret-free summaries of configured `[[llm.providers]]` entries, for the TUI
624    /// settings view's Providers tab (issue #6024). Refreshed at startup, on `/provider`
625    /// switch, and on config hot-reload — never derived from `update_mcp_metrics`, whose
626    /// MCP-lifecycle-only trigger would leave this stale or empty when no MCP servers are
627    /// configured. `Arc<[T]>` keeps this snapshot cheap to clone on the metrics watch channel.
628    pub providers: Arc<[ProviderSummary]>,
629    /// Summaries of configured sub-agent **definitions** (templates), for the TUI settings
630    /// view's Agents tab (issue #6024) — distinct from `sub_agents` (runtime instances).
631    /// Refreshed at the same sites as `providers`.
632    pub agent_definitions: Arc<[AgentDefSummary]>,
633}
634
635/// Snapshot of a single in-flight background shell run for TUI display.
636///
637/// Populated from [`zeph_tools::BackgroundRunSnapshot`] during the per-turn metrics update in
638/// `reap_background_tasks_and_update_metrics`. The `run_id` is truncated to 8 hex chars for
639/// compact TUI rendering.
640#[derive(Debug, Clone, Default, serde::Serialize)]
641pub struct ShellBackgroundRunRow {
642    /// First 8 hex characters of the run's UUID, sufficient for unique identification in TUI.
643    pub run_id: String,
644    /// Original command, truncated to 80 characters.
645    pub command: String,
646    /// Wall-clock elapsed seconds since spawn.
647    pub elapsed_secs: u64,
648}
649
650/// Configuration-derived fields of [`MetricsSnapshot`] that are known at agent startup and do
651/// not change during the session.
652///
653/// Pass this struct to `AgentBuilder::with_static_metrics` immediately after
654/// `AgentBuilder::with_metrics` to initialize all static fields in one place rather than
655/// through scattered `send_modify` calls in the runner.
656///
657/// # Examples
658///
659/// ```no_run
660/// use zeph_core::metrics::StaticMetricsInit;
661///
662/// let init = StaticMetricsInit {
663///     active_channel: "cli".to_owned(),
664///     ..StaticMetricsInit::default()
665/// };
666/// ```
667#[derive(Debug, Default)]
668pub struct StaticMetricsInit {
669    /// STT model name (e.g. `"whisper-1"`). `None` when STT is not configured.
670    pub stt_model: Option<String>,
671    /// Model used for context compaction/summarization. `None` when no summary provider is set.
672    pub compaction_model: Option<String>,
673    /// Whether the semantic response cache is enabled.
674    ///
675    /// This value is also written to [`MetricsSnapshot::cache_enabled`] which is an alias for the
676    /// same concept.
677    pub semantic_cache_enabled: bool,
678    /// Embedding model name (e.g. `"nomic-embed-text"`). Empty when embeddings are disabled.
679    pub embedding_model: String,
680    /// Whether self-learning (skill evolution) is enabled.
681    pub self_learning_enabled: bool,
682    /// Active I/O channel name: `"cli"`, `"telegram"`, `"tui"`, `"discord"`, `"slack"`.
683    pub active_channel: String,
684    /// Token budget for context window. `None` when not configured.
685    pub token_budget: Option<u64>,
686    /// Token threshold that triggers soft compaction. `None` when not configured.
687    pub compaction_threshold: Option<u32>,
688    /// Vault backend identifier: `"age"`, `"env"`, or `"none"`.
689    pub vault_backend: String,
690    /// Whether assistant messages are auto-saved to memory.
691    pub autosave_enabled: bool,
692    /// Override for the active model name. When `Some`, replaces the model name set by the
693    /// builder from `runtime.model_name` (which may be a placeholder) with the effective model
694    /// resolved from the LLM provider configuration.
695    pub model_name_override: Option<String>,
696}
697
698/// Strip ASCII control characters and ANSI escape sequences from a string for safe TUI display.
699///
700/// Allows tab, LF, and CR; removes everything else in the `0x00–0x1F` range including full
701/// ANSI CSI sequences (`ESC[...`). This prevents escape-sequence injection from LLM planner
702/// output into the TUI.
703fn strip_ctrl(s: &str) -> String {
704    let mut out = String::with_capacity(s.len());
705    let mut chars = s.chars().peekable();
706    while let Some(c) = chars.next() {
707        if c == '\x1b' {
708            // Consume an ANSI CSI sequence: ESC [ <params> <final-byte in 0x40–0x7E>
709            if chars.peek() == Some(&'[') {
710                chars.next(); // consume '['
711                for inner in chars.by_ref() {
712                    if ('\x40'..='\x7e').contains(&inner) {
713                        break;
714                    }
715                }
716            }
717            // Drop ESC and any consumed sequence — write nothing.
718        } else if c.is_control() && c != '\t' && c != '\n' && c != '\r' {
719            // drop other control chars
720        } else {
721            out.push(c);
722        }
723    }
724    out
725}
726
727/// Strip control chars, then truncate at 80 chars with an ellipsis (SEC-P6-01) — shared by
728/// every free-text field surfaced from task-graph content (`error`, `handoff_rejected`).
729fn strip_and_truncate_80(s: &str) -> String {
730    let s = strip_ctrl(s);
731    if s.len() > 80 {
732        let end = s.floor_char_boundary(79);
733        format!("{}…", &s[..end])
734    } else {
735        s
736    }
737}
738
739/// Convert a live `TaskGraph` into a lightweight snapshot for TUI display.
740impl From<&zeph_orchestration::TaskGraph> for TaskGraphSnapshot {
741    fn from(graph: &zeph_orchestration::TaskGraph) -> Self {
742        let tasks = graph
743            .tasks
744            .iter()
745            .map(|t| {
746                let error = t
747                    .result
748                    .as_ref()
749                    .filter(|_| t.status == zeph_orchestration::TaskStatus::Failed)
750                    .and_then(|r| {
751                        if r.output.is_empty() {
752                            None
753                        } else {
754                            Some(strip_and_truncate_80(&r.output))
755                        }
756                    });
757                let handoff_rejected = t.handoff_rejected.as_deref().map(strip_and_truncate_80);
758                let duration_ms = t.result.as_ref().map_or(0, |r| r.duration_ms);
759                TaskSnapshotRow {
760                    id: t.id.as_u32(),
761                    title: strip_ctrl(&t.title),
762                    status: t.status.to_string(),
763                    agent: t.assigned_agent.as_deref().map(strip_ctrl),
764                    duration_ms,
765                    error,
766                    handoff_rejected,
767                }
768            })
769            .collect();
770        Self {
771            graph_id: graph.id.to_string(),
772            goal: strip_ctrl(&graph.goal),
773            status: graph.status.to_string(),
774            tasks,
775            completed_at: None,
776        }
777    }
778}
779
780pub struct MetricsCollector {
781    tx: watch::Sender<MetricsSnapshot>,
782}
783
784impl MetricsCollector {
785    #[must_use]
786    pub fn new() -> (Self, watch::Receiver<MetricsSnapshot>) {
787        let (tx, rx) = watch::channel(MetricsSnapshot::default());
788        (Self { tx }, rx)
789    }
790
791    pub fn update(&self, f: impl FnOnce(&mut MetricsSnapshot)) {
792        self.tx.send_modify(f);
793    }
794
795    /// Publish the runtime-resolved context window limit.
796    ///
797    /// Call after the provider pool is constructed (builder) and on every successful
798    /// `/provider` switch so the TUI context gauge reflects the active provider's window.
799    ///
800    /// # Examples
801    ///
802    /// ```rust
803    /// use zeph_core::metrics::MetricsCollector;
804    ///
805    /// let (collector, rx) = MetricsCollector::new();
806    /// collector.set_context_max_tokens(128_000);
807    /// assert_eq!(rx.borrow().context_max_tokens, 128_000);
808    /// ```
809    pub fn set_context_max_tokens(&self, max_tokens: u64) {
810        self.tx.send_modify(|m| m.context_max_tokens = max_tokens);
811    }
812
813    /// Record the outcome of the most recent compaction event.
814    ///
815    /// Sets all three `compaction_last_*` fields atomically. `at_ms` is the Unix epoch in
816    /// milliseconds — obtain via `SystemTime::UNIX_EPOCH.elapsed().unwrap_or_default().as_millis()`.
817    ///
818    /// # Examples
819    ///
820    /// ```rust
821    /// use zeph_core::metrics::MetricsCollector;
822    ///
823    /// let (collector, rx) = MetricsCollector::new();
824    /// collector.record_compaction(50_000, 12_000, 1_700_000_000_000);
825    /// let snap = rx.borrow();
826    /// assert_eq!(snap.compaction_last_before, 50_000);
827    /// assert_eq!(snap.compaction_last_after, 12_000);
828    /// assert_eq!(snap.compaction_last_at_ms, 1_700_000_000_000);
829    /// ```
830    pub fn record_compaction(&self, before: u64, after: u64, at_ms: u64) {
831        self.tx.send_modify(|m| {
832            m.compaction_last_before = before;
833            m.compaction_last_after = after;
834            m.compaction_last_at_ms = at_ms;
835        });
836    }
837
838    /// Returns a clone of the underlying [`watch::Sender`].
839    ///
840    /// Use this to pass the sender to code that requires a raw
841    /// `watch::Sender<MetricsSnapshot>` while the [`MetricsCollector`] is
842    /// also shared (e.g., passed to a `MetricsBridge` layer).
843    #[must_use]
844    pub fn sender(&self) -> watch::Sender<MetricsSnapshot> {
845        self.tx.clone()
846    }
847}
848
849// ---------------------------------------------------------------------------
850// HistogramRecorder
851// ---------------------------------------------------------------------------
852
853/// Per-event histogram recording contract for the agent loop.
854///
855/// Implementors record individual latency observations into Prometheus histograms
856/// (or any other backend). The trait is object-safe: the agent stores an
857/// `Option<Arc<dyn HistogramRecorder>>` and calls these methods at each measurement
858/// point. When `None`, recording is a no-op with zero overhead.
859///
860/// # Contract for implementors
861///
862/// - All methods must be non-blocking; they must not call async code or acquire
863///   mutexes that may block.
864/// - Implementations must be `Send + Sync` — the agent loop runs on the tokio
865///   thread pool and the recorder may be called from multiple tasks.
866///
867/// # Examples
868///
869/// ```rust
870/// use std::sync::Arc;
871/// use std::time::Duration;
872/// use zeph_core::metrics::HistogramRecorder;
873///
874/// struct NoOpRecorder;
875///
876/// impl HistogramRecorder for NoOpRecorder {
877///     fn observe_llm_latency(&self, _: Duration) {}
878///     fn observe_turn_duration(&self, _: Duration) {}
879///     fn observe_tool_execution(&self, _: Duration) {}
880///     fn observe_bg_task(&self, _: &str, _: Duration) {}
881/// }
882///
883/// let recorder: Arc<dyn HistogramRecorder> = Arc::new(NoOpRecorder);
884/// recorder.observe_llm_latency(Duration::from_millis(500));
885/// ```
886pub trait HistogramRecorder: Send + Sync {
887    /// Record a single LLM API call latency observation.
888    fn observe_llm_latency(&self, duration: std::time::Duration);
889
890    /// Record a full agent turn duration observation (context prep + LLM + tools + persist).
891    fn observe_turn_duration(&self, duration: std::time::Duration);
892
893    /// Record a single tool execution latency observation.
894    fn observe_tool_execution(&self, duration: std::time::Duration);
895
896    /// Record a background task completion latency.
897    ///
898    /// `class_label` is `"enrichment"` or `"telemetry"` (from `TaskClass::name()`).
899    fn observe_bg_task(&self, class_label: &str, duration: std::time::Duration);
900}
901
902#[cfg(test)]
903mod tests {
904    #![allow(clippy::field_reassign_with_default)]
905
906    use super::*;
907
908    #[test]
909    fn default_metrics_snapshot() {
910        let m = MetricsSnapshot::default();
911        assert_eq!(m.total_tokens, 0);
912        assert_eq!(m.api_calls, 0);
913        assert!(m.active_skills.is_empty());
914        assert!(m.active_mcp_tools.is_empty());
915        assert_eq!(m.mcp_tool_count, 0);
916        assert_eq!(m.mcp_server_count, 0);
917        assert!(m.provider_name.is_empty());
918        assert_eq!(m.summaries_count, 0);
919        // Phase 2 fields
920        assert!(m.stt_model.is_none());
921        assert!(m.compaction_model.is_none());
922        assert!(m.provider_temperature.is_none());
923        assert!(m.provider_top_p.is_none());
924        assert!(m.active_channel.is_empty());
925        assert!(m.embedding_model.is_empty());
926        assert!(m.token_budget.is_none());
927        assert!(!m.self_learning_enabled);
928        assert!(!m.semantic_cache_enabled);
929    }
930
931    #[test]
932    fn metrics_collector_update_phase2_fields() {
933        let (collector, rx) = MetricsCollector::new();
934        collector.update(|m| {
935            m.stt_model = Some("whisper-1".into());
936            m.compaction_model = Some("haiku".into());
937            m.provider_temperature = Some(0.7);
938            m.provider_top_p = Some(0.95);
939            m.active_channel = "tui".into();
940            m.embedding_model = "nomic-embed-text".into();
941            m.token_budget = Some(200_000);
942            m.self_learning_enabled = true;
943            m.semantic_cache_enabled = true;
944        });
945        let s = rx.borrow();
946        assert_eq!(s.stt_model.as_deref(), Some("whisper-1"));
947        assert_eq!(s.compaction_model.as_deref(), Some("haiku"));
948        assert_eq!(s.provider_temperature, Some(0.7));
949        assert_eq!(s.provider_top_p, Some(0.95));
950        assert_eq!(s.active_channel, "tui");
951        assert_eq!(s.embedding_model, "nomic-embed-text");
952        assert_eq!(s.token_budget, Some(200_000));
953        assert!(s.self_learning_enabled);
954        assert!(s.semantic_cache_enabled);
955    }
956
957    #[test]
958    fn metrics_collector_update() {
959        let (collector, rx) = MetricsCollector::new();
960        collector.update(|m| {
961            m.api_calls = 5;
962            m.total_tokens = 1000;
963        });
964        let snapshot = rx.borrow().clone();
965        assert_eq!(snapshot.api_calls, 5);
966        assert_eq!(snapshot.total_tokens, 1000);
967    }
968
969    #[test]
970    fn metrics_collector_multiple_updates() {
971        let (collector, rx) = MetricsCollector::new();
972        collector.update(|m| m.api_calls = 1);
973        collector.update(|m| m.api_calls += 1);
974        assert_eq!(rx.borrow().api_calls, 2);
975    }
976
977    #[test]
978    fn metrics_snapshot_clone() {
979        let mut m = MetricsSnapshot::default();
980        m.provider_name = "ollama".into();
981        let cloned = m.clone();
982        assert_eq!(cloned.provider_name, "ollama");
983    }
984
985    #[test]
986    fn filter_metrics_tracking() {
987        let (collector, rx) = MetricsCollector::new();
988        collector.update(|m| {
989            m.filter_raw_tokens += 250;
990            m.filter_saved_tokens += 200;
991            m.filter_applications += 1;
992        });
993        collector.update(|m| {
994            m.filter_raw_tokens += 100;
995            m.filter_saved_tokens += 80;
996            m.filter_applications += 1;
997        });
998        let s = rx.borrow();
999        assert_eq!(s.filter_raw_tokens, 350);
1000        assert_eq!(s.filter_saved_tokens, 280);
1001        assert_eq!(s.filter_applications, 2);
1002    }
1003
1004    #[test]
1005    fn filter_confidence_and_command_metrics() {
1006        let (collector, rx) = MetricsCollector::new();
1007        collector.update(|m| {
1008            m.filter_total_commands += 1;
1009            m.filter_filtered_commands += 1;
1010            m.filter_confidence_full += 1;
1011        });
1012        collector.update(|m| {
1013            m.filter_total_commands += 1;
1014            m.filter_confidence_partial += 1;
1015        });
1016        let s = rx.borrow();
1017        assert_eq!(s.filter_total_commands, 2);
1018        assert_eq!(s.filter_filtered_commands, 1);
1019        assert_eq!(s.filter_confidence_full, 1);
1020        assert_eq!(s.filter_confidence_partial, 1);
1021        assert_eq!(s.filter_confidence_fallback, 0);
1022    }
1023
1024    #[test]
1025    fn summaries_count_tracks_summarizations() {
1026        let (collector, rx) = MetricsCollector::new();
1027        collector.update(|m| m.summaries_count += 1);
1028        collector.update(|m| m.summaries_count += 1);
1029        assert_eq!(rx.borrow().summaries_count, 2);
1030    }
1031
1032    #[test]
1033    fn cancellations_counter_increments() {
1034        let (collector, rx) = MetricsCollector::new();
1035        assert_eq!(rx.borrow().cancellations, 0);
1036        collector.update(|m| m.cancellations += 1);
1037        collector.update(|m| m.cancellations += 1);
1038        assert_eq!(rx.borrow().cancellations, 2);
1039    }
1040
1041    #[test]
1042    fn security_event_detail_exact_128_not_truncated() {
1043        let s = "a".repeat(128);
1044        let ev = SecurityEvent::new(SecurityEventCategory::InjectionFlag, "src", s.clone());
1045        assert_eq!(ev.detail, s, "128-char string must not be truncated");
1046    }
1047
1048    #[test]
1049    fn security_event_detail_129_is_truncated() {
1050        let s = "a".repeat(129);
1051        let ev = SecurityEvent::new(SecurityEventCategory::InjectionFlag, "src", s);
1052        assert!(
1053            ev.detail.ends_with('…'),
1054            "129-char string must end with ellipsis"
1055        );
1056        assert!(
1057            ev.detail.len() <= 130,
1058            "truncated detail must be at most 130 bytes"
1059        );
1060    }
1061
1062    #[test]
1063    fn security_event_detail_multibyte_utf8_no_panic() {
1064        // Each '中' is 3 bytes. 43 chars = 129 bytes — triggers truncation at a multi-byte boundary.
1065        let s = "中".repeat(43);
1066        let ev = SecurityEvent::new(SecurityEventCategory::InjectionFlag, "src", s);
1067        assert!(ev.detail.ends_with('…'));
1068    }
1069
1070    #[test]
1071    fn security_event_source_capped_at_64_chars() {
1072        let long_source = "x".repeat(200);
1073        let ev = SecurityEvent::new(SecurityEventCategory::InjectionFlag, long_source, "detail");
1074        assert_eq!(ev.source.len(), 64);
1075    }
1076
1077    #[test]
1078    fn security_event_source_strips_control_chars() {
1079        let source = "tool\x00name\x1b[31m";
1080        let ev = SecurityEvent::new(SecurityEventCategory::InjectionFlag, source, "detail");
1081        assert!(!ev.source.contains('\x00'));
1082        assert!(!ev.source.contains('\x1b'));
1083    }
1084
1085    #[test]
1086    fn security_event_category_as_str() {
1087        assert_eq!(SecurityEventCategory::InjectionFlag.as_str(), "injection");
1088        assert_eq!(SecurityEventCategory::ExfiltrationBlock.as_str(), "exfil");
1089        assert_eq!(SecurityEventCategory::Quarantine.as_str(), "quarantine");
1090        assert_eq!(SecurityEventCategory::Truncation.as_str(), "truncation");
1091        assert_eq!(
1092            SecurityEventCategory::CrossBoundaryMcpToAcp.as_str(),
1093            "cross_boundary_mcp_to_acp"
1094        );
1095    }
1096
1097    #[test]
1098    fn ring_buffer_respects_cap_via_update() {
1099        let (collector, rx) = MetricsCollector::new();
1100        for i in 0..110u64 {
1101            let event = SecurityEvent::new(
1102                SecurityEventCategory::InjectionFlag,
1103                "src",
1104                format!("event {i}"),
1105            );
1106            collector.update(|m| {
1107                if m.security_events.len() >= SECURITY_EVENT_CAP {
1108                    m.security_events.pop_front();
1109                }
1110                m.security_events.push_back(event);
1111            });
1112        }
1113        let snap = rx.borrow();
1114        assert_eq!(snap.security_events.len(), SECURITY_EVENT_CAP);
1115        // FIFO: earliest events evicted, last one present
1116        assert!(snap.security_events.back().unwrap().detail.contains("109"));
1117    }
1118
1119    #[test]
1120    fn security_events_empty_by_default() {
1121        let m = MetricsSnapshot::default();
1122        assert!(m.security_events.is_empty());
1123    }
1124
1125    #[test]
1126    fn orchestration_metrics_default_zero() {
1127        let m = OrchestrationMetrics::default();
1128        assert_eq!(m.plans_total, 0);
1129        assert_eq!(m.tasks_total, 0);
1130        assert_eq!(m.tasks_completed, 0);
1131        assert_eq!(m.tasks_failed, 0);
1132        assert_eq!(m.tasks_skipped, 0);
1133    }
1134
1135    #[test]
1136    fn metrics_snapshot_includes_orchestration_default_zero() {
1137        let m = MetricsSnapshot::default();
1138        assert_eq!(m.orchestration.plans_total, 0);
1139        assert_eq!(m.orchestration.tasks_total, 0);
1140        assert_eq!(m.orchestration.tasks_completed, 0);
1141    }
1142
1143    #[test]
1144    fn orchestration_metrics_update_via_collector() {
1145        let (collector, rx) = MetricsCollector::new();
1146        collector.update(|m| {
1147            m.orchestration.plans_total += 1;
1148            m.orchestration.tasks_total += 5;
1149            m.orchestration.tasks_completed += 3;
1150            m.orchestration.tasks_failed += 1;
1151            m.orchestration.tasks_skipped += 1;
1152        });
1153        let s = rx.borrow();
1154        assert_eq!(s.orchestration.plans_total, 1);
1155        assert_eq!(s.orchestration.tasks_total, 5);
1156        assert_eq!(s.orchestration.tasks_completed, 3);
1157        assert_eq!(s.orchestration.tasks_failed, 1);
1158        assert_eq!(s.orchestration.tasks_skipped, 1);
1159    }
1160
1161    #[test]
1162    fn strip_ctrl_removes_escape_sequences() {
1163        let input = "hello\x1b[31mworld\x00end";
1164        let result = strip_ctrl(input);
1165        assert_eq!(result, "helloworldend");
1166    }
1167
1168    #[test]
1169    fn strip_ctrl_allows_tab_lf_cr() {
1170        let input = "a\tb\nc\rd";
1171        let result = strip_ctrl(input);
1172        assert_eq!(result, "a\tb\nc\rd");
1173    }
1174
1175    #[test]
1176    fn task_graph_snapshot_is_stale_after_30s() {
1177        let mut snap = TaskGraphSnapshot::default();
1178        // Not stale if no completed_at.
1179        assert!(!snap.is_stale());
1180        // Not stale if just completed.
1181        snap.completed_at = Some(std::time::Instant::now());
1182        assert!(!snap.is_stale());
1183        // Stale if completed more than 30s ago.
1184        snap.completed_at = Some(
1185            std::time::Instant::now()
1186                .checked_sub(std::time::Duration::from_secs(31))
1187                .unwrap(),
1188        );
1189        assert!(snap.is_stale());
1190    }
1191
1192    // T1: From<&TaskGraph> correctly maps fields including duration_ms and error truncation.
1193    #[test]
1194    fn task_graph_snapshot_from_task_graph_maps_fields() {
1195        use zeph_orchestration::{GraphStatus, TaskGraph, TaskNode, TaskResult, TaskStatus};
1196
1197        let mut graph = TaskGraph::new("My goal");
1198        let mut task = TaskNode::new(0, "Do work", "description");
1199        task.status = TaskStatus::Failed;
1200        task.assigned_agent = Some("agent-1".into());
1201        task.result = Some(TaskResult {
1202            output: "error occurred here".into(),
1203            artifacts: vec![],
1204            duration_ms: 1234,
1205            agent_id: None,
1206            agent_def: None,
1207        });
1208        graph.tasks.push(task);
1209        graph.status = GraphStatus::Failed;
1210
1211        let snap = TaskGraphSnapshot::from(&graph);
1212        assert_eq!(snap.goal, "My goal");
1213        assert_eq!(snap.status, "failed");
1214        assert_eq!(snap.tasks.len(), 1);
1215        let row = &snap.tasks[0];
1216        assert_eq!(row.title, "Do work");
1217        assert_eq!(row.status, "failed");
1218        assert_eq!(row.agent.as_deref(), Some("agent-1"));
1219        assert_eq!(row.duration_ms, 1234);
1220        assert!(row.error.as_deref().unwrap().contains("error occurred"));
1221    }
1222
1223    // T2: From impl compiles with orchestration feature active.
1224    #[test]
1225    fn task_graph_snapshot_from_compiles_with_feature() {
1226        use zeph_orchestration::TaskGraph;
1227        let graph = TaskGraph::new("feature flag test");
1228        let snap = TaskGraphSnapshot::from(&graph);
1229        assert_eq!(snap.goal, "feature flag test");
1230        assert!(snap.tasks.is_empty());
1231        assert!(!snap.is_stale());
1232    }
1233
1234    // T1-extra: long error is truncated with ellipsis.
1235    #[test]
1236    fn task_graph_snapshot_error_truncated_at_80_chars() {
1237        use zeph_orchestration::{TaskGraph, TaskNode, TaskResult, TaskStatus};
1238
1239        let mut graph = TaskGraph::new("goal");
1240        let mut task = TaskNode::new(0, "t", "d");
1241        task.status = TaskStatus::Failed;
1242        task.result = Some(TaskResult {
1243            output: "e".repeat(100),
1244            artifacts: vec![],
1245            duration_ms: 0,
1246            agent_id: None,
1247            agent_def: None,
1248        });
1249        graph.tasks.push(task);
1250
1251        let snap = TaskGraphSnapshot::from(&graph);
1252        let err = snap.tasks[0].error.as_ref().unwrap();
1253        assert!(err.ends_with('…'), "truncated error must end with ellipsis");
1254        assert!(
1255            err.len() <= 83,
1256            "truncated error must not exceed 80 chars + ellipsis"
1257        );
1258    }
1259
1260    // SEC-P6-01: control chars in task title are stripped.
1261    #[test]
1262    fn task_graph_snapshot_strips_control_chars_from_title() {
1263        use zeph_orchestration::{TaskGraph, TaskNode};
1264
1265        let mut graph = TaskGraph::new("goal\x1b[31m");
1266        let task = TaskNode::new(0, "title\x00injected", "d");
1267        graph.tasks.push(task);
1268
1269        let snap = TaskGraphSnapshot::from(&graph);
1270        assert!(!snap.goal.contains('\x1b'), "goal must not contain escape");
1271        assert!(
1272            !snap.tasks[0].title.contains('\x00'),
1273            "title must not contain null byte"
1274        );
1275    }
1276
1277    // #6390: handoff_rejected is mapped, stripped, and truncated the same way error is.
1278    #[test]
1279    fn task_graph_snapshot_maps_handoff_rejected() {
1280        use zeph_orchestration::{TaskGraph, TaskNode, TaskStatus};
1281
1282        let mut graph = TaskGraph::new("goal");
1283        let mut task = TaskNode::new(0, "Router", "d");
1284        task.status = TaskStatus::Completed;
1285        task.handoff_rejected = Some("goto target already completed\x00".to_string());
1286        graph.tasks.push(task);
1287
1288        let snap = TaskGraphSnapshot::from(&graph);
1289        let rejected = snap.tasks[0].handoff_rejected.as_ref().unwrap();
1290        assert!(rejected.contains("goto target already completed"));
1291        assert!(!rejected.contains('\x00'), "control chars must be stripped");
1292    }
1293
1294    #[test]
1295    fn task_graph_snapshot_handoff_rejected_none_by_default() {
1296        use zeph_orchestration::{TaskGraph, TaskNode};
1297
1298        let mut graph = TaskGraph::new("goal");
1299        graph.tasks.push(TaskNode::new(0, "Router", "d"));
1300
1301        let snap = TaskGraphSnapshot::from(&graph);
1302        assert!(snap.tasks[0].handoff_rejected.is_none());
1303    }
1304
1305    #[test]
1306    fn graph_metrics_default_zero() {
1307        let m = MetricsSnapshot::default();
1308        assert_eq!(m.graph_entities_total, 0);
1309        assert_eq!(m.graph_edges_total, 0);
1310        assert_eq!(m.graph_communities_total, 0);
1311        assert_eq!(m.graph_extraction_count, 0);
1312        assert_eq!(m.graph_extraction_failures, 0);
1313    }
1314
1315    #[test]
1316    fn graph_metrics_update_via_collector() {
1317        let (collector, rx) = MetricsCollector::new();
1318        collector.update(|m| {
1319            m.graph_entities_total = 5;
1320            m.graph_edges_total = 10;
1321            m.graph_communities_total = 2;
1322            m.graph_extraction_count = 7;
1323            m.graph_extraction_failures = 1;
1324        });
1325        let snapshot = rx.borrow().clone();
1326        assert_eq!(snapshot.graph_entities_total, 5);
1327        assert_eq!(snapshot.graph_edges_total, 10);
1328        assert_eq!(snapshot.graph_communities_total, 2);
1329        assert_eq!(snapshot.graph_extraction_count, 7);
1330        assert_eq!(snapshot.graph_extraction_failures, 1);
1331    }
1332
1333    #[test]
1334    fn histogram_recorder_trait_is_object_safe() {
1335        use std::sync::Arc;
1336        use std::time::Duration;
1337
1338        struct NoOpRecorder;
1339        impl HistogramRecorder for NoOpRecorder {
1340            fn observe_llm_latency(&self, _: Duration) {}
1341            fn observe_turn_duration(&self, _: Duration) {}
1342            fn observe_tool_execution(&self, _: Duration) {}
1343            fn observe_bg_task(&self, _: &str, _: Duration) {}
1344        }
1345
1346        // Verify the trait can be used as a trait object (object-safe).
1347        let recorder: Arc<dyn HistogramRecorder> = Arc::new(NoOpRecorder);
1348        recorder.observe_llm_latency(Duration::from_millis(500));
1349        recorder.observe_turn_duration(Duration::from_secs(3));
1350        recorder.observe_tool_execution(Duration::from_millis(100));
1351    }
1352
1353    // ── ProviderSummary / AgentDefSummary whitelist-copy (issue #6024) ─────────────
1354
1355    #[test]
1356    fn provider_summary_never_carries_secret_fields() {
1357        // SC-003: seed a provider with every secret-bearing field and assert none of
1358        // their values are reachable anywhere on the resulting ProviderSummary — the
1359        // struct has no fields that could hold them, by construction.
1360        let entry = zeph_config::ProviderEntry {
1361            name: Some("leaky".to_owned()),
1362            api_key: Some("sk-SUPERSECRET".to_owned()),
1363            cocoon_access_hash: Some("hash-SUPERSECRET".to_owned()),
1364            candle: Some(zeph_config::CandleInlineConfig {
1365                hf_token: Some("hf_SUPERSECRET".to_owned()),
1366                ..Default::default()
1367            }),
1368            ..zeph_config::ProviderEntry::default()
1369        };
1370        let summaries = ProviderSummary::build_pool(&[entry], "leaky");
1371        assert_eq!(summaries.len(), 1);
1372        let debug = format!("{:?}", summaries[0]);
1373        assert!(!debug.contains("SUPERSECRET"));
1374    }
1375
1376    #[test]
1377    fn provider_summary_marks_active_case_insensitively() {
1378        let entry = zeph_config::ProviderEntry {
1379            name: Some("Fast".to_owned()),
1380            ..zeph_config::ProviderEntry::default()
1381        };
1382        let summaries = ProviderSummary::build_pool(&[entry], "fast");
1383        assert!(summaries[0].active);
1384    }
1385
1386    #[test]
1387    fn provider_summary_redacts_base_url_userinfo() {
1388        let entry = zeph_config::ProviderEntry {
1389            name: Some("compat".to_owned()),
1390            base_url: Some("https://user:secret@example.com/v1".to_owned()),
1391            ..zeph_config::ProviderEntry::default()
1392        };
1393        let summaries = ProviderSummary::build_pool(&[entry], "compat");
1394        let base_url = summaries[0].base_url.as_deref().unwrap_or_default();
1395        assert!(!base_url.contains("secret"));
1396        assert!(base_url.contains("example.com"));
1397    }
1398
1399    #[test]
1400    fn provider_summary_empty_pool_produces_empty_slice() {
1401        let summaries = ProviderSummary::build_pool(&[], "");
1402        assert!(summaries.is_empty());
1403    }
1404
1405    #[test]
1406    fn agent_def_summary_maps_definition_fields() {
1407        let def = zeph_subagent::SubAgentDef::for_test("reviewer");
1408        let summaries = AgentDefSummary::build_all(&[def]);
1409        assert_eq!(summaries.len(), 1);
1410        assert_eq!(summaries[0].name, "reviewer");
1411        assert_eq!(summaries[0].tools_summary, "inherit all");
1412    }
1413}