1use std::sync::{Arc, Mutex};
2use std::time::{Instant, SystemTime, UNIX_EPOCH};
3
4use crate::budget::Budget;
5use crate::observer::ExecutionObserver;
6use crate::progress::ProgressInfo;
7use crate::recent_log::{LogEntry, LogSink};
8use crate::tokens::{estimate_tokens, TokenCount, TokenSource};
9use crate::{BudgetHandle, CustomMetrics, CustomMetricsHandle, LlmQuery, ProgressHandle, QueryId};
10
11struct TranscriptEntry {
20 query_id: String,
21 prompt: String,
22 system: Option<String>,
23 response: Option<String>,
24 prompt_tokens: u64,
26 prompt_source: TokenSource,
27 response_tokens: u64,
30 response_source: TokenSource,
31 started_at_ms: i64,
33 completed_at_ms: Option<i64>,
36}
37
38impl TranscriptEntry {
39 fn to_json(&self) -> serde_json::Value {
40 serde_json::json!({
41 "query_id": self.query_id,
42 "prompt": self.prompt,
43 "system": self.system,
44 "response": self.response,
45 })
46 }
47
48 fn to_history_json(&self) -> serde_json::Value {
57 serde_json::json!({
58 "query_id": self.query_id,
59 "prompt": self.prompt,
60 "response": self.response,
61 "prompt_tokens": self.prompt_tokens,
62 "response_tokens": self.response_tokens,
63 "started_at": self.started_at_ms,
64 "completed_at": self.completed_at_ms,
65 })
66 }
67}
68
69pub(crate) struct SessionStatus {
123 started_at: Instant,
124 ended_at: Option<Instant>,
125 pub(crate) llm_calls: u64,
126 pauses: u64,
127 rounds: u64,
128 total_prompt_chars: u64,
129 total_response_chars: u64,
130 transcript: Vec<TranscriptEntry>,
131 pub(crate) budget: Option<Budget>,
132 pub(crate) progress: Option<ProgressInfo>,
133}
134
135impl SessionStatus {
136 fn new() -> Self {
137 Self {
138 started_at: Instant::now(),
139 ended_at: None,
140 llm_calls: 0,
141 pauses: 0,
142 rounds: 0,
143 total_prompt_chars: 0,
144 total_response_chars: 0,
145 transcript: Vec::new(),
146 budget: None,
147 progress: None,
148 }
149 }
150
151 fn prompt_token_count(&self) -> TokenCount {
153 let mut tc = TokenCount::new(TokenSource::Definite);
154 for e in &self.transcript {
155 tc.accumulate(e.prompt_tokens, e.prompt_source);
156 }
157 tc
158 }
159
160 fn response_token_count(&self) -> TokenCount {
162 let mut tc = TokenCount::new(TokenSource::Definite);
163 for e in &self.transcript {
164 tc.accumulate(e.response_tokens, e.response_source);
165 }
166 tc
167 }
168
169 fn total_tokens(&self) -> u64 {
171 self.transcript
172 .iter()
173 .map(|e| e.prompt_tokens + e.response_tokens)
174 .sum()
175 }
176
177 fn elapsed_ms(&self) -> u64 {
179 self.ended_at
180 .map(|end| end.duration_since(self.started_at).as_millis() as u64)
181 .unwrap_or_else(|| self.started_at.elapsed().as_millis() as u64)
182 }
183
184 fn to_json(&self) -> serde_json::Value {
185 let prompt_tc = self.prompt_token_count();
186 let response_tc = self.response_token_count();
187 let total_tc = TokenCount {
188 tokens: prompt_tc.tokens + response_tc.tokens,
189 source: prompt_tc.source.weaker(response_tc.source),
190 };
191 let mut json = serde_json::json!({
192 "elapsed_ms": self.elapsed_ms(),
193 "llm_calls": self.llm_calls,
194 "pauses": self.pauses,
195 "rounds": self.rounds,
196 "total_prompt_chars": self.total_prompt_chars,
197 "total_response_chars": self.total_response_chars,
198 "prompt_tokens": prompt_tc.to_json(),
199 "response_tokens": response_tc.to_json(),
200 "total_tokens": total_tc.to_json(),
201 });
202 if let Some(ref b) = self.budget {
203 json["budget"] = b.to_json();
204 }
205 json
206 }
207
208 pub(crate) fn check_budget(&self) -> Result<(), String> {
209 match self.budget {
210 Some(ref b) => b.check(self.llm_calls, self.elapsed_ms(), self.total_tokens()),
211 None => Ok(()),
212 }
213 }
214
215 fn snapshot(&self, include_history: bool, log_sink: &LogSink) -> serde_json::Value {
235 let prompt_tc = self.prompt_token_count();
237 let response_tc = self.response_token_count();
238 let total_tokens = prompt_tc.tokens + response_tc.tokens;
239
240 let current_query = self.transcript.last().and_then(|e| {
243 if e.response.is_none() {
244 Some(serde_json::json!({
245 "query_id": e.query_id,
246 "prompt_tokens": e.prompt_tokens,
247 "started_waiting_at": e.started_at_ms,
248 }))
249 } else {
250 None
251 }
252 });
253
254 let mut json = serde_json::json!({
255 "elapsed_ms": self.elapsed_ms(),
256 "llm_calls": self.llm_calls,
257 "rounds": self.rounds,
258 "tokens": {
259 "prompt_total": prompt_tc.tokens,
260 "response_total": response_tc.tokens,
261 "total": total_tokens,
262 "current_query": current_query,
263 },
264 "recent_logs": log_sink.to_json(),
265 });
266
267 if let Some(ref p) = self.progress {
268 json["progress"] = serde_json::json!({
269 "step": p.step,
270 "total": p.total,
271 "message": p.message,
272 });
273 }
274
275 if let Some(ref b) = self.budget {
276 json["budget_remaining"] =
277 b.remaining_json(self.llm_calls, self.elapsed_ms(), self.total_tokens());
278 }
279
280 if include_history {
281 let start = self.transcript.len().saturating_sub(10);
283 let history: Vec<serde_json::Value> = self.transcript[start..]
284 .iter()
285 .map(|e| e.to_history_json())
286 .collect();
287 json["conversation_history"] = serde_json::Value::Array(history);
288 }
289
290 json
291 }
292
293 pub(crate) fn budget_remaining(&self) -> serde_json::Value {
294 match self.budget {
295 None => serde_json::Value::Null,
296 Some(ref b) => b.remaining_json(self.llm_calls, self.elapsed_ms(), self.total_tokens()),
297 }
298 }
299}
300
301pub struct ExecutionMetrics {
314 auto: Arc<Mutex<SessionStatus>>,
315 custom: Arc<Mutex<CustomMetrics>>,
316 log_sink: LogSink,
317}
318
319impl ExecutionMetrics {
320 pub fn new() -> Self {
321 Self {
322 auto: Arc::new(Mutex::new(SessionStatus::new())),
323 custom: Arc::new(Mutex::new(CustomMetrics::new())),
324 log_sink: LogSink::new(),
325 }
326 }
327
328 pub fn to_json(&self) -> serde_json::Value {
330 let auto_json = self
331 .auto
332 .lock()
333 .map(|m| m.to_json())
334 .unwrap_or(serde_json::Value::Null);
335
336 let custom_json = self
337 .custom
338 .lock()
339 .map(|m| m.to_json())
340 .unwrap_or(serde_json::Value::Null);
341
342 serde_json::json!({
343 "auto": auto_json,
344 "custom": custom_json,
345 })
346 }
347
348 pub fn transcript_to_json(&self) -> Vec<serde_json::Value> {
350 self.auto
351 .lock()
352 .map(|m| m.transcript.iter().map(|e| e.to_json()).collect())
353 .unwrap_or_default()
354 }
355
356 pub fn custom_metrics_handle(&self) -> CustomMetricsHandle {
358 CustomMetricsHandle::new(Arc::clone(&self.custom))
359 }
360
361 pub fn set_budget(&self, budget: Budget) {
363 if let Ok(mut m) = self.auto.lock() {
364 m.budget = Some(budget);
365 }
366 }
367
368 pub fn budget_handle(&self) -> BudgetHandle {
370 BudgetHandle::new(Arc::clone(&self.auto))
371 }
372
373 pub fn progress_handle(&self) -> ProgressHandle {
375 ProgressHandle::new(Arc::clone(&self.auto))
376 }
377
378 pub fn snapshot(&self, include_history: bool) -> serde_json::Value {
393 self.auto
394 .lock()
395 .map(|m| m.snapshot(include_history, &self.log_sink))
396 .unwrap_or(serde_json::Value::Null)
397 }
398
399 pub fn create_observer(&self) -> MetricsObserver {
400 MetricsObserver::new(Arc::clone(&self.auto), self.log_sink.clone())
401 }
402
403 pub fn log_sink_handle(&self) -> LogSink {
413 self.log_sink.clone()
414 }
415
416 pub fn stats_handle(&self) -> StatsHandle {
419 StatsHandle::new(Arc::clone(&self.auto))
420 }
421
422 pub fn usage_aggregate(&self) -> Option<crate::TokenUsage> {
432 let m = self.auto.lock().ok()?;
433 if m.llm_calls == 0 {
434 return None;
435 }
436 Some(crate::TokenUsage {
437 prompt_tokens: Some(m.prompt_token_count().tokens),
438 completion_tokens: Some(m.response_token_count().tokens),
439 })
440 }
441}
442
443#[derive(Clone)]
456pub struct StatsHandle {
457 auto: Arc<Mutex<SessionStatus>>,
458}
459
460impl StatsHandle {
461 pub(crate) fn new(auto: Arc<Mutex<SessionStatus>>) -> Self {
462 Self { auto }
463 }
464
465 pub fn llm_calls(&self) -> u64 {
473 self.auto.lock().map(|m| m.llm_calls).unwrap_or(0)
474 }
475}
476
477impl Default for ExecutionMetrics {
478 fn default() -> Self {
479 Self::new()
480 }
481}
482
483impl serde::Serialize for ExecutionMetrics {
484 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
485 self.to_json().serialize(serializer)
486 }
487}
488
489pub struct MetricsObserver {
491 auto: Arc<Mutex<SessionStatus>>,
492 log_sink: LogSink,
493}
494
495impl MetricsObserver {
496 pub(crate) fn new(auto: Arc<Mutex<SessionStatus>>, log_sink: LogSink) -> Self {
497 Self { auto, log_sink }
498 }
499}
500
501impl ExecutionObserver for MetricsObserver {
502 fn on_paused(&self, queries: &[LlmQuery]) {
503 let now_ms = SystemTime::now()
506 .duration_since(UNIX_EPOCH)
507 .unwrap_or_default()
508 .as_millis() as i64;
509 if let Ok(mut m) = self.auto.lock() {
510 m.pauses += 1;
511 m.llm_calls += queries.len() as u64;
512 for q in queries {
513 m.total_prompt_chars += q.prompt.len() as u64;
514 let mut est = estimate_tokens(&q.prompt);
515 if let Some(ref sys) = q.system {
516 m.total_prompt_chars += sys.len() as u64;
517 est += estimate_tokens(sys);
518 }
519 m.transcript.push(TranscriptEntry {
520 query_id: q.id.as_str().to_string(),
521 prompt: q.prompt.clone(),
522 system: q.system.clone(),
523 response: None,
524 prompt_tokens: est,
525 prompt_source: TokenSource::Estimated,
526 response_tokens: 0,
527 response_source: TokenSource::Estimated,
528 started_at_ms: now_ms,
529 completed_at_ms: None,
530 });
531 }
532 }
533 }
534
535 fn on_response_fed(
536 &self,
537 query_id: &QueryId,
538 response: &str,
539 usage: Option<&crate::TokenUsage>,
540 ) {
541 let now_ms = SystemTime::now()
544 .duration_since(UNIX_EPOCH)
545 .unwrap_or_default()
546 .as_millis() as i64;
547 if let Ok(mut m) = self.auto.lock() {
548 m.total_response_chars += response.len() as u64;
549
550 if let Some(entry) = m
551 .transcript
552 .iter_mut()
553 .rev()
554 .find(|e| e.query_id == query_id.as_str())
555 {
556 entry.response = Some(response.to_string());
557 entry.completed_at_ms = Some(now_ms);
558
559 if let Some(pt) = usage.and_then(|u| u.prompt_tokens) {
561 entry.prompt_tokens = pt;
562 entry.prompt_source = TokenSource::Provided;
563 }
564
565 match usage.and_then(|u| u.completion_tokens) {
567 Some(ct) => {
568 entry.response_tokens = ct;
569 entry.response_source = TokenSource::Provided;
570 }
571 None => {
572 entry.response_tokens = estimate_tokens(response);
573 entry.response_source = TokenSource::Estimated;
574 }
575 }
576 }
577 }
578 }
579
580 fn on_log(&self, entry: &LogEntry) {
581 self.log_sink.push(entry.clone());
582 }
583
584 fn on_resumed(&self) {
585 if let Ok(mut m) = self.auto.lock() {
586 m.rounds += 1;
587 }
588 }
589
590 fn on_completed(&self, _result: &serde_json::Value) {
591 if let Ok(mut m) = self.auto.lock() {
592 m.ended_at = Some(Instant::now());
593 }
594 }
595
596 fn on_failed(&self, _error: &str) {
597 if let Ok(mut m) = self.auto.lock() {
598 m.ended_at = Some(Instant::now());
599 }
600 }
601
602 fn on_cancelled(&self) {
603 if let Ok(mut m) = self.auto.lock() {
604 m.ended_at = Some(Instant::now());
605 }
606 }
607}
608
609#[cfg(test)]
610mod tests {
611 use super::*;
612 use crate::{LlmQuery, QueryId};
613
614 #[test]
615 fn metrics_to_json_has_auto_and_custom() {
616 let metrics = ExecutionMetrics::new();
617 let json = metrics.to_json();
618 assert!(json.get("auto").is_some());
619 assert!(json.get("custom").is_some());
620 }
621
622 #[test]
623 fn custom_handle_shares_state() {
624 let metrics = ExecutionMetrics::new();
625 let handle = metrics.custom_metrics_handle();
626
627 handle.record("key".into(), serde_json::json!("value"));
628
629 let json = metrics.to_json();
630 let custom = json.get("custom").unwrap();
631 assert_eq!(custom.get("key").unwrap(), "value");
632 }
633
634 #[test]
635 fn observer_updates_auto_metrics() {
636 let metrics = ExecutionMetrics::new();
637 let observer = metrics.create_observer();
638
639 let queries = vec![LlmQuery {
640 id: QueryId::batch(0),
641 prompt: "test".into(),
642 system: None,
643 max_tokens: 100,
644 grounded: false,
645 underspecified: false,
646 cache_breakpoint: None,
647 }];
648
649 observer.on_paused(&queries);
650 observer.on_completed(&serde_json::json!(null));
651
652 let json = metrics.to_json();
653 let auto = json.get("auto").unwrap();
654 assert_eq!(auto.get("llm_calls").unwrap(), 1);
655 assert_eq!(auto.get("pauses").unwrap(), 1);
656 assert_eq!(auto.get("rounds").unwrap(), 0);
657 assert_eq!(auto.get("total_prompt_chars").unwrap(), 4); assert_eq!(auto.get("total_response_chars").unwrap(), 0);
659 }
660
661 #[test]
662 fn observer_tracks_prompt_and_response_chars() {
663 let metrics = ExecutionMetrics::new();
664 let observer = metrics.create_observer();
665
666 let queries = vec![
667 LlmQuery {
668 id: QueryId::batch(0),
669 prompt: "hello".into(), system: Some("sys".into()), max_tokens: 100,
672 grounded: false,
673 underspecified: false,
674 cache_breakpoint: None,
675 },
676 LlmQuery {
677 id: QueryId::batch(1),
678 prompt: "world".into(), system: None,
680 max_tokens: 100,
681 grounded: false,
682 underspecified: false,
683 cache_breakpoint: None,
684 },
685 ];
686
687 observer.on_paused(&queries);
688 observer.on_response_fed(&QueryId::batch(0), &"x".repeat(42), None);
689 observer.on_response_fed(&QueryId::batch(1), &"y".repeat(58), None);
690 observer.on_resumed();
691 observer.on_completed(&serde_json::json!(null));
692
693 let json = metrics.to_json();
694 let auto = json.get("auto").unwrap();
695 assert_eq!(auto.get("total_prompt_chars").unwrap(), 13); assert_eq!(auto.get("total_response_chars").unwrap(), 100); assert_eq!(auto.get("rounds").unwrap(), 1);
698 }
699
700 #[test]
701 fn observer_tracks_multiple_rounds() {
702 let metrics = ExecutionMetrics::new();
703 let observer = metrics.create_observer();
704
705 let q = vec![LlmQuery {
706 id: QueryId::single(),
707 prompt: "p".into(),
708 system: None,
709 max_tokens: 10,
710 grounded: false,
711 underspecified: false,
712 cache_breakpoint: None,
713 }];
714
715 observer.on_paused(&q);
717 observer.on_response_fed(&QueryId::single(), &"x".repeat(10), None);
718 observer.on_resumed();
719 observer.on_paused(&q);
721 observer.on_response_fed(&QueryId::single(), &"y".repeat(20), None);
722 observer.on_resumed();
723 observer.on_paused(&q);
725 observer.on_response_fed(&QueryId::single(), &"z".repeat(30), None);
726 observer.on_resumed();
727
728 observer.on_completed(&serde_json::json!(null));
729
730 let json = metrics.to_json();
731 let auto = json.get("auto").unwrap();
732 assert_eq!(auto.get("rounds").unwrap(), 3);
733 assert_eq!(auto.get("pauses").unwrap(), 3);
734 assert_eq!(auto.get("llm_calls").unwrap(), 3);
735 assert_eq!(auto.get("total_prompt_chars").unwrap(), 3); assert_eq!(auto.get("total_response_chars").unwrap(), 60); }
738
739 #[test]
740 fn transcript_records_prompt_response_pairs() {
741 let metrics = ExecutionMetrics::new();
742 let observer = metrics.create_observer();
743
744 let queries = vec![LlmQuery {
745 id: QueryId::single(),
746 prompt: "What is 2+2?".into(),
747 system: Some("You are a calculator.".into()),
748 max_tokens: 50,
749 grounded: false,
750 underspecified: false,
751 cache_breakpoint: None,
752 }];
753
754 observer.on_paused(&queries);
755 observer.on_response_fed(&QueryId::single(), "4", None);
756 observer.on_resumed();
757 observer.on_completed(&serde_json::json!(null));
758
759 let transcript = metrics.transcript_to_json();
760 assert_eq!(transcript.len(), 1);
761 assert_eq!(transcript[0]["query_id"], "q-0");
762 assert_eq!(transcript[0]["prompt"], "What is 2+2?");
763 assert_eq!(transcript[0]["system"], "You are a calculator.");
764 assert_eq!(transcript[0]["response"], "4");
765 }
766
767 #[test]
768 fn transcript_not_in_stats() {
769 let metrics = ExecutionMetrics::new();
770 let observer = metrics.create_observer();
771 observer.on_paused(&[LlmQuery {
772 id: QueryId::single(),
773 prompt: "p".into(),
774 system: None,
775 max_tokens: 10,
776 grounded: false,
777 underspecified: false,
778 cache_breakpoint: None,
779 }]);
780 observer.on_response_fed(&QueryId::single(), "r", None);
781 observer.on_resumed();
782 observer.on_completed(&serde_json::json!(null));
783
784 let json = metrics.to_json();
785 assert!(json["auto"].get("transcript").is_none());
786 }
787
788 #[test]
789 fn transcript_multi_round() {
790 let metrics = ExecutionMetrics::new();
791 let observer = metrics.create_observer();
792
793 observer.on_paused(&[LlmQuery {
795 id: QueryId::single(),
796 prompt: "step1".into(),
797 system: None,
798 max_tokens: 100,
799 grounded: false,
800 underspecified: false,
801 cache_breakpoint: None,
802 }]);
803 observer.on_response_fed(&QueryId::single(), "answer1", None);
804 observer.on_resumed();
805
806 observer.on_paused(&[LlmQuery {
808 id: QueryId::single(),
809 prompt: "step2".into(),
810 system: Some("expert".into()),
811 max_tokens: 100,
812 grounded: false,
813 underspecified: false,
814 cache_breakpoint: None,
815 }]);
816 observer.on_response_fed(&QueryId::single(), "answer2", None);
817 observer.on_resumed();
818
819 observer.on_completed(&serde_json::json!(null));
820
821 let transcript = metrics.transcript_to_json();
822 assert_eq!(transcript.len(), 2);
823
824 assert_eq!(transcript[0]["prompt"], "step1");
825 assert!(transcript[0]["system"].is_null());
826 assert_eq!(transcript[0]["response"], "answer1");
827
828 assert_eq!(transcript[1]["prompt"], "step2");
829 assert_eq!(transcript[1]["system"], "expert");
830 assert_eq!(transcript[1]["response"], "answer2");
831 }
832
833 #[test]
834 fn transcript_batch_queries() {
835 let metrics = ExecutionMetrics::new();
836 let observer = metrics.create_observer();
837
838 let queries = vec![
839 LlmQuery {
840 id: QueryId::batch(0),
841 prompt: "q0".into(),
842 system: None,
843 max_tokens: 50,
844 grounded: false,
845 underspecified: false,
846 cache_breakpoint: None,
847 },
848 LlmQuery {
849 id: QueryId::batch(1),
850 prompt: "q1".into(),
851 system: None,
852 max_tokens: 50,
853 grounded: false,
854 underspecified: false,
855 cache_breakpoint: None,
856 },
857 ];
858
859 observer.on_paused(&queries);
860 observer.on_response_fed(&QueryId::batch(0), "r0", None);
861 observer.on_response_fed(&QueryId::batch(1), "r1", None);
862 observer.on_resumed();
863 observer.on_completed(&serde_json::json!(null));
864
865 let transcript = metrics.transcript_to_json();
866 assert_eq!(transcript.len(), 2);
867 assert_eq!(transcript[0]["query_id"], "q-0");
868 assert_eq!(transcript[0]["response"], "r0");
869 assert_eq!(transcript[1]["query_id"], "q-1");
870 assert_eq!(transcript[1]["response"], "r1");
871 }
872
873 #[test]
877 fn on_log_routes_to_log_sink() {
878 let metrics = ExecutionMetrics::new();
879 let observer = metrics.create_observer();
880
881 observer.on_log(&crate::LogEntry::new("info", "engine", "hello"));
882 observer.on_log(&crate::LogEntry::new("warn", "alc.log", "world"));
883
884 let sink = metrics.log_sink_handle();
885 let entries = sink.entries();
886 assert_eq!(entries.len(), 2);
887 assert_eq!(entries[0].level, "info");
888 assert_eq!(entries[0].source, "engine");
889 assert_eq!(entries[0].message, "hello");
890 assert_eq!(entries[1].level, "warn");
891 assert_eq!(entries[1].message, "world");
892 }
893
894 #[test]
896 fn on_log_cap_enforcement_via_observer() {
897 let metrics = ExecutionMetrics::new();
898 let observer = metrics.create_observer();
899
900 for i in 0..=20u32 {
901 observer.on_log(&crate::LogEntry::new("info", "engine", format!("msg-{i}")));
902 }
903
904 let sink = metrics.log_sink_handle();
905 let entries = sink.entries();
906 assert_eq!(entries.len(), crate::recent_log::LOG_SINK_CAP);
907 assert_eq!(entries[0].message, "msg-1");
908 assert_eq!(
909 entries[crate::recent_log::LOG_SINK_CAP - 1].message,
910 "msg-20"
911 );
912 }
913
914 #[test]
917 fn transcript_timestamps_recorded() {
918 let metrics = ExecutionMetrics::new();
919 let observer = metrics.create_observer();
920
921 let before = std::time::SystemTime::now()
922 .duration_since(std::time::UNIX_EPOCH)
923 .unwrap_or_default()
924 .as_millis() as i64;
925
926 observer.on_paused(&[LlmQuery {
927 id: QueryId::single(),
928 prompt: "ts-test".into(),
929 system: None,
930 max_tokens: 10,
931 grounded: false,
932 underspecified: false,
933 cache_breakpoint: None,
934 }]);
935
936 observer.on_response_fed(&QueryId::single(), "response", None);
937
938 let after_fed = std::time::SystemTime::now()
939 .duration_since(std::time::UNIX_EPOCH)
940 .unwrap_or_default()
941 .as_millis() as i64;
942
943 let snap = metrics.snapshot(true);
945 let history = snap["conversation_history"]
946 .as_array()
947 .expect("conversation_history must be array");
948 assert_eq!(history.len(), 1);
949
950 let started_at = history[0]["started_at"]
951 .as_i64()
952 .expect("started_at must be i64");
953 let completed_at = history[0]["completed_at"]
954 .as_i64()
955 .expect("completed_at must be i64 (not null)");
956
957 assert!(
958 started_at >= before,
959 "started_at ({started_at}) should be >= before ({before})"
960 );
961 assert!(
962 completed_at >= started_at,
963 "completed_at ({completed_at}) should be >= started_at ({started_at})"
964 );
965 assert!(
966 completed_at <= after_fed,
967 "completed_at ({completed_at}) should be <= after_fed ({after_fed})"
968 );
969 }
970
971 #[test]
973 fn snapshot_current_query_while_paused() {
974 let metrics = ExecutionMetrics::new();
975 let observer = metrics.create_observer();
976
977 observer.on_paused(&[LlmQuery {
978 id: QueryId::single(),
979 prompt: "in-flight".into(),
980 system: None,
981 max_tokens: 10,
982 grounded: false,
983 underspecified: false,
984 cache_breakpoint: None,
985 }]);
986
987 let snap = metrics.snapshot(false);
989
990 let tokens = snap.get("tokens").expect("tokens field must be present");
991 let current_query = tokens
992 .get("current_query")
993 .expect("current_query must be present");
994 assert!(
995 !current_query.is_null(),
996 "current_query should be non-null while paused"
997 );
998 assert_eq!(current_query["query_id"], "q-0");
999 assert!(
1001 snap.get("conversation_history").is_none(),
1002 "conversation_history must be absent when include_history=false"
1003 );
1004 }
1005
1006 #[test]
1008 fn snapshot_current_query_null_after_response() {
1009 let metrics = ExecutionMetrics::new();
1010 let observer = metrics.create_observer();
1011
1012 observer.on_paused(&[LlmQuery {
1013 id: QueryId::single(),
1014 prompt: "done".into(),
1015 system: None,
1016 max_tokens: 10,
1017 grounded: false,
1018 underspecified: false,
1019 cache_breakpoint: None,
1020 }]);
1021 observer.on_response_fed(&QueryId::single(), "answer", None);
1022
1023 let snap = metrics.snapshot(false);
1024 let tokens = snap.get("tokens").expect("tokens must be present");
1025 let current_query = &tokens["current_query"];
1026 assert!(
1027 current_query.is_null(),
1028 "current_query should be null after response is fed"
1029 );
1030 }
1031
1032 #[test]
1034 fn snapshot_conversation_history_opt_in() {
1035 let metrics = ExecutionMetrics::new();
1036 let observer = metrics.create_observer();
1037
1038 observer.on_paused(&[LlmQuery {
1039 id: QueryId::single(),
1040 prompt: "hello".into(),
1041 system: None,
1042 max_tokens: 50,
1043 grounded: false,
1044 underspecified: false,
1045 cache_breakpoint: None,
1046 }]);
1047 observer.on_response_fed(&QueryId::single(), "world", None);
1048 observer.on_resumed();
1049 observer.on_completed(&serde_json::json!(null));
1050
1051 let snap_false = metrics.snapshot(false);
1053 assert!(
1054 snap_false.get("conversation_history").is_none(),
1055 "conversation_history must be absent with include_history=false"
1056 );
1057
1058 let snap_true = metrics.snapshot(true);
1060 let history = snap_true
1061 .get("conversation_history")
1062 .expect("conversation_history must be present with include_history=true");
1063 let arr = history
1064 .as_array()
1065 .expect("conversation_history must be an array");
1066 assert_eq!(arr.len(), 1);
1067 assert_eq!(arr[0]["query_id"], "q-0");
1068 assert_eq!(arr[0]["prompt"], "hello");
1069 assert_eq!(arr[0]["response"], "world");
1070 assert!(arr[0].get("started_at").is_some());
1072 assert!(arr[0].get("completed_at").is_some());
1073 }
1074
1075 #[test]
1077 fn snapshot_conversation_history_capped_at_10() {
1078 let metrics = ExecutionMetrics::new();
1079 let observer = metrics.create_observer();
1080
1081 for i in 0..15u32 {
1082 observer.on_paused(&[LlmQuery {
1083 id: QueryId::single(),
1084 prompt: format!("prompt-{i}"),
1085 system: None,
1086 max_tokens: 10,
1087 grounded: false,
1088 underspecified: false,
1089 cache_breakpoint: None,
1090 }]);
1091 observer.on_response_fed(&QueryId::single(), &format!("resp-{i}"), None);
1092 observer.on_resumed();
1093 }
1094
1095 let snap = metrics.snapshot(true);
1096 let history = snap["conversation_history"]
1097 .as_array()
1098 .expect("must be array");
1099 assert_eq!(history.len(), 10, "capped at 10 entries");
1100 assert_eq!(history[0]["prompt"], "prompt-5");
1102 assert_eq!(history[9]["prompt"], "prompt-14");
1103 }
1104
1105 #[test]
1107 fn snapshot_includes_recent_logs() {
1108 let metrics = ExecutionMetrics::new();
1109 let observer = metrics.create_observer();
1110 observer.on_log(&crate::LogEntry::new("info", "engine", "test-log"));
1111
1112 let snap = metrics.snapshot(false);
1113 let logs = snap
1114 .get("recent_logs")
1115 .expect("recent_logs must be in snapshot");
1116 let arr = logs.as_array().expect("recent_logs must be array");
1117 assert_eq!(arr.len(), 1);
1118 assert_eq!(arr[0]["message"], "test-log");
1119 }
1120
1121 #[test]
1123 fn snapshot_tokens_aggregate() {
1124 let metrics = ExecutionMetrics::new();
1125 let observer = metrics.create_observer();
1126
1127 observer.on_paused(&[LlmQuery {
1128 id: QueryId::single(),
1129 prompt: "x".repeat(100),
1130 system: None,
1131 max_tokens: 50,
1132 grounded: false,
1133 underspecified: false,
1134 cache_breakpoint: None,
1135 }]);
1136 observer.on_response_fed(&QueryId::single(), &"y".repeat(50), None);
1137 observer.on_resumed();
1138
1139 let snap = metrics.snapshot(false);
1140 let tokens = snap.get("tokens").expect("tokens must be in snapshot");
1141 let prompt_total = tokens["prompt_total"]
1142 .as_u64()
1143 .expect("prompt_total must be u64");
1144 let response_total = tokens["response_total"]
1145 .as_u64()
1146 .expect("response_total must be u64");
1147 let total = tokens["total"].as_u64().expect("total must be u64");
1148 assert!(prompt_total > 0, "prompt_total must be positive");
1150 assert!(response_total > 0, "response_total must be positive");
1151 assert_eq!(total, prompt_total + response_total);
1152 }
1153
1154 #[test]
1159 fn usage_aggregate_none_when_no_llm_calls() {
1160 let metrics = ExecutionMetrics::new();
1161 assert!(
1162 metrics.usage_aggregate().is_none(),
1163 "fresh metrics with no LLM calls must return None"
1164 );
1165 }
1166
1167 #[test]
1168 fn usage_aggregate_some_when_llm_calls_recorded() {
1169 use crate::TokenUsage;
1170
1171 let metrics = ExecutionMetrics::new();
1172 let observer = metrics.create_observer();
1173
1174 let queries = vec![LlmQuery {
1175 id: QueryId::batch(0),
1176 prompt: "test".into(),
1177 system: None,
1178 max_tokens: 100,
1179 grounded: false,
1180 underspecified: false,
1181 cache_breakpoint: None,
1182 }];
1183 observer.on_paused(&queries);
1184 observer.on_response_fed(
1185 &QueryId::batch(0),
1186 "ans",
1187 Some(&TokenUsage {
1188 prompt_tokens: Some(10),
1189 completion_tokens: Some(5),
1190 }),
1191 );
1192
1193 let result = metrics.usage_aggregate();
1194 assert!(
1195 result.is_some(),
1196 "usage_aggregate must return Some after LLM call"
1197 );
1198 let usage = result.unwrap();
1199 assert_eq!(
1200 usage.prompt_tokens,
1201 Some(10),
1202 "prompt_tokens must match provided value"
1203 );
1204 assert_eq!(
1205 usage.completion_tokens,
1206 Some(5),
1207 "completion_tokens must match provided value"
1208 );
1209 }
1210
1211 #[test]
1212 fn usage_aggregate_some_with_estimated_path() {
1213 let metrics = ExecutionMetrics::new();
1217 let observer = metrics.create_observer();
1218
1219 let queries = vec![LlmQuery {
1220 id: QueryId::single(),
1221 prompt: "hello world".into(),
1222 system: None,
1223 max_tokens: 50,
1224 grounded: false,
1225 underspecified: false,
1226 cache_breakpoint: None,
1227 }];
1228 observer.on_paused(&queries);
1229 observer.on_response_fed(&QueryId::single(), "response text", None);
1231
1232 let result = metrics.usage_aggregate();
1233 assert!(
1234 result.is_some(),
1235 "usage_aggregate must return Some even when token counts are estimated"
1236 );
1237 let usage = result.unwrap();
1238 assert!(
1239 usage.prompt_tokens.is_some(),
1240 "prompt_tokens must be Some (estimated)"
1241 );
1242 assert!(
1243 usage.completion_tokens.is_some(),
1244 "completion_tokens must be Some (estimated)"
1245 );
1246 }
1247}