1use 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#[derive(Debug, Clone)]
15pub struct SecurityEvent {
16 pub timestamp: u64,
18 pub category: SecurityEventCategory,
19 pub source: String,
21 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 let source: String = source
34 .into()
35 .chars()
36 .filter(|c| !c.is_ascii_control())
37 .take(64)
38 .collect();
39 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
59pub const SECURITY_EVENT_CAP: usize = 100;
61
62#[derive(Debug, Clone)]
66pub struct TaskSnapshotRow {
67 pub id: u32,
68 pub title: String,
69 pub status: String,
71 pub agent: Option<String>,
72 pub duration_ms: u64,
73 pub error: Option<String>,
75 pub handoff_rejected: Option<String>,
82}
83
84#[derive(Debug, Clone, Default)]
86pub struct TaskGraphSnapshot {
87 pub graph_id: String,
88 pub goal: String,
89 pub status: String,
91 pub tasks: Vec<TaskSnapshotRow>,
92 pub completed_at: Option<std::time::Instant>,
93}
94
95impl TaskGraphSnapshot {
96 #[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#[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 pub ensemble_degraded_total: u64,
119 pub ensemble_last_agreement_ratio: Option<f64>,
123 pub ensemble_member_stats: Vec<(String, f64, u64)>,
127}
128
129#[non_exhaustive]
130#[derive(Debug, Clone, PartialEq, Eq)]
132pub enum McpServerConnectionStatus {
133 Connected,
134 Failed,
135}
136
137#[derive(Debug, Clone)]
139pub struct McpServerStatus {
140 pub id: String,
141 pub status: McpServerConnectionStatus,
142 pub tool_count: usize,
144 pub error: String,
146 pub input_schemas_dropped: usize,
148 pub output_schemas_dropped: usize,
150}
151
152#[derive(Debug, Clone, Default)]
161pub struct ProviderSummary {
162 pub name: String,
164 pub provider_type: String,
166 pub model: Option<String>,
168 pub base_url: Option<String>,
170 pub max_tokens: Option<u32>,
172 pub embedding_model: Option<String>,
174 pub stt_model: Option<String>,
176 pub default: bool,
178 pub active: bool,
180}
181
182impl ProviderSummary {
183 #[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#[derive(Debug, Clone, Default)]
243pub struct AgentDefSummary {
244 pub name: String,
246 pub description: String,
248 pub model: Option<String>,
250 pub source: Option<String>,
252 pub memory_scope: Option<String>,
254 pub tools_summary: String,
256}
257
258impl AgentDefSummary {
259 #[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 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
298fn 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 other => format!("{other:?}"),
309 };
310 if !disallowed.is_empty() {
311 let _ = write!(summary, " (except: {})", disallowed.join(", "));
312 }
313 summary
314}
315
316#[derive(Debug, Clone, Default)]
318pub struct SkillConfidence {
319 pub name: String,
320 pub posterior: f64,
321 pub total_uses: u32,
322}
323
324#[derive(Debug, Clone, Default)]
326pub struct SubAgentMetrics {
327 pub id: String,
328 pub name: String,
329 pub state: String,
331 pub turns_used: u32,
332 pub max_turns: u32,
333 pub background: bool,
334 pub elapsed_secs: u64,
335 pub permission_mode: String,
338 pub transcript_dir: Option<String>,
341 pub live_transcript: Vec<String>,
345}
346
347#[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#[derive(Debug, Clone, Default)]
366#[allow(clippy::struct_excessive_bools)] pub struct MetricsSnapshot {
368 pub prompt_tokens: u64,
369 pub completion_tokens: u64,
370 pub total_tokens: u64,
371 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 pub mcp_server_count: usize,
381 pub mcp_tool_count: usize,
382 pub mcp_connected_count: usize,
384 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 pub compaction_hard_count: u64,
401 pub compaction_turns_after_hard: Vec<u64>,
405 pub compression_events: u64,
406 pub compression_tokens_saved: u64,
407 pub acon_results_compressed: u64,
409 pub acon_tokens_saved: u64,
411 pub tool_output_prunes: u64,
412 pub compaction_probe_passes: u64,
414 pub compaction_probe_soft_failures: u64,
416 pub compaction_probe_failures: u64,
418 pub compaction_probe_errors: u64,
420 pub last_probe_verdict: Option<zeph_memory::ProbeVerdict>,
422 pub last_probe_score: Option<f32>,
425 pub last_probe_category_scores: Option<Vec<zeph_memory::CategoryScore>>,
427 pub compaction_probe_threshold: f32,
429 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 pub cost_cps_cents: Option<f64>,
436 pub cost_successful_tasks: u64,
438 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 pub sanitizer_injection_fp_local: u64,
458 pub sanitizer_truncations: u64,
459 pub quarantine_invocations: u64,
460 pub quarantine_failures: u64,
461 pub classifier_tool_blocks: u64,
463 pub classifier_tool_suspicious: u64,
465 pub causal_ipi_flags: u64,
467 pub vigil_flags_total: u64,
469 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 pub pii_ner_timeouts: u64,
477 pub pii_ner_circuit_breaker_trips: u64,
479 pub memory_validation_failures: u64,
480 pub rate_limit_trips: u64,
481 pub cost_budget_exhausted: u64,
484 pub pre_execution_blocks: u64,
485 pub pre_execution_warnings: u64,
486 pub guardrail_enabled: bool,
488 pub guardrail_warn_mode: bool,
490 pub nli_enabled: bool,
492 pub nli_checks: u64,
494 pub nli_flags: u64,
496 pub secret_masking_enabled: bool,
498 pub secret_mask_registrations: u64,
500 pub secret_mask_applied: u64,
503 pub secret_unmask_misses: u64,
508 pub sub_agents: Vec<SubAgentMetrics>,
509 pub skill_confidence: Vec<SkillConfidence>,
510 pub scheduled_tasks: Vec<[String; 4]>,
512 pub router_thompson_stats: Vec<(String, f64, f64)>,
514 pub security_events: VecDeque<SecurityEvent>,
516 pub orchestration: OrchestrationMetrics,
517 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 pub extended_context: bool,
528 pub guidelines_version: u32,
530 pub guidelines_updated_at: String,
532 pub tool_cache_hits: u64,
533 pub tool_cache_misses: u64,
534 pub tool_cache_entries: usize,
535 pub semantic_fact_count: u64,
537 pub stt_model: Option<String>,
539 pub compaction_model: Option<String>,
541 pub provider_temperature: Option<f32>,
543 pub provider_top_p: Option<f32>,
545 pub embedding_model: String,
547 pub token_budget: Option<u64>,
549 pub compaction_threshold: Option<u32>,
551 pub vault_backend: String,
553 pub active_channel: String,
555 pub bg_inflight: u64,
557 pub bg_dropped: u64,
559 pub bg_completed: u64,
561 pub bg_enrichment_inflight: u64,
563 pub bg_telemetry_inflight: u64,
565 pub shell_background_runs: Vec<ShellBackgroundRunRow>,
567 pub self_learning_enabled: bool,
569 pub semantic_cache_enabled: bool,
571 pub cache_enabled: bool,
573 pub autosave_enabled: bool,
575 pub classifier: ClassifierMetricsSnapshot,
577 pub last_turn_timings: TurnTimings,
579 pub bridge_timings_written: u8,
589 pub avg_turn_timings: TurnTimings,
591 pub max_turn_timings: TurnTimings,
595 pub timing_sample_count: u64,
597 pub egress_requests_total: u64,
599 pub egress_dropped_total: u64,
601 pub egress_blocked_total: u64,
603 pub context_max_tokens: u64,
609 pub compaction_last_before: u64,
611 pub compaction_last_after: u64,
613 pub compaction_last_at_ms: u64,
615 pub active_goal: Option<crate::goal::GoalSnapshot>,
617 pub cocoon_connected: Option<bool>,
620 pub cocoon_worker_count: u32,
622 pub cocoon_model_count: usize,
624 pub cocoon_ton_balance: Option<f64>,
626 pub providers: Arc<[ProviderSummary]>,
632 pub agent_definitions: Arc<[AgentDefSummary]>,
636}
637
638#[derive(Debug, Clone, Default, serde::Serialize)]
644pub struct ShellBackgroundRunRow {
645 pub run_id: String,
647 pub command: String,
649 pub elapsed_secs: u64,
651}
652
653#[derive(Debug, Default)]
671pub struct StaticMetricsInit {
672 pub stt_model: Option<String>,
674 pub compaction_model: Option<String>,
676 pub semantic_cache_enabled: bool,
681 pub embedding_model: String,
683 pub self_learning_enabled: bool,
685 pub active_channel: String,
687 pub token_budget: Option<u64>,
689 pub compaction_threshold: Option<u32>,
691 pub vault_backend: String,
693 pub autosave_enabled: bool,
695 pub model_name_override: Option<String>,
699}
700
701fn 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 if chars.peek() == Some(&'[') {
713 chars.next(); for inner in chars.by_ref() {
715 if ('\x40'..='\x7e').contains(&inner) {
716 break;
717 }
718 }
719 }
720 } else if c.is_control() && c != '\t' && c != '\n' && c != '\r' {
722 } else {
724 out.push(c);
725 }
726 }
727 out
728}
729
730fn 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
742impl 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 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 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 #[must_use]
847 pub fn sender(&self) -> watch::Sender<MetricsSnapshot> {
848 self.tx.clone()
849 }
850}
851
852pub trait HistogramRecorder: Send + Sync {
890 fn observe_llm_latency(&self, duration: std::time::Duration);
892
893 fn observe_turn_duration(&self, duration: std::time::Duration);
895
896 fn observe_tool_execution(&self, duration: std::time::Duration);
898
899 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 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 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 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 assert!(!snap.is_stale());
1183 snap.completed_at = Some(std::time::Instant::now());
1185 assert!(!snap.is_stale());
1186 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 #[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 #[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 #[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 #[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 #[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 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 #[test]
1359 fn provider_summary_never_carries_secret_fields() {
1360 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}