1use std::collections::VecDeque;
5
6use tokio::sync::watch;
7
8pub use zeph_llm::{ClassifierMetricsSnapshot, TaskMetricsSnapshot};
9pub use zeph_memory::{CategoryScore, ProbeCategory, ProbeVerdict};
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum SecurityEventCategory {
14 InjectionFlag,
15 InjectionBlocked,
17 ExfiltrationBlock,
18 Quarantine,
19 Truncation,
20 RateLimit,
21 MemoryValidation,
22 PreExecutionBlock,
23 PreExecutionWarn,
24 ResponseVerification,
25 CausalIpiFlag,
27 CrossBoundaryMcpToAcp,
29}
30
31impl SecurityEventCategory {
32 #[must_use]
33 pub fn as_str(self) -> &'static str {
34 match self {
35 Self::InjectionFlag => "injection",
36 Self::InjectionBlocked => "injection_blocked",
37 Self::ExfiltrationBlock => "exfil",
38 Self::Quarantine => "quarantine",
39 Self::Truncation => "truncation",
40 Self::RateLimit => "rate_limit",
41 Self::MemoryValidation => "memory_validation",
42 Self::PreExecutionBlock => "pre_exec_block",
43 Self::PreExecutionWarn => "pre_exec_warn",
44 Self::ResponseVerification => "response_verify",
45 Self::CausalIpiFlag => "causal_ipi",
46 Self::CrossBoundaryMcpToAcp => "cross_boundary_mcp_to_acp",
47 }
48 }
49}
50
51#[derive(Debug, Clone)]
53pub struct SecurityEvent {
54 pub timestamp: u64,
56 pub category: SecurityEventCategory,
57 pub source: String,
59 pub detail: String,
61}
62
63impl SecurityEvent {
64 #[must_use]
65 pub fn new(
66 category: SecurityEventCategory,
67 source: impl Into<String>,
68 detail: impl Into<String>,
69 ) -> Self {
70 let source: String = source
72 .into()
73 .chars()
74 .filter(|c| !c.is_ascii_control())
75 .take(64)
76 .collect();
77 let detail = detail.into();
79 let detail = if detail.len() > 128 {
80 let end = detail.floor_char_boundary(127);
81 format!("{}…", &detail[..end])
82 } else {
83 detail
84 };
85 Self {
86 timestamp: std::time::SystemTime::now()
87 .duration_since(std::time::UNIX_EPOCH)
88 .unwrap_or_default()
89 .as_secs(),
90 category,
91 source,
92 detail,
93 }
94 }
95}
96
97pub const SECURITY_EVENT_CAP: usize = 100;
99
100#[derive(Debug, Clone)]
104pub struct TaskSnapshotRow {
105 pub id: u32,
106 pub title: String,
107 pub status: String,
109 pub agent: Option<String>,
110 pub duration_ms: u64,
111 pub error: Option<String>,
113}
114
115#[derive(Debug, Clone, Default)]
117pub struct TaskGraphSnapshot {
118 pub graph_id: String,
119 pub goal: String,
120 pub status: String,
122 pub tasks: Vec<TaskSnapshotRow>,
123 pub completed_at: Option<std::time::Instant>,
124}
125
126impl TaskGraphSnapshot {
127 #[must_use]
130 pub fn is_stale(&self) -> bool {
131 self.completed_at
132 .is_some_and(|t| t.elapsed().as_secs() > 30)
133 }
134}
135
136#[derive(Debug, Clone, Default)]
140pub struct OrchestrationMetrics {
141 pub plans_total: u64,
142 pub tasks_total: u64,
143 pub tasks_completed: u64,
144 pub tasks_failed: u64,
145 pub tasks_skipped: u64,
146}
147
148#[derive(Debug, Clone, PartialEq, Eq)]
150pub enum McpServerConnectionStatus {
151 Connected,
152 Failed,
153}
154
155#[derive(Debug, Clone)]
157pub struct McpServerStatus {
158 pub id: String,
159 pub status: McpServerConnectionStatus,
160 pub tool_count: usize,
162 pub error: String,
164}
165
166#[derive(Debug, Clone, Default)]
168pub struct SkillConfidence {
169 pub name: String,
170 pub posterior: f64,
171 pub total_uses: u32,
172}
173
174#[derive(Debug, Clone, Default)]
176pub struct SubAgentMetrics {
177 pub id: String,
178 pub name: String,
179 pub state: String,
181 pub turns_used: u32,
182 pub max_turns: u32,
183 pub background: bool,
184 pub elapsed_secs: u64,
185 pub permission_mode: String,
188 pub transcript_dir: Option<String>,
191}
192
193#[derive(Debug, Clone, Default)]
198pub struct TurnTimings {
199 pub prepare_context_ms: u64,
200 pub llm_chat_ms: u64,
201 pub tool_exec_ms: u64,
202 pub persist_message_ms: u64,
203}
204
205#[derive(Debug, Clone, Default)]
206#[allow(clippy::struct_excessive_bools)]
207pub struct MetricsSnapshot {
208 pub prompt_tokens: u64,
209 pub completion_tokens: u64,
210 pub total_tokens: u64,
211 pub context_tokens: u64,
212 pub api_calls: u64,
213 pub active_skills: Vec<String>,
214 pub total_skills: usize,
215 pub mcp_server_count: usize,
217 pub mcp_tool_count: usize,
218 pub mcp_connected_count: usize,
220 pub mcp_servers: Vec<McpServerStatus>,
222 pub active_mcp_tools: Vec<String>,
223 pub sqlite_message_count: u64,
224 pub sqlite_conversation_id: Option<zeph_memory::ConversationId>,
225 pub qdrant_available: bool,
226 pub vector_backend: String,
227 pub embeddings_generated: u64,
228 pub last_llm_latency_ms: u64,
229 pub uptime_seconds: u64,
230 pub provider_name: String,
231 pub model_name: String,
232 pub summaries_count: u64,
233 pub context_compactions: u64,
234 pub compaction_hard_count: u64,
237 pub compaction_turns_after_hard: Vec<u64>,
241 pub compression_events: u64,
242 pub compression_tokens_saved: u64,
243 pub tool_output_prunes: u64,
244 pub compaction_probe_passes: u64,
246 pub compaction_probe_soft_failures: u64,
248 pub compaction_probe_failures: u64,
250 pub compaction_probe_errors: u64,
252 pub last_probe_verdict: Option<zeph_memory::ProbeVerdict>,
254 pub last_probe_score: Option<f32>,
257 pub last_probe_category_scores: Option<Vec<zeph_memory::CategoryScore>>,
259 pub compaction_probe_threshold: f32,
261 pub compaction_probe_hard_fail_threshold: f32,
263 pub cache_read_tokens: u64,
264 pub cache_creation_tokens: u64,
265 pub cost_spent_cents: f64,
266 pub provider_cost_breakdown: Vec<(String, crate::cost::ProviderUsage)>,
268 pub filter_raw_tokens: u64,
269 pub filter_saved_tokens: u64,
270 pub filter_applications: u64,
271 pub filter_total_commands: u64,
272 pub filter_filtered_commands: u64,
273 pub filter_confidence_full: u64,
274 pub filter_confidence_partial: u64,
275 pub filter_confidence_fallback: u64,
276 pub cancellations: u64,
277 pub server_compaction_events: u64,
278 pub sanitizer_runs: u64,
279 pub sanitizer_injection_flags: u64,
280 pub sanitizer_injection_fp_local: u64,
286 pub sanitizer_truncations: u64,
287 pub quarantine_invocations: u64,
288 pub quarantine_failures: u64,
289 pub classifier_tool_blocks: u64,
291 pub classifier_tool_suspicious: u64,
293 pub causal_ipi_flags: u64,
295 pub exfiltration_images_blocked: u64,
296 pub exfiltration_tool_urls_flagged: u64,
297 pub exfiltration_memory_guards: u64,
298 pub pii_scrub_count: u64,
299 pub pii_ner_timeouts: u64,
301 pub pii_ner_circuit_breaker_trips: u64,
303 pub memory_validation_failures: u64,
304 pub rate_limit_trips: u64,
305 pub pre_execution_blocks: u64,
306 pub pre_execution_warnings: u64,
307 pub guardrail_enabled: bool,
309 pub guardrail_warn_mode: bool,
311 pub sub_agents: Vec<SubAgentMetrics>,
312 pub skill_confidence: Vec<SkillConfidence>,
313 pub scheduled_tasks: Vec<[String; 4]>,
315 pub router_thompson_stats: Vec<(String, f64, f64)>,
317 pub security_events: VecDeque<SecurityEvent>,
319 pub orchestration: OrchestrationMetrics,
320 pub orchestration_graph: Option<TaskGraphSnapshot>,
322 pub graph_community_detection_failures: u64,
323 pub graph_entities_total: u64,
324 pub graph_edges_total: u64,
325 pub graph_communities_total: u64,
326 pub graph_extraction_count: u64,
327 pub graph_extraction_failures: u64,
328 pub extended_context: bool,
331 pub guidelines_version: u32,
333 pub guidelines_updated_at: String,
335 pub tool_cache_hits: u64,
336 pub tool_cache_misses: u64,
337 pub tool_cache_entries: usize,
338 pub semantic_fact_count: u64,
340 pub stt_model: Option<String>,
342 pub compaction_model: Option<String>,
344 pub provider_temperature: Option<f32>,
346 pub provider_top_p: Option<f32>,
348 pub embedding_model: String,
350 pub token_budget: Option<u64>,
352 pub compaction_threshold: Option<u32>,
354 pub vault_backend: String,
356 pub active_channel: String,
358 pub bg_inflight: u64,
360 pub bg_dropped: u64,
362 pub bg_completed: u64,
364 pub bg_enrichment_inflight: u64,
366 pub bg_telemetry_inflight: u64,
368 pub self_learning_enabled: bool,
370 pub semantic_cache_enabled: bool,
372 pub cache_enabled: bool,
374 pub autosave_enabled: bool,
376 pub classifier: ClassifierMetricsSnapshot,
378 pub last_turn_timings: TurnTimings,
380 pub avg_turn_timings: TurnTimings,
382 pub max_turn_timings: TurnTimings,
386 pub timing_sample_count: u64,
388}
389
390fn strip_ctrl(s: &str) -> String {
396 let mut out = String::with_capacity(s.len());
397 let mut chars = s.chars().peekable();
398 while let Some(c) = chars.next() {
399 if c == '\x1b' {
400 if chars.peek() == Some(&'[') {
402 chars.next(); for inner in chars.by_ref() {
404 if ('\x40'..='\x7e').contains(&inner) {
405 break;
406 }
407 }
408 }
409 } else if c.is_control() && c != '\t' && c != '\n' && c != '\r' {
411 } else {
413 out.push(c);
414 }
415 }
416 out
417}
418
419impl From<&zeph_orchestration::TaskGraph> for TaskGraphSnapshot {
421 fn from(graph: &zeph_orchestration::TaskGraph) -> Self {
422 let tasks = graph
423 .tasks
424 .iter()
425 .map(|t| {
426 let error = t
427 .result
428 .as_ref()
429 .filter(|_| t.status == zeph_orchestration::TaskStatus::Failed)
430 .and_then(|r| {
431 if r.output.is_empty() {
432 None
433 } else {
434 let s = strip_ctrl(&r.output);
436 if s.len() > 80 {
437 let end = s.floor_char_boundary(79);
438 Some(format!("{}…", &s[..end]))
439 } else {
440 Some(s)
441 }
442 }
443 });
444 let duration_ms = t.result.as_ref().map_or(0, |r| r.duration_ms);
445 TaskSnapshotRow {
446 id: t.id.as_u32(),
447 title: strip_ctrl(&t.title),
448 status: t.status.to_string(),
449 agent: t.assigned_agent.as_deref().map(strip_ctrl),
450 duration_ms,
451 error,
452 }
453 })
454 .collect();
455 Self {
456 graph_id: graph.id.to_string(),
457 goal: strip_ctrl(&graph.goal),
458 status: graph.status.to_string(),
459 tasks,
460 completed_at: None,
461 }
462 }
463}
464
465pub struct MetricsCollector {
466 tx: watch::Sender<MetricsSnapshot>,
467}
468
469impl MetricsCollector {
470 #[must_use]
471 pub fn new() -> (Self, watch::Receiver<MetricsSnapshot>) {
472 let (tx, rx) = watch::channel(MetricsSnapshot::default());
473 (Self { tx }, rx)
474 }
475
476 pub fn update(&self, f: impl FnOnce(&mut MetricsSnapshot)) {
477 self.tx.send_modify(f);
478 }
479
480 #[must_use]
486 pub fn sender(&self) -> watch::Sender<MetricsSnapshot> {
487 self.tx.clone()
488 }
489}
490
491pub trait HistogramRecorder: Send + Sync {
529 fn observe_llm_latency(&self, duration: std::time::Duration);
531
532 fn observe_turn_duration(&self, duration: std::time::Duration);
534
535 fn observe_tool_execution(&self, duration: std::time::Duration);
537
538 fn observe_bg_task(&self, class_label: &str, duration: std::time::Duration);
542}
543
544#[cfg(test)]
545mod tests {
546 #![allow(clippy::field_reassign_with_default)]
547
548 use super::*;
549
550 #[test]
551 fn default_metrics_snapshot() {
552 let m = MetricsSnapshot::default();
553 assert_eq!(m.total_tokens, 0);
554 assert_eq!(m.api_calls, 0);
555 assert!(m.active_skills.is_empty());
556 assert!(m.active_mcp_tools.is_empty());
557 assert_eq!(m.mcp_tool_count, 0);
558 assert_eq!(m.mcp_server_count, 0);
559 assert!(m.provider_name.is_empty());
560 assert_eq!(m.summaries_count, 0);
561 assert!(m.stt_model.is_none());
563 assert!(m.compaction_model.is_none());
564 assert!(m.provider_temperature.is_none());
565 assert!(m.provider_top_p.is_none());
566 assert!(m.active_channel.is_empty());
567 assert!(m.embedding_model.is_empty());
568 assert!(m.token_budget.is_none());
569 assert!(!m.self_learning_enabled);
570 assert!(!m.semantic_cache_enabled);
571 }
572
573 #[test]
574 fn metrics_collector_update_phase2_fields() {
575 let (collector, rx) = MetricsCollector::new();
576 collector.update(|m| {
577 m.stt_model = Some("whisper-1".into());
578 m.compaction_model = Some("haiku".into());
579 m.provider_temperature = Some(0.7);
580 m.provider_top_p = Some(0.95);
581 m.active_channel = "tui".into();
582 m.embedding_model = "nomic-embed-text".into();
583 m.token_budget = Some(200_000);
584 m.self_learning_enabled = true;
585 m.semantic_cache_enabled = true;
586 });
587 let s = rx.borrow();
588 assert_eq!(s.stt_model.as_deref(), Some("whisper-1"));
589 assert_eq!(s.compaction_model.as_deref(), Some("haiku"));
590 assert_eq!(s.provider_temperature, Some(0.7));
591 assert_eq!(s.provider_top_p, Some(0.95));
592 assert_eq!(s.active_channel, "tui");
593 assert_eq!(s.embedding_model, "nomic-embed-text");
594 assert_eq!(s.token_budget, Some(200_000));
595 assert!(s.self_learning_enabled);
596 assert!(s.semantic_cache_enabled);
597 }
598
599 #[test]
600 fn metrics_collector_update() {
601 let (collector, rx) = MetricsCollector::new();
602 collector.update(|m| {
603 m.api_calls = 5;
604 m.total_tokens = 1000;
605 });
606 let snapshot = rx.borrow().clone();
607 assert_eq!(snapshot.api_calls, 5);
608 assert_eq!(snapshot.total_tokens, 1000);
609 }
610
611 #[test]
612 fn metrics_collector_multiple_updates() {
613 let (collector, rx) = MetricsCollector::new();
614 collector.update(|m| m.api_calls = 1);
615 collector.update(|m| m.api_calls += 1);
616 assert_eq!(rx.borrow().api_calls, 2);
617 }
618
619 #[test]
620 fn metrics_snapshot_clone() {
621 let mut m = MetricsSnapshot::default();
622 m.provider_name = "ollama".into();
623 let cloned = m.clone();
624 assert_eq!(cloned.provider_name, "ollama");
625 }
626
627 #[test]
628 fn filter_metrics_tracking() {
629 let (collector, rx) = MetricsCollector::new();
630 collector.update(|m| {
631 m.filter_raw_tokens += 250;
632 m.filter_saved_tokens += 200;
633 m.filter_applications += 1;
634 });
635 collector.update(|m| {
636 m.filter_raw_tokens += 100;
637 m.filter_saved_tokens += 80;
638 m.filter_applications += 1;
639 });
640 let s = rx.borrow();
641 assert_eq!(s.filter_raw_tokens, 350);
642 assert_eq!(s.filter_saved_tokens, 280);
643 assert_eq!(s.filter_applications, 2);
644 }
645
646 #[test]
647 fn filter_confidence_and_command_metrics() {
648 let (collector, rx) = MetricsCollector::new();
649 collector.update(|m| {
650 m.filter_total_commands += 1;
651 m.filter_filtered_commands += 1;
652 m.filter_confidence_full += 1;
653 });
654 collector.update(|m| {
655 m.filter_total_commands += 1;
656 m.filter_confidence_partial += 1;
657 });
658 let s = rx.borrow();
659 assert_eq!(s.filter_total_commands, 2);
660 assert_eq!(s.filter_filtered_commands, 1);
661 assert_eq!(s.filter_confidence_full, 1);
662 assert_eq!(s.filter_confidence_partial, 1);
663 assert_eq!(s.filter_confidence_fallback, 0);
664 }
665
666 #[test]
667 fn summaries_count_tracks_summarizations() {
668 let (collector, rx) = MetricsCollector::new();
669 collector.update(|m| m.summaries_count += 1);
670 collector.update(|m| m.summaries_count += 1);
671 assert_eq!(rx.borrow().summaries_count, 2);
672 }
673
674 #[test]
675 fn cancellations_counter_increments() {
676 let (collector, rx) = MetricsCollector::new();
677 assert_eq!(rx.borrow().cancellations, 0);
678 collector.update(|m| m.cancellations += 1);
679 collector.update(|m| m.cancellations += 1);
680 assert_eq!(rx.borrow().cancellations, 2);
681 }
682
683 #[test]
684 fn security_event_detail_exact_128_not_truncated() {
685 let s = "a".repeat(128);
686 let ev = SecurityEvent::new(SecurityEventCategory::InjectionFlag, "src", s.clone());
687 assert_eq!(ev.detail, s, "128-char string must not be truncated");
688 }
689
690 #[test]
691 fn security_event_detail_129_is_truncated() {
692 let s = "a".repeat(129);
693 let ev = SecurityEvent::new(SecurityEventCategory::InjectionFlag, "src", s);
694 assert!(
695 ev.detail.ends_with('…'),
696 "129-char string must end with ellipsis"
697 );
698 assert!(
699 ev.detail.len() <= 130,
700 "truncated detail must be at most 130 bytes"
701 );
702 }
703
704 #[test]
705 fn security_event_detail_multibyte_utf8_no_panic() {
706 let s = "中".repeat(43);
708 let ev = SecurityEvent::new(SecurityEventCategory::InjectionFlag, "src", s);
709 assert!(ev.detail.ends_with('…'));
710 }
711
712 #[test]
713 fn security_event_source_capped_at_64_chars() {
714 let long_source = "x".repeat(200);
715 let ev = SecurityEvent::new(SecurityEventCategory::InjectionFlag, long_source, "detail");
716 assert_eq!(ev.source.len(), 64);
717 }
718
719 #[test]
720 fn security_event_source_strips_control_chars() {
721 let source = "tool\x00name\x1b[31m";
722 let ev = SecurityEvent::new(SecurityEventCategory::InjectionFlag, source, "detail");
723 assert!(!ev.source.contains('\x00'));
724 assert!(!ev.source.contains('\x1b'));
725 }
726
727 #[test]
728 fn security_event_category_as_str() {
729 assert_eq!(SecurityEventCategory::InjectionFlag.as_str(), "injection");
730 assert_eq!(SecurityEventCategory::ExfiltrationBlock.as_str(), "exfil");
731 assert_eq!(SecurityEventCategory::Quarantine.as_str(), "quarantine");
732 assert_eq!(SecurityEventCategory::Truncation.as_str(), "truncation");
733 assert_eq!(
734 SecurityEventCategory::CrossBoundaryMcpToAcp.as_str(),
735 "cross_boundary_mcp_to_acp"
736 );
737 }
738
739 #[test]
740 fn ring_buffer_respects_cap_via_update() {
741 let (collector, rx) = MetricsCollector::new();
742 for i in 0..110u64 {
743 let event = SecurityEvent::new(
744 SecurityEventCategory::InjectionFlag,
745 "src",
746 format!("event {i}"),
747 );
748 collector.update(|m| {
749 if m.security_events.len() >= SECURITY_EVENT_CAP {
750 m.security_events.pop_front();
751 }
752 m.security_events.push_back(event);
753 });
754 }
755 let snap = rx.borrow();
756 assert_eq!(snap.security_events.len(), SECURITY_EVENT_CAP);
757 assert!(snap.security_events.back().unwrap().detail.contains("109"));
759 }
760
761 #[test]
762 fn security_events_empty_by_default() {
763 let m = MetricsSnapshot::default();
764 assert!(m.security_events.is_empty());
765 }
766
767 #[test]
768 fn orchestration_metrics_default_zero() {
769 let m = OrchestrationMetrics::default();
770 assert_eq!(m.plans_total, 0);
771 assert_eq!(m.tasks_total, 0);
772 assert_eq!(m.tasks_completed, 0);
773 assert_eq!(m.tasks_failed, 0);
774 assert_eq!(m.tasks_skipped, 0);
775 }
776
777 #[test]
778 fn metrics_snapshot_includes_orchestration_default_zero() {
779 let m = MetricsSnapshot::default();
780 assert_eq!(m.orchestration.plans_total, 0);
781 assert_eq!(m.orchestration.tasks_total, 0);
782 assert_eq!(m.orchestration.tasks_completed, 0);
783 }
784
785 #[test]
786 fn orchestration_metrics_update_via_collector() {
787 let (collector, rx) = MetricsCollector::new();
788 collector.update(|m| {
789 m.orchestration.plans_total += 1;
790 m.orchestration.tasks_total += 5;
791 m.orchestration.tasks_completed += 3;
792 m.orchestration.tasks_failed += 1;
793 m.orchestration.tasks_skipped += 1;
794 });
795 let s = rx.borrow();
796 assert_eq!(s.orchestration.plans_total, 1);
797 assert_eq!(s.orchestration.tasks_total, 5);
798 assert_eq!(s.orchestration.tasks_completed, 3);
799 assert_eq!(s.orchestration.tasks_failed, 1);
800 assert_eq!(s.orchestration.tasks_skipped, 1);
801 }
802
803 #[test]
804 fn strip_ctrl_removes_escape_sequences() {
805 let input = "hello\x1b[31mworld\x00end";
806 let result = strip_ctrl(input);
807 assert_eq!(result, "helloworldend");
808 }
809
810 #[test]
811 fn strip_ctrl_allows_tab_lf_cr() {
812 let input = "a\tb\nc\rd";
813 let result = strip_ctrl(input);
814 assert_eq!(result, "a\tb\nc\rd");
815 }
816
817 #[test]
818 fn task_graph_snapshot_is_stale_after_30s() {
819 let mut snap = TaskGraphSnapshot::default();
820 assert!(!snap.is_stale());
822 snap.completed_at = Some(std::time::Instant::now());
824 assert!(!snap.is_stale());
825 snap.completed_at = Some(
827 std::time::Instant::now()
828 .checked_sub(std::time::Duration::from_secs(31))
829 .unwrap(),
830 );
831 assert!(snap.is_stale());
832 }
833
834 #[test]
836 fn task_graph_snapshot_from_task_graph_maps_fields() {
837 use zeph_orchestration::{GraphStatus, TaskGraph, TaskNode, TaskResult, TaskStatus};
838
839 let mut graph = TaskGraph::new("My goal");
840 let mut task = TaskNode::new(0, "Do work", "description");
841 task.status = TaskStatus::Failed;
842 task.assigned_agent = Some("agent-1".into());
843 task.result = Some(TaskResult {
844 output: "error occurred here".into(),
845 artifacts: vec![],
846 duration_ms: 1234,
847 agent_id: None,
848 agent_def: None,
849 });
850 graph.tasks.push(task);
851 graph.status = GraphStatus::Failed;
852
853 let snap = TaskGraphSnapshot::from(&graph);
854 assert_eq!(snap.goal, "My goal");
855 assert_eq!(snap.status, "failed");
856 assert_eq!(snap.tasks.len(), 1);
857 let row = &snap.tasks[0];
858 assert_eq!(row.title, "Do work");
859 assert_eq!(row.status, "failed");
860 assert_eq!(row.agent.as_deref(), Some("agent-1"));
861 assert_eq!(row.duration_ms, 1234);
862 assert!(row.error.as_deref().unwrap().contains("error occurred"));
863 }
864
865 #[test]
867 fn task_graph_snapshot_from_compiles_with_feature() {
868 use zeph_orchestration::TaskGraph;
869 let graph = TaskGraph::new("feature flag test");
870 let snap = TaskGraphSnapshot::from(&graph);
871 assert_eq!(snap.goal, "feature flag test");
872 assert!(snap.tasks.is_empty());
873 assert!(!snap.is_stale());
874 }
875
876 #[test]
878 fn task_graph_snapshot_error_truncated_at_80_chars() {
879 use zeph_orchestration::{TaskGraph, TaskNode, TaskResult, TaskStatus};
880
881 let mut graph = TaskGraph::new("goal");
882 let mut task = TaskNode::new(0, "t", "d");
883 task.status = TaskStatus::Failed;
884 task.result = Some(TaskResult {
885 output: "e".repeat(100),
886 artifacts: vec![],
887 duration_ms: 0,
888 agent_id: None,
889 agent_def: None,
890 });
891 graph.tasks.push(task);
892
893 let snap = TaskGraphSnapshot::from(&graph);
894 let err = snap.tasks[0].error.as_ref().unwrap();
895 assert!(err.ends_with('…'), "truncated error must end with ellipsis");
896 assert!(
897 err.len() <= 83,
898 "truncated error must not exceed 80 chars + ellipsis"
899 );
900 }
901
902 #[test]
904 fn task_graph_snapshot_strips_control_chars_from_title() {
905 use zeph_orchestration::{TaskGraph, TaskNode};
906
907 let mut graph = TaskGraph::new("goal\x1b[31m");
908 let task = TaskNode::new(0, "title\x00injected", "d");
909 graph.tasks.push(task);
910
911 let snap = TaskGraphSnapshot::from(&graph);
912 assert!(!snap.goal.contains('\x1b'), "goal must not contain escape");
913 assert!(
914 !snap.tasks[0].title.contains('\x00'),
915 "title must not contain null byte"
916 );
917 }
918
919 #[test]
920 fn graph_metrics_default_zero() {
921 let m = MetricsSnapshot::default();
922 assert_eq!(m.graph_entities_total, 0);
923 assert_eq!(m.graph_edges_total, 0);
924 assert_eq!(m.graph_communities_total, 0);
925 assert_eq!(m.graph_extraction_count, 0);
926 assert_eq!(m.graph_extraction_failures, 0);
927 }
928
929 #[test]
930 fn graph_metrics_update_via_collector() {
931 let (collector, rx) = MetricsCollector::new();
932 collector.update(|m| {
933 m.graph_entities_total = 5;
934 m.graph_edges_total = 10;
935 m.graph_communities_total = 2;
936 m.graph_extraction_count = 7;
937 m.graph_extraction_failures = 1;
938 });
939 let snapshot = rx.borrow().clone();
940 assert_eq!(snapshot.graph_entities_total, 5);
941 assert_eq!(snapshot.graph_edges_total, 10);
942 assert_eq!(snapshot.graph_communities_total, 2);
943 assert_eq!(snapshot.graph_extraction_count, 7);
944 assert_eq!(snapshot.graph_extraction_failures, 1);
945 }
946
947 #[test]
948 fn histogram_recorder_trait_is_object_safe() {
949 use std::sync::Arc;
950 use std::time::Duration;
951
952 struct NoOpRecorder;
953 impl HistogramRecorder for NoOpRecorder {
954 fn observe_llm_latency(&self, _: Duration) {}
955 fn observe_turn_duration(&self, _: Duration) {}
956 fn observe_tool_execution(&self, _: Duration) {}
957 fn observe_bg_task(&self, _: &str, _: Duration) {}
958 }
959
960 let recorder: Arc<dyn HistogramRecorder> = Arc::new(NoOpRecorder);
962 recorder.observe_llm_latency(Duration::from_millis(500));
963 recorder.observe_turn_duration(Duration::from_millis(3000));
964 recorder.observe_tool_execution(Duration::from_millis(100));
965 }
966}