1pub mod agent_events;
10
11#[cfg(feature = "status-server")]
12pub mod server;
13
14#[cfg(feature = "status-server")]
15pub mod multi_server;
16
17use serde::{Deserialize, Serialize};
18use std::collections::VecDeque;
19use std::sync::Arc;
20use tokio::sync::RwLock;
21use utoipa::ToSchema;
22
23const MAX_RECENT_TASKS: usize = 20;
25
26pub(crate) const MAX_EVENT_LOG: usize = 200;
34
35const MAX_RECENT_SCORES: usize = 50;
37
38pub type SharedAgentStatus = Arc<RwLock<AgentStatusSnapshot>>;
40
41#[derive(Debug, Clone, Serialize, ToSchema)]
43pub struct AgentStatusSnapshot {
44 pub agent_id: String,
45 pub model_name: String,
46 pub provider_id: String,
47 pub nats_connected: bool,
48 pub current_job: Option<String>,
49 pub current_round: Option<u32>,
50 pub current_phase: Option<String>,
52 pub uptime_secs: u64,
53 pub tasks_completed: u64,
54 pub tasks_failed: u64,
55 #[schema(value_type = Vec<TaskLogEntry>)]
56 pub recent_tasks: VecDeque<TaskLogEntry>,
57 pub scratchpad_keys: u64,
58 #[schema(value_type = Vec<EventLogEntry>)]
60 pub event_log: VecDeque<EventLogEntry>,
61 pub is_paused: bool,
63 pub buffered_count: u32,
65 pub error_rate: f32,
67 #[schema(value_type = Vec<ScoreEntry>)]
70 pub recent_scores: VecDeque<ScoreEntry>,
71 pub mean_score: Option<f32>,
73 pub score_std_dev: Option<f32>,
75 pub is_flagged: bool,
77 pub flag_reason: Option<String>,
79}
80
81#[derive(Debug, Clone, Serialize, ToSchema)]
83pub struct TaskLogEntry {
84 pub timestamp: String,
85 pub action: String,
86 pub job_id: String,
87 pub round: u32,
88 pub status: String,
90 pub duration_ms: u64,
91 #[serde(skip_serializing_if = "Option::is_none")]
94 pub content_preview: Option<String>,
95}
96
97#[derive(Debug, Clone, Serialize, ToSchema)]
99pub struct EventLogEntry {
100 pub timestamp: String,
101 pub event_type: String,
104 pub job_id: Option<String>,
106 pub detail: String,
108}
109
110#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
116pub struct ScoreEntry {
117 pub timestamp: String,
118 pub job_id: String,
120 pub round: u32,
122 pub evaluator: String,
124 pub score: f32,
126}
127
128impl AgentStatusSnapshot {
129 pub fn new(agent_id: String, model_name: String, provider_id: String) -> Self {
131 Self {
132 agent_id,
133 model_name,
134 provider_id,
135 nats_connected: false,
136 current_job: None,
137 current_round: None,
138 current_phase: None,
139 uptime_secs: 0,
140 tasks_completed: 0,
141 tasks_failed: 0,
142 recent_tasks: VecDeque::with_capacity(MAX_RECENT_TASKS + 1),
143 scratchpad_keys: 0,
144 event_log: VecDeque::with_capacity(MAX_EVENT_LOG + 1),
145 is_paused: false,
146 buffered_count: 0,
147 error_rate: 0.0,
148 recent_scores: VecDeque::with_capacity(MAX_RECENT_SCORES + 1),
149 mean_score: None,
150 score_std_dev: None,
151 is_flagged: false,
152 flag_reason: None,
153 }
154 }
155
156 pub fn push_task(&mut self, entry: TaskLogEntry) {
158 if entry.status == "ok" {
159 self.tasks_completed += 1;
160 } else {
161 self.tasks_failed += 1;
162 }
163 let total = self.tasks_completed + self.tasks_failed;
164 self.error_rate = self.tasks_failed as f32 / total as f32;
166 self.recent_tasks.push_front(entry);
167 if self.recent_tasks.len() > MAX_RECENT_TASKS {
168 self.recent_tasks.pop_back();
169 }
170 }
171
172 pub fn push_score(&mut self, entry: ScoreEntry) {
175 self.recent_scores.push_back(entry);
176 if self.recent_scores.len() > MAX_RECENT_SCORES {
177 self.recent_scores.pop_front();
178 }
179 let n = self.recent_scores.len();
182 let sum: f32 = self.recent_scores.iter().map(|s| s.score).sum();
183 let mean = sum / n as f32;
184 self.mean_score = Some(mean);
185 if n >= 2 {
186 let variance: f32 = self
187 .recent_scores
188 .iter()
189 .map(|s| (s.score - mean).powi(2))
190 .sum::<f32>()
191 / n as f32;
192 self.score_std_dev = Some(variance.sqrt());
193 } else {
194 self.score_std_dev = None;
195 }
196 self.check_flags();
197 }
198
199 pub fn check_flags(&mut self) {
208 let n = self.recent_scores.len();
209
210 if n >= 3 {
212 let recent_3_avg: f32 = self
213 .recent_scores
214 .iter()
215 .rev()
216 .take(3)
217 .map(|s| s.score)
218 .sum::<f32>()
219 / 3.0;
220 const LOW_SCORE_THRESHOLD: f32 = -0.3;
222 if recent_3_avg < LOW_SCORE_THRESHOLD {
223 self.is_flagged = true;
224 self.flag_reason = Some(format!("Low scores: recent avg {:.2}", recent_3_avg));
225 return;
226 }
227 }
228
229 if let Some(std_dev) = self.score_std_dev {
231 const HIGH_DIVERGENCE_THRESHOLD: f32 = 1.5;
233 if std_dev > HIGH_DIVERGENCE_THRESHOLD {
234 self.is_flagged = true;
235 self.flag_reason = Some(format!("High divergence: std_dev {:.1}", std_dev));
236 return;
237 }
238 }
239
240 self.is_flagged = false;
242 self.flag_reason = None;
243 }
244
245 pub fn push_event(&mut self, event_type: &str, job_id: Option<&str>, detail: &str) {
247 self.event_log.push_back(EventLogEntry {
248 timestamp: chrono::Utc::now().to_rfc3339(),
249 event_type: event_type.to_string(),
250 job_id: job_id.map(String::from),
251 detail: detail.to_string(),
252 });
253 if self.event_log.len() > MAX_EVENT_LOG {
254 self.event_log.pop_front();
255 }
256 }
257}
258
259pub fn new_shared_status(
261 agent_id: String,
262 model_name: String,
263 provider_id: String,
264) -> SharedAgentStatus {
265 Arc::new(RwLock::new(AgentStatusSnapshot::new(
266 agent_id,
267 model_name,
268 provider_id,
269 )))
270}
271
272#[cfg(test)]
273mod tests {
274 use super::*;
275
276 #[test]
277 fn test_snapshot_push_task_trims() {
278 let mut snap = AgentStatusSnapshot::new("a".into(), "m".into(), "p".into());
279 for i in 0..25 {
280 snap.push_task(TaskLogEntry {
281 timestamp: format!("2025-01-01T00:00:{:02}Z", i),
282 action: "propose".into(),
283 job_id: format!("job-{}", i),
284 round: 1,
285 status: if i % 3 == 0 {
286 "error".into()
287 } else {
288 "ok".into()
289 },
290 duration_ms: 100,
291 content_preview: None,
292 });
293 }
294 assert_eq!(snap.recent_tasks.len(), MAX_RECENT_TASKS);
295 assert_eq!(snap.tasks_failed, 9);
297 assert_eq!(snap.tasks_completed, 16);
298 }
299
300 #[test]
301 fn test_snapshot_counters() {
302 let mut snap = AgentStatusSnapshot::new("a".into(), "m".into(), "p".into());
303 snap.push_task(TaskLogEntry {
304 timestamp: "t".into(),
305 action: "propose".into(),
306 job_id: "j1".into(),
307 round: 1,
308 status: "ok".into(),
309 duration_ms: 50,
310 content_preview: None,
311 });
312 snap.push_task(TaskLogEntry {
313 timestamp: "t".into(),
314 action: "evaluate".into(),
315 job_id: "j1".into(),
316 round: 1,
317 status: "error".into(),
318 duration_ms: 100,
319 content_preview: None,
320 });
321 assert_eq!(snap.tasks_completed, 1);
322 assert_eq!(snap.tasks_failed, 1);
323 assert_eq!(snap.recent_tasks.len(), 2);
324 assert_eq!(snap.recent_tasks[0].action, "evaluate");
326 }
327
328 #[test]
329 fn test_event_log_push_and_trim() {
330 let mut snap = AgentStatusSnapshot::new("a".into(), "m".into(), "p".into());
331 for i in 0..250 {
332 snap.push_event("test", Some("job1"), &format!("event {}", i));
333 }
334 assert_eq!(snap.event_log.len(), MAX_EVENT_LOG);
335 assert!(snap.event_log.front().unwrap().detail.contains("50"));
337 }
338
339 #[test]
340 fn test_event_log_entry_fields() {
341 let mut snap = AgentStatusSnapshot::new("a".into(), "m".into(), "p".into());
342 snap.push_event("agent_accepted", Some("job-123"), "Accepted manifest");
343 assert_eq!(snap.event_log.len(), 1);
344 let entry = snap.event_log.front().unwrap();
345 assert_eq!(entry.event_type, "agent_accepted");
346 assert_eq!(entry.job_id.as_deref(), Some("job-123"));
347 assert_eq!(entry.detail, "Accepted manifest");
348 assert!(!entry.timestamp.is_empty());
349 }
350
351 #[test]
352 fn test_snapshot_new_defaults() {
353 let snap = AgentStatusSnapshot::new("agent-1".into(), "gpt-4".into(), "openai".into());
354 assert_eq!(snap.agent_id, "agent-1");
355 assert_eq!(snap.model_name, "gpt-4");
356 assert_eq!(snap.provider_id, "openai");
357 assert!(!snap.nats_connected);
358 assert!(snap.current_job.is_none());
359 assert!(snap.current_round.is_none());
360 assert!(snap.current_phase.is_none());
361 assert_eq!(snap.uptime_secs, 0);
362 assert_eq!(snap.tasks_completed, 0);
363 assert_eq!(snap.tasks_failed, 0);
364 assert!(snap.recent_tasks.is_empty());
365 assert_eq!(snap.scratchpad_keys, 0);
366 assert!(snap.event_log.is_empty());
367 }
368
369 #[test]
370 fn test_push_event_without_job_id() {
371 let mut snap = AgentStatusSnapshot::new("a".into(), "m".into(), "p".into());
372 snap.push_event("heartbeat", None, "Still alive");
373 assert_eq!(snap.event_log.len(), 1);
374 let entry = snap.event_log.front().unwrap();
375 assert_eq!(entry.event_type, "heartbeat");
376 assert!(entry.job_id.is_none());
377 assert_eq!(entry.detail, "Still alive");
378 }
379
380 #[test]
381 fn test_push_task_ordering() {
382 let mut snap = AgentStatusSnapshot::new("a".into(), "m".into(), "p".into());
383 for i in 0..3 {
384 snap.push_task(TaskLogEntry {
385 timestamp: format!("t{}", i),
386 action: format!("action-{}", i),
387 job_id: "j1".into(),
388 round: 1,
389 status: "ok".into(),
390 duration_ms: 10,
391 content_preview: None,
392 });
393 }
394 assert_eq!(snap.recent_tasks[0].action, "action-2");
396 assert_eq!(snap.recent_tasks[1].action, "action-1");
397 assert_eq!(snap.recent_tasks[2].action, "action-0");
398 }
399
400 #[test]
401 fn test_snapshot_serialization() {
402 let mut snap =
403 AgentStatusSnapshot::new("agent-x".into(), "claude".into(), "anthropic".into());
404 snap.nats_connected = true;
405 snap.tasks_completed = 5;
406 snap.push_event("connected", None, "Connected to NATS");
407 let json = serde_json::to_string(&snap).unwrap();
408 assert!(json.contains("\"agent_id\":\"agent-x\""));
409 assert!(json.contains("\"nats_connected\":true"));
410 assert!(json.contains("\"tasks_completed\":5"));
411 assert!(json.contains("\"event_log\""));
412 }
413
414 #[tokio::test]
415 async fn test_new_shared_status() {
416 let shared = new_shared_status("agent-1".into(), "model".into(), "provider".into());
417 let snap = shared.read().await;
418 assert_eq!(snap.agent_id, "agent-1");
419 assert_eq!(snap.model_name, "model");
420 assert_eq!(snap.provider_id, "provider");
421 assert!(!snap.nats_connected);
422 }
423
424 #[test]
429 fn test_snapshot_new_has_default_hitl_fields() {
430 let snap = AgentStatusSnapshot::new("a".into(), "m".into(), "p".into());
431 assert!(!snap.is_paused);
432 assert_eq!(snap.buffered_count, 0);
433 assert_eq!(snap.error_rate, 0.0);
434 assert!(snap.recent_scores.is_empty());
435 assert!(snap.mean_score.is_none());
436 }
437
438 #[test]
439 fn test_snapshot_error_rate_computed() {
440 let mut snap = AgentStatusSnapshot::new("a".into(), "m".into(), "p".into());
441 for i in 0..4 {
442 snap.push_task(TaskLogEntry {
443 timestamp: "t".into(),
444 action: "propose".into(),
445 job_id: format!("j{}", i),
446 round: 1,
447 status: if i == 3 { "error" } else { "ok" }.into(),
448 duration_ms: 10,
449 content_preview: None,
450 });
451 }
452 assert!((snap.error_rate - 0.25).abs() < f32::EPSILON);
454 }
455
456 #[test]
457 fn test_snapshot_error_rate_zero_tasks() {
458 let snap = AgentStatusSnapshot::new("a".into(), "m".into(), "p".into());
459 assert_eq!(snap.error_rate, 0.0);
460 }
461
462 #[test]
463 fn test_snapshot_push_score_updates_mean() {
464 let mut snap = AgentStatusSnapshot::new("a".into(), "m".into(), "p".into());
465 snap.push_score(ScoreEntry {
466 timestamp: "t1".into(),
467 job_id: "j1".into(),
468 round: 1,
469 evaluator: "BETA".into(),
470 score: 6.0,
471 });
472 snap.push_score(ScoreEntry {
473 timestamp: "t2".into(),
474 job_id: "j1".into(),
475 round: 1,
476 evaluator: "GAMMA".into(),
477 score: 9.0,
478 });
479 snap.push_score(ScoreEntry {
480 timestamp: "t3".into(),
481 job_id: "j2".into(),
482 round: 1,
483 evaluator: "BETA".into(),
484 score: 3.0,
485 });
486 assert_eq!(snap.recent_scores.len(), 3);
487 assert!((snap.mean_score.unwrap() - 6.0).abs() < f32::EPSILON);
489 }
490
491 #[test]
492 fn test_snapshot_push_score_trims_to_max() {
493 let mut snap = AgentStatusSnapshot::new("a".into(), "m".into(), "p".into());
494 for i in 0..60 {
495 snap.push_score(ScoreEntry {
496 timestamp: format!("t{}", i),
497 job_id: "j".into(),
498 round: 1,
499 evaluator: "e".into(),
500 score: i as f32,
501 });
502 }
503 assert_eq!(snap.recent_scores.len(), MAX_RECENT_SCORES);
504 assert_eq!(snap.recent_scores.front().unwrap().score, 10.0);
506 }
507
508 #[test]
509 fn test_score_entry_serialization() {
510 let entry = ScoreEntry {
511 timestamp: "2025-01-01T00:00:00Z".into(),
512 job_id: "job-abc".into(),
513 round: 3,
514 evaluator: "BETA".into(),
515 score: 7.5,
516 };
517 let json = serde_json::to_string(&entry).unwrap();
518 let roundtripped: ScoreEntry = serde_json::from_str(&json).unwrap();
519 assert_eq!(roundtripped.job_id, "job-abc");
520 assert_eq!(roundtripped.round, 3);
521 assert_eq!(roundtripped.evaluator, "BETA");
522 assert!((roundtripped.score - 7.5).abs() < f32::EPSILON);
523 }
524
525 fn push_n_scores(snap: &mut AgentStatusSnapshot, scores: &[f32]) {
530 for (i, &s) in scores.iter().enumerate() {
531 snap.push_score(ScoreEntry {
532 timestamp: format!("t{}", i),
533 job_id: "j".into(),
534 round: 1,
535 evaluator: "e".into(),
536 score: s,
537 });
538 }
539 }
540
541 #[test]
542 fn test_push_score_computes_std_dev() {
543 let mut snap = AgentStatusSnapshot::new("a".into(), "m".into(), "p".into());
544 push_n_scores(&mut snap, &[4.0, 6.0, 8.0]);
545 let std_dev = snap.score_std_dev.unwrap();
548 assert!((std_dev - 1.633).abs() < 0.01, "std_dev was {}", std_dev);
549 }
550
551 #[test]
552 fn test_std_dev_none_with_single_score() {
553 let mut snap = AgentStatusSnapshot::new("a".into(), "m".into(), "p".into());
554 push_n_scores(&mut snap, &[5.0]);
555 assert!(snap.score_std_dev.is_none());
556 }
557
558 #[test]
559 fn test_check_flags_low_scores() {
560 let mut snap = AgentStatusSnapshot::new("a".into(), "m".into(), "p".into());
561 push_n_scores(&mut snap, &[-0.5, -0.8, -0.4]);
563 assert!(snap.is_flagged, "should be flagged for low scores");
564 assert!(
565 snap.flag_reason.as_ref().unwrap().contains("Low scores"),
566 "flag_reason: {:?}",
567 snap.flag_reason
568 );
569 }
570
571 #[test]
572 fn test_check_flags_high_divergence() {
573 let mut snap = AgentStatusSnapshot::new("a".into(), "m".into(), "p".into());
574 push_n_scores(&mut snap, &[-2.0, 2.0, -2.0, 2.0, -2.0, 2.0]);
576 assert!(
577 snap.score_std_dev.unwrap() > 1.5,
578 "std_dev should be > 1.5, got {}",
579 snap.score_std_dev.unwrap()
580 );
581 assert!(snap.is_flagged);
582 assert!(
583 snap.flag_reason.as_ref().unwrap().contains("divergence"),
584 "flag_reason: {:?}",
585 snap.flag_reason
586 );
587 }
588
589 #[test]
590 fn test_check_flags_clears_when_scores_improve() {
591 let mut snap = AgentStatusSnapshot::new("a".into(), "m".into(), "p".into());
592 push_n_scores(&mut snap, &[-0.5, -0.6, -0.7]);
594 assert!(snap.is_flagged);
595
596 push_n_scores(&mut snap, &[0.8, 0.9, 1.0]);
598 assert!(!snap.is_flagged, "flag should be cleared after good scores");
599 assert!(snap.flag_reason.is_none());
600 }
601
602 #[test]
603 fn test_check_flags_not_flagged_with_good_scores() {
604 let mut snap = AgentStatusSnapshot::new("a".into(), "m".into(), "p".into());
605 push_n_scores(&mut snap, &[0.7, 0.8, 0.9]);
607 assert!(!snap.is_flagged);
608 assert!(snap.flag_reason.is_none());
609 }
610
611 #[test]
612 fn test_snapshot_new_has_default_flag_fields() {
613 let snap = AgentStatusSnapshot::new("a".into(), "m".into(), "p".into());
614 assert!(!snap.is_flagged);
615 assert!(snap.flag_reason.is_none());
616 assert!(snap.score_std_dev.is_none());
617 }
618
619 #[test]
620 fn test_agent_summary_includes_flag_fields_in_serialization() {
621 let mut snap = AgentStatusSnapshot::new("a".into(), "m".into(), "p".into());
622 push_n_scores(&mut snap, &[-0.5, -0.6, -0.7]);
624 let json = serde_json::to_string(&snap).unwrap();
625 assert!(json.contains("\"is_flagged\":true"));
626 assert!(json.contains("\"flag_reason\""));
627 assert!(json.contains("\"score_std_dev\""));
628 }
629
630 #[test]
631 fn test_402_payment_sets_paused_and_flagged() {
632 let mut snap = AgentStatusSnapshot::new("a".into(), "m".into(), "p".into());
635 assert!(!snap.is_paused);
636 assert!(!snap.is_flagged);
637 assert!(snap.flag_reason.is_none());
638
639 snap.is_paused = true;
641 snap.is_flagged = true;
642 snap.flag_reason = Some("Provider payment required (402) — agent paused".to_string());
643
644 assert!(snap.is_paused);
645 assert!(snap.is_flagged);
646 assert_eq!(
647 snap.flag_reason.as_deref(),
648 Some("Provider payment required (402) — agent paused")
649 );
650
651 let json = serde_json::to_string(&snap).unwrap();
653 assert!(json.contains("\"is_paused\":true"));
654 assert!(json.contains("\"is_flagged\":true"));
655 assert!(json.contains("402"));
656 assert!(json.contains("payment"));
657 }
658
659 #[test]
664 fn test_event_lifecycle_ordering_non_buffered() {
665 let mut snap = AgentStatusSnapshot::new("a".into(), "m".into(), "p".into());
668 snap.push_event("connected", None, "NATS connected");
669 snap.push_event("agent_working", Some("job-1"), "Round 1 propose");
670 snap.push_event("task_complete", Some("job-1"), "propose ok 150ms");
671
672 let types: Vec<&str> = snap
673 .event_log
674 .iter()
675 .map(|e| e.event_type.as_str())
676 .collect();
677 assert_eq!(types, vec!["connected", "agent_working", "task_complete"]);
678 }
679
680 #[test]
681 fn test_event_lifecycle_ordering_buffered() {
682 let mut snap = AgentStatusSnapshot::new("a".into(), "m".into(), "p".into());
685 snap.push_event("connected", None, "NATS connected");
686 snap.push_event("agent_working", Some("job-1"), "Round 1 propose");
687 snap.push_event(
688 "response_buffered",
689 Some("job-1"),
690 "Round 1 propose buffered 5000ms hold",
691 );
692 snap.push_event(
693 "buffer_released",
694 Some("job-1"),
695 "Round 1 propose released from buffer",
696 );
697 snap.push_event("task_complete", Some("job-1"), "Round 1 propose released");
698
699 let types: Vec<&str> = snap
700 .event_log
701 .iter()
702 .map(|e| e.event_type.as_str())
703 .collect();
704 assert_eq!(
705 types,
706 vec![
707 "connected",
708 "agent_working",
709 "response_buffered",
710 "buffer_released",
711 "task_complete"
712 ]
713 );
714
715 let job_events: Vec<_> = snap
717 .event_log
718 .iter()
719 .filter(|e| e.job_id.as_deref() == Some("job-1"))
720 .collect();
721 assert_eq!(job_events.len(), 4, "4 events should reference job-1");
722 }
723
724 #[test]
725 fn test_event_lifecycle_with_stop_unstop() {
726 let mut snap = AgentStatusSnapshot::new("a".into(), "m".into(), "p".into());
728 snap.push_event("agent_working", Some("job-1"), "Round 1 propose");
729 snap.push_event(
730 "response_buffered",
731 Some("job-1"),
732 "Round 1 propose buffered 5000ms hold",
733 );
734 snap.push_event(
735 "buffer_stopped",
736 Some("entry-1"),
737 "Response stopped by operator",
738 );
739 snap.push_event("buffer_unstopped", Some("entry-1"), "Stop removed");
740 snap.push_event("buffer_released", Some("job-1"), "Round 1 propose released");
741 snap.push_event("task_complete", Some("job-1"), "Round 1 propose released");
742
743 let types: Vec<&str> = snap
744 .event_log
745 .iter()
746 .map(|e| e.event_type.as_str())
747 .collect();
748 assert_eq!(
749 types,
750 vec![
751 "agent_working",
752 "response_buffered",
753 "buffer_stopped",
754 "buffer_unstopped",
755 "buffer_released",
756 "task_complete"
757 ]
758 );
759 }
760
761 #[test]
762 fn test_event_lifecycle_with_reject() {
763 let mut snap = AgentStatusSnapshot::new("a".into(), "m".into(), "p".into());
765 snap.push_event("agent_working", Some("job-1"), "Round 1 propose");
766 snap.push_event(
767 "response_buffered",
768 Some("job-1"),
769 "Round 1 propose buffered",
770 );
771 snap.push_event(
772 "buffer_rejected",
773 Some("job-1"),
774 "propose rejected by operator",
775 );
776
777 let types: Vec<&str> = snap
778 .event_log
779 .iter()
780 .map(|e| e.event_type.as_str())
781 .collect();
782 assert_eq!(
783 types,
784 vec!["agent_working", "response_buffered", "buffer_rejected"]
785 );
786 }
788
789 #[test]
790 fn test_event_lifecycle_multi_agent_interleaved() {
791 let mut snap = AgentStatusSnapshot::new("a".into(), "m".into(), "p".into());
793 snap.push_event("agent_working", Some("job-A"), "Round 1 propose");
794 snap.push_event("agent_working", Some("job-B"), "Round 1 evaluate");
795 snap.push_event(
796 "response_buffered",
797 Some("job-A"),
798 "Round 1 propose buffered",
799 );
800 snap.push_event("task_complete", Some("job-B"), "evaluate ok");
801 snap.push_event("buffer_released", Some("job-A"), "Round 1 propose released");
802 snap.push_event("task_complete", Some("job-A"), "Round 1 propose released");
803
804 assert_eq!(snap.event_log.len(), 6);
805 let job_a: Vec<&str> = snap
807 .event_log
808 .iter()
809 .filter(|e| e.job_id.as_deref() == Some("job-A"))
810 .map(|e| e.event_type.as_str())
811 .collect();
812 assert_eq!(
813 job_a,
814 vec![
815 "agent_working",
816 "response_buffered",
817 "buffer_released",
818 "task_complete"
819 ]
820 );
821 }
822
823 #[test]
824 fn test_event_buffered_count_tracking() {
825 let mut snap = AgentStatusSnapshot::new("a".into(), "m".into(), "p".into());
827 assert_eq!(snap.buffered_count, 0);
828
829 snap.buffered_count = 3;
830 assert_eq!(snap.buffered_count, 3);
831
832 snap.buffered_count = 0;
833 assert_eq!(snap.buffered_count, 0);
834 }
835}