1pub mod buffer;
14
15use crate::agents::{
16 AgentConfig, AgentContext, AgentHeartbeat, AgentLiveStatus, ChatCapable, NsedAgent,
17 PersistenceStore, ProposalRecord, UserToolHandlerTrait,
18};
19use crate::nats_utils::{NatsAuth, connect_nats, ensure_kv_bucket, sanitize_subject_component};
20use crate::providers::{Availability, ModelAvailability};
21use crate::status::agent_events::{AgentEvent, AgentEventKind, AgentEventStore};
22use crate::status::{SharedAgentStatus, TaskLogEntry, new_shared_status};
23use crate::telemetry::{TaskFailureClass, TelemetryEmitterMux};
24
25use anyhow::{Context, Result};
26use async_nats::connection::State as NatsState;
27use async_nats::jetstream::{self, kv};
28use async_trait::async_trait;
29use futures::StreamExt;
30use serde::{Deserialize, Serialize};
31use std::collections::HashSet;
32use std::fmt::Debug;
33use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
34use std::sync::{Arc, Mutex};
35use std::time::Instant;
36use tracing::{error, info, warn};
37use uuid::Uuid;
38
39pub const PASSTHROUGH_SUBJECT_SUFFIX: &str = "passthrough";
49
50#[derive(Debug, Clone, Serialize, Deserialize)]
56pub struct PassthroughRequest {
57 pub session_id: String,
59 pub messages: Vec<PassthroughMessage>,
61 pub operator_principal: String,
63}
64
65#[derive(Debug, Clone, Serialize, Deserialize)]
67pub struct PassthroughMessage {
68 pub role: String,
70 pub content: String,
71}
72
73#[derive(Debug, Clone, Serialize, Deserialize)]
75pub struct PassthroughResponse {
76 pub content: String,
78 pub input_tokens: Option<u32>,
80 pub output_tokens: Option<u32>,
82}
83
84#[derive(Debug, Clone, Serialize, Deserialize)]
86pub struct PassthroughError {
87 pub error: String,
88}
89
90#[async_trait]
100pub trait WorkerHook: Send + Sync + Debug {
101 async fn before_publish(&self, _subject: &str, _payload: &mut Vec<u8>) -> Result<()> {
103 Ok(())
104 }
105}
106
107#[async_trait]
116pub trait UserToolHandlerFactory: Send + Sync + Debug {
117 fn create(
119 &self,
120 nats: async_nats::Client,
121 js: jetstream::Context,
122 session_id: String,
123 agent_id: String,
124 budget_remaining_secs: f64,
125 subject_prefix: String,
126 ) -> Arc<dyn UserToolHandlerTrait>;
127}
128
129#[derive(Clone, Debug)]
141pub struct WorkerConfig {
142 pub nats_url: String,
144 pub stream_name: String,
146 pub consumer_name: String,
148 pub subject_prefix: String,
150 pub api_prefix: String,
152 pub scratchpad_retention_secs: u64,
154 pub nats_auth: Option<NatsAuth>,
156 pub max_concurrent_jobs: Option<usize>,
162}
163
164impl WorkerConfig {
165 pub fn new(nats_url: String, stream_name: String, consumer_name: String) -> Self {
167 Self {
168 nats_url,
169 stream_name,
170 consumer_name,
171 subject_prefix: "nsed".to_string(),
172 api_prefix: "sphera".to_string(),
173 scratchpad_retention_secs: 86400 * 7,
174 nats_auth: None,
175 max_concurrent_jobs: None,
176 }
177 }
178
179 pub fn with_subject_prefix(mut self, prefix: String) -> Self {
181 self.subject_prefix = prefix;
182 self
183 }
184
185 pub fn with_api_prefix(mut self, prefix: String) -> Self {
187 self.api_prefix = prefix;
188 self
189 }
190
191 pub fn with_scratchpad_retention(mut self, secs: u64) -> Self {
193 self.scratchpad_retention_secs = secs;
194 self
195 }
196
197 pub fn with_nats_auth(mut self, auth: NatsAuth) -> Self {
199 self.nats_auth = Some(auth);
200 self
201 }
202
203 pub fn with_max_concurrent_jobs(mut self, n: usize) -> Self {
206 self.max_concurrent_jobs = Some(n);
207 self
208 }
209
210 fn max_ack_pending(&self) -> i64 {
214 self.max_concurrent_jobs.map(|n| n as i64).unwrap_or(0)
215 }
216}
217
218#[derive(Debug, Clone, Serialize, Deserialize)]
224pub struct JobManifest {
225 pub job_id: String,
226 pub task_description: String,
227 pub agents: Vec<String>,
228 pub rounds: u32,
229 pub timestamp: u64,
230}
231
232#[derive(Clone, Debug)]
240pub struct NatsScratchpadStore {
241 store: kv::Store,
242 js: jetstream::Context,
243 scope_prefix: String,
244}
245
246impl NatsScratchpadStore {
247 pub fn new(store: kv::Store, js: jetstream::Context, scope_prefix: String) -> Self {
249 Self {
250 store,
251 js,
252 scope_prefix,
253 }
254 }
255
256 fn scoped_key(&self, key: &str) -> String {
257 format!("{}.{}", self.scope_prefix, key)
258 }
259
260 async fn store_get(&self, key: &str) -> Result<Option<bytes::Bytes>> {
261 self.store
262 .get(self.scoped_key(key))
263 .await
264 .map_err(|e| anyhow::anyhow!(e))
265 }
266
267 async fn store_entry(&self, key: &str) -> Result<Option<kv::Entry>> {
268 self.store
269 .entry(self.scoped_key(key))
270 .await
271 .map_err(|e| anyhow::anyhow!(e))
272 }
273
274 async fn store_put(&self, key: &str, value: bytes::Bytes) -> Result<u64> {
275 self.store
276 .put(self.scoped_key(key), value)
277 .await
278 .map_err(|e| anyhow::anyhow!(e))
279 }
280
281 async fn store_create(
282 &self,
283 key: &str,
284 value: bytes::Bytes,
285 ) -> std::result::Result<u64, async_nats::error::Error<kv::CreateErrorKind>> {
286 self.store.create(self.scoped_key(key), value).await
287 }
288
289 async fn store_update(
290 &self,
291 key: &str,
292 value: bytes::Bytes,
293 revision: u64,
294 ) -> std::result::Result<u64, async_nats::error::Error<kv::UpdateErrorKind>> {
295 self.store
296 .update(self.scoped_key(key), value, revision)
297 .await
298 }
299}
300
301#[async_trait]
302impl PersistenceStore for NatsScratchpadStore {
303 async fn get(&self, key: &str) -> Result<Option<String>> {
304 match self.store_get(key).await? {
305 Some(data) => {
306 let vec: Vec<u8> = data.to_vec();
307 Ok(Some(String::from_utf8(vec)?))
308 }
309 None => Ok(None),
310 }
311 }
312
313 async fn append(&self, key: &str, content: &str) -> Result<()> {
314 let mut attempts = 0;
315 let max_retries = 20;
316
317 loop {
318 attempts += 1;
319 if attempts > max_retries {
320 return Err(anyhow::anyhow!(
321 "Failed to append to key '{}' (scoped) after {} attempts due to contention",
322 key,
323 max_retries
324 ));
325 }
326
327 match self.store_entry(key).await? {
328 Some(entry) => {
329 let current = String::from_utf8_lossy(&entry.value);
330 let new_content = format!("{}{}", current, content);
331
332 match self
333 .store_update(key, new_content.into(), entry.revision)
334 .await
335 {
336 Ok(_) => return Ok(()),
337 Err(e) => {
338 if matches!(e.kind(), kv::UpdateErrorKind::WrongLastRevision) {
339 tokio::time::sleep(std::time::Duration::from_millis(
340 5 + (attempts * 2),
341 ))
342 .await;
343 continue;
344 }
345 return Err(anyhow::anyhow!(e));
346 }
347 }
348 }
349 None => match self.store_create(key, content.to_string().into()).await {
350 Ok(_) => return Ok(()),
351 Err(e) => {
352 if matches!(e.kind(), kv::CreateErrorKind::AlreadyExists) {
353 tokio::time::sleep(std::time::Duration::from_millis(
354 5 + (attempts * 2),
355 ))
356 .await;
357 continue;
358 }
359 return Err(anyhow::anyhow!(e));
360 }
361 },
362 }
363 }
364 }
365
366 async fn set(&self, key: &str, content: &str) -> Result<()> {
367 self.store_put(key, content.to_string().into()).await?;
368 Ok(())
369 }
370
371 async fn get_round_history(&self, round: u32) -> Result<Option<Vec<ProposalRecord>>> {
372 let safe_id = sanitize_subject_component(&self.scope_prefix);
373 let bucket_name = format!("nsed_hist_{}", safe_id);
374
375 let history_store = match self.js.get_key_value(&bucket_name).await {
376 Ok(s) => s,
377 Err(e) => {
378 let err_str = e.to_string();
379 if err_str.contains("not found") || err_str.contains("no stream") {
380 return Ok(None);
381 }
382 return Err(anyhow::anyhow!(e)
383 .context(format!("Failed to access history bucket '{}'", bucket_name)));
384 }
385 };
386
387 let key = format!("round_{}", round);
388
389 match history_store.get(&key).await {
390 Ok(Some(entry)) => {
391 let records = serde_json::from_slice(&entry)
392 .context("Failed to deserialize round history")?;
393 Ok(Some(records))
394 }
395 Ok(None) => Ok(None),
396 Err(e) => Err(anyhow::anyhow!("NATS KV Get Error for history: {}", e)),
397 }
398 }
399}
400
401pub struct NatsNsedWorker {
415 agent: Arc<dyn NsedAgent>,
416 agent_config: AgentConfig,
418 nats: async_nats::Client,
419 js: jetstream::Context,
420 processed_kv: kv::Store,
421 scratchpad_kv: kv::Store,
422 config: WorkerConfig,
423 agent_id: String,
424 active_jobs: Arc<Mutex<HashSet<String>>>,
425 start_time: Instant,
426 status: Option<SharedAgentStatus>,
428 hook: Option<Arc<dyn WorkerHook>>,
430 user_tool_factory: Option<Arc<dyn UserToolHandlerFactory>>,
432 chat_agent: Option<Arc<dyn ChatCapable>>,
434 response_buffer: Option<Arc<buffer::ResponseBuffer>>,
436 paused: Arc<AtomicBool>,
442 model_down_until_ms: Arc<AtomicU64>,
448 model_down_strikes: Arc<AtomicU64>,
453 model_down_detector: Arc<dyn ModelDownDetector>,
457 model_availability: Option<Arc<ModelAvailability>>,
463 last_availability_probe_ms: Arc<AtomicU64>,
466 telemetry: Option<TelemetryEmitterMux>,
468 before_prompt_mw: Option<Arc<crate::middleware::pipeline::MiddlewarePipeline>>,
472 provider_response_mw: Option<Arc<crate::middleware::pipeline::MiddlewarePipeline>>,
473 completion_mw: Option<Arc<crate::middleware::pipeline::MiddlewarePipeline>>,
474 job_complete_mw: Option<Arc<crate::middleware::pipeline::MiddlewarePipeline>>,
476}
477
478impl NatsNsedWorker {
479 pub async fn new(
481 agent: impl NsedAgent + 'static,
482 agent_config: AgentConfig,
483 config: WorkerConfig,
484 telemetry: Option<TelemetryEmitterMux>,
485 ) -> Result<Self> {
486 Self::from_dyn_agent(Arc::new(agent), agent_config, config, telemetry).await
487 }
488
489 pub async fn from_dyn_agent(
498 agent: Arc<dyn NsedAgent>,
499 agent_config: AgentConfig,
500 config: WorkerConfig,
501 telemetry: Option<TelemetryEmitterMux>,
502 ) -> Result<Self> {
503 let agent_id = agent.name();
504 let nats = connect_nats(&config.nats_url, config.nats_auth.as_ref()).await?;
505 let js = jetstream::new(nats.clone());
506
507 info!("🍃 NATS Leaf Worker Connected: {}", agent_id);
508
509 let safe_id = agent_id.replace(|c: char| !c.is_alphanumeric(), "_");
510
511 let processed_bucket_name = format!("nsed_proc_{}", safe_id);
512 let processed_kv = ensure_kv_bucket(
513 &js,
514 kv::Config {
515 bucket: processed_bucket_name.clone(),
516 description: format!("Idempotency keys for Agent {}", agent_id),
517 max_age: std::time::Duration::from_secs(86400),
518 storage: jetstream::stream::StorageType::File,
519 num_replicas: 1,
520 ..Default::default()
521 },
522 )
523 .await?;
524
525 let scratchpad_bucket_name = format!("nsed_local_mem_{}", safe_id);
526 let scratchpad_ttl = if config.scratchpad_retention_secs > 0 {
527 std::time::Duration::from_secs(config.scratchpad_retention_secs)
528 } else {
529 std::time::Duration::ZERO
530 };
531
532 let scratchpad_kv = ensure_kv_bucket(
533 &js,
534 kv::Config {
535 bucket: scratchpad_bucket_name.clone(),
536 description: format!("Session-scoped scratchpad for Agent {}", agent_id),
537 history: 5,
538 max_age: scratchpad_ttl,
539 storage: jetstream::stream::StorageType::File,
540 num_replicas: 1,
541 ..Default::default()
542 },
543 )
544 .await?;
545
546 info!(
547 "🔒 Initialized Sovereign KV Stores: {} & {}",
548 processed_bucket_name, scratchpad_bucket_name
549 );
550
551 let opt_pipeline = |p: crate::middleware::pipeline::MiddlewarePipeline| {
554 if p.is_empty() {
555 None
556 } else {
557 Some(Arc::new(p))
558 }
559 };
560 let before_prompt_mw = opt_pipeline(
564 agent_config
565 .middleware
566 .build_before_prompt_pipeline()
567 .map_err(|e| anyhow::anyhow!(e))?,
568 );
569 let provider_response_mw = opt_pipeline(
570 agent_config
571 .middleware
572 .build_provider_response_pipeline()
573 .map_err(|e| anyhow::anyhow!(e))?,
574 );
575 let completion_mw = opt_pipeline(
576 agent_config
577 .middleware
578 .build_completion_pipeline()
579 .map_err(|e| anyhow::anyhow!(e))?,
580 );
581 let job_complete_mw = opt_pipeline(
582 agent_config
583 .middleware
584 .build_job_complete_pipeline()
585 .map_err(|e| anyhow::anyhow!(e))?,
586 );
587
588 Ok(Self {
589 agent,
590 agent_config,
591 nats,
592 js,
593 processed_kv,
594 scratchpad_kv,
595 config,
596 agent_id,
597 active_jobs: Arc::new(Mutex::new(HashSet::new())),
598 start_time: Instant::now(),
599 status: None,
600 hook: None,
601 user_tool_factory: None,
602 chat_agent: None,
603 response_buffer: None,
604 paused: Arc::new(AtomicBool::new(false)),
605 model_down_until_ms: Arc::new(AtomicU64::new(0)),
606 model_down_strikes: Arc::new(AtomicU64::new(0)),
607 model_down_detector: Arc::new(HeuristicModelDownDetector),
608 model_availability: None,
609 last_availability_probe_ms: Arc::new(AtomicU64::new(0)),
610 telemetry,
611 before_prompt_mw,
612 provider_response_mw,
613 completion_mw,
614 job_complete_mw,
615 })
616 }
617
618 pub fn telemetry(&self) -> Option<&TelemetryEmitterMux> {
620 self.telemetry.as_ref()
621 }
622
623 pub fn with_hook(mut self, hook: Arc<dyn WorkerHook>) -> Self {
625 self.hook = Some(hook);
626 self
627 }
628
629 pub fn with_model_down_detector(mut self, detector: Arc<dyn ModelDownDetector>) -> Self {
634 self.model_down_detector = detector;
635 self
636 }
637
638 pub fn with_model_availability(mut self, probe: Arc<ModelAvailability>) -> Self {
643 self.model_availability = Some(probe);
644 self
645 }
646
647 const AVAILABILITY_PROBE_INTERVAL_MS: u64 = 300_000; async fn maybe_probe_model_availability(&self) -> bool {
657 let Some(probe) = self.model_availability.as_ref() else {
658 return false;
659 };
660 let now = chrono::Utc::now().timestamp_millis() as u64;
661 if !availability_probe_due(
662 self.last_availability_probe_ms.load(Ordering::Relaxed),
663 now,
664 Self::AVAILABILITY_PROBE_INTERVAL_MS,
665 ) {
666 return false;
667 }
668 self.last_availability_probe_ms
669 .store(now, Ordering::Relaxed);
670 if let Err(e) = probe.refresh().await {
671 warn!(agent_id = %self.agent_id, error = %e, "model-availability probe refresh failed — keeping agent up");
673 return false;
674 }
675 if probe.is_available(
676 &self.agent_config.provider_id,
677 &self.agent_config.model_name,
678 ) == Availability::Unavailable
679 {
680 let strikes = self.model_down_strikes.fetch_add(1, Ordering::Relaxed) + 1;
681 let cooldown = escalated_cooldown_ms(strikes);
682 let until = now + cooldown;
683 self.model_down_until_ms.store(until, Ordering::Relaxed);
684 warn!(
685 agent_id = %self.agent_id,
686 model = %self.agent_config.model_name,
687 cooldown_secs = cooldown / 1000,
688 "Model absent from provider catalog — self-benching (proactive) until cooldown expires"
689 );
690 return true;
691 }
692 false
693 }
694
695 #[cfg(feature = "audit")]
704 pub fn with_signing(self, keypair: crate::crypto::AgentKeyPair) -> Self {
705 let hook = Arc::new(crate::crypto::SigningHook::new(
706 keypair,
707 self.agent_id.clone(),
708 ));
709 self.with_hook(hook)
710 }
711
712 #[cfg(feature = "audit")]
720 pub fn auto_sign(self) -> Self {
721 self.with_signing(crate::crypto::AgentKeyPair::generate())
722 }
723
724 pub fn with_user_tool_factory(mut self, factory: Arc<dyn UserToolHandlerFactory>) -> Self {
726 self.user_tool_factory = Some(factory);
727 self
728 }
729
730 pub fn user_tool_factory(&self) -> Option<Arc<dyn UserToolHandlerFactory>> {
735 self.user_tool_factory.clone()
736 }
737
738 pub fn with_chat(mut self, chat: Arc<dyn ChatCapable>) -> Self {
740 self.chat_agent = Some(chat);
741 self
742 }
743
744 pub fn with_status(mut self, _port: u16) -> Self {
750 let shared = new_shared_status(
751 self.agent_id.clone(),
752 self.agent_config.model_name.clone(),
753 self.agent_config.provider_id.clone(),
754 );
755 self.status = Some(shared);
756 self
757 }
758
759 pub fn status(&self) -> Option<&SharedAgentStatus> {
761 self.status.as_ref()
762 }
763
764 pub fn agent_config(&self) -> &AgentConfig {
766 &self.agent_config
767 }
768
769 pub fn agent_id(&self) -> &str {
771 &self.agent_id
772 }
773
774 pub fn chat_agent(&self) -> Option<&Arc<dyn ChatCapable>> {
776 self.chat_agent.as_ref()
777 }
778
779 pub fn with_response_buffer(mut self, hold_duration: std::time::Duration) -> Self {
785 self.response_buffer = Some(Arc::new(buffer::ResponseBuffer::new(hold_duration)));
786 self
787 }
788
789 pub fn response_buffer(&self) -> Option<&Arc<buffer::ResponseBuffer>> {
791 self.response_buffer.as_ref()
792 }
793
794 pub fn pause_handle(&self) -> Arc<AtomicBool> {
805 self.paused.clone()
806 }
807
808 pub fn pause(&self) {
810 self.paused.store(true, Ordering::Relaxed);
811 if let Some(ref buf) = self.response_buffer {
813 buf.pause();
814 }
815 }
816
817 pub fn resume(&self) {
819 self.paused.store(false, Ordering::Relaxed);
820 if let Some(ref buf) = self.response_buffer {
821 buf.resume();
822 }
823 }
824
825 pub fn is_paused(&self) -> bool {
827 self.paused.load(Ordering::Relaxed)
828 }
829
830 pub fn event_store(&self) -> AgentEventStore {
833 AgentEventStore::new(self.js.clone(), self.agent_id.clone())
834 }
835
836 async fn record_event(&self, event: AgentEvent) {
840 if let Err(e) = self.event_store().publish(&event).await {
841 warn!("failed to record agent event: {}", e);
842 }
843 }
844
845 fn new_event(&self, kind: AgentEventKind) -> AgentEvent {
846 AgentEvent::now(self.agent_id.clone(), kind)
847 }
848
849 pub async fn run(&self) -> Result<()> {
852 let prefix = &self.config.subject_prefix;
853 let task_filter = format!("{}.*.task.{}.*", prefix, self.agent_id);
854
855 let stream = {
857 let max_attempts = 10;
858 let mut attempt = 0;
859 loop {
860 attempt += 1;
861 match self.js.get_stream(&self.config.stream_name).await {
862 Ok(s) => break s,
863 Err(e) => {
864 if attempt >= max_attempts {
865 return Err(anyhow::anyhow!(
866 "Stream '{}' not found after {} attempts. \
867 Is the orchestrator running? Last error: {}",
868 self.config.stream_name,
869 max_attempts,
870 e
871 ));
872 }
873 info!(
874 "⏳ Waiting for stream '{}' (attempt {}/{}). \
875 Orchestrator may still be starting...",
876 self.config.stream_name, attempt, max_attempts
877 );
878 tokio::time::sleep(std::time::Duration::from_millis(500 * attempt as u64))
879 .await;
880 }
881 }
882 }
883 };
884
885 if let Err(e) = AgentEventStore::ensure_stream(&self.js).await {
888 warn!(
889 "agent event log unavailable (dashboard history disabled): {}",
890 e
891 );
892 }
893
894 let task_consumer = stream
902 .get_or_create_consumer(
903 &self.config.consumer_name,
904 jetstream::consumer::pull::Config {
905 durable_name: Some(self.config.consumer_name.clone()),
906 filter_subject: task_filter,
907 ack_wait: std::time::Duration::from_secs(30), max_ack_pending: self.config.max_ack_pending(),
911 ..Default::default()
912 },
913 )
914 .await?;
915
916 let manifest_consumer_name = format!("manifest_watcher_{}", self.agent_id);
918 let manifest_consumer = stream
919 .get_or_create_consumer(
920 &manifest_consumer_name,
921 jetstream::consumer::pull::Config {
922 durable_name: Some(manifest_consumer_name.clone()),
923 filter_subject: format!("{}.jobs.manifest.>", self.config.api_prefix),
924 deliver_policy: jetstream::consumer::DeliverPolicy::New,
925 ..Default::default()
926 },
927 )
928 .await?;
929
930 let score_subject = format!(
938 "{}.*.result.event.round_summary",
939 self.config.subject_prefix
940 );
941 let score_subscription: Option<async_nats::Subscriber> = if self.status.is_some() {
942 match self.nats.subscribe(score_subject.clone()).await {
943 Ok(sub) => Some(sub),
944 Err(e) => {
945 warn!(
946 "Failed to subscribe to score events on {}: {}. Score tracking disabled.",
947 score_subject, e
948 );
949 None
950 }
951 }
952 } else {
953 None
954 };
955
956 let job_complete_subject =
960 format!("{}.*.result.event.job_complete", self.config.subject_prefix);
961 let job_complete_subscription: Option<async_nats::Subscriber> =
962 if self.job_complete_mw.is_some() {
963 match self.nats.subscribe(job_complete_subject.clone()).await {
964 Ok(sub) => Some(sub),
965 Err(e) => {
966 warn!(
967 "Failed to subscribe to job_complete on {}: {}. \
968 on_job_complete hook disabled.",
969 job_complete_subject, e
970 );
971 None
972 }
973 }
974 } else {
975 None
976 };
977
978 let passthrough_subject = format!(
982 "{}.agent.{}.{}",
983 self.config.subject_prefix, self.agent_id, PASSTHROUGH_SUBJECT_SUFFIX
984 );
985 let passthrough_subscription: Option<async_nats::Subscriber> =
986 match self.nats.subscribe(passthrough_subject.clone()).await {
987 Ok(sub) => Some(sub),
988 Err(e) => {
989 warn!(
990 "Failed to subscribe to passthrough subject {}: {}. \
991 Passthrough mode unavailable for this agent.",
992 passthrough_subject, e
993 );
994 None
995 }
996 };
997
998 info!(
999 "🎧 Agent {} listening for tasks, manifests, score events, and passthrough requests.",
1000 self.agent_id
1001 );
1002
1003 if let Some(ref status) = self.status {
1005 let mut snap = status.write().await;
1006 snap.nats_connected = true;
1007 snap.push_event("connected", None, "NATS connected, listening for tasks");
1008 }
1009
1010 let mut task_messages = task_consumer.messages().await?;
1011 let mut manifest_messages = manifest_consumer.messages().await?;
1012 let mut score_messages = score_subscription;
1013 let mut job_complete_messages = job_complete_subscription;
1014 let mut passthrough_messages = passthrough_subscription;
1015 let mut heartbeat_interval = tokio::time::interval(std::time::Duration::from_secs(10));
1016 let mut drain_interval = tokio::time::interval(std::time::Duration::from_millis(500));
1017 let mut conn_check_interval = tokio::time::interval(std::time::Duration::from_secs(5));
1018 let mut last_conn_state = NatsState::Connected;
1019 let mut reconnects_so_far: u32 = 0;
1020
1021 loop {
1022 let is_paused = self.paused.load(Ordering::Relaxed);
1025
1026 tokio::select! {
1027 Some(msg_res) = task_messages.next(), if !is_paused => {
1028 match msg_res {
1029 Ok(msg) => {
1030 let worker = self.clone();
1031 tokio::spawn(async move {
1032 if let Err(e) = worker.handle_message(msg).await {
1033 error!("Failed to process task: {:?}", e);
1034 }
1035 });
1036 }
1037 Err(e) => {
1038 error!("Task consumer error: {:?}", e);
1039 break;
1040 }
1041 }
1042 }
1043 Some(msg_res) = manifest_messages.next() => {
1044 match msg_res {
1045 Ok(msg) => {
1046 let worker = self.clone();
1047 tokio::spawn(async move {
1048 if let Err(e) = worker.handle_manifest(msg).await {
1049 error!("Failed to process manifest: {:?}", e);
1050 }
1051 });
1052 }
1053 Err(e) => {
1054 error!("Manifest consumer error: {:?}", e);
1055 break;
1056 }
1057 }
1058 }
1059 Some(msg) = async {
1060 match &mut score_messages {
1061 Some(sub) => sub.next().await,
1062 None => std::future::pending::<Option<async_nats::Message>>().await,
1063 }
1064 } => {
1065 let worker = self.clone();
1066 tokio::spawn(async move {
1067 if let Err(e) = worker.handle_round_summary(msg).await {
1068 warn!("Failed to process round summary: {:?}", e);
1069 }
1070 });
1071 }
1072 Some(msg) = async {
1073 match &mut job_complete_messages {
1074 Some(sub) => sub.next().await,
1075 None => std::future::pending::<Option<async_nats::Message>>().await,
1076 }
1077 } => {
1078 let worker = self.clone();
1079 tokio::spawn(async move {
1080 if let Err(e) = worker.handle_job_complete(msg).await {
1081 warn!("Failed to process job_complete: {:?}", e);
1082 }
1083 });
1084 }
1085 Some(msg) = async {
1086 match &mut passthrough_messages {
1087 Some(sub) => sub.next().await,
1088 None => std::future::pending::<Option<async_nats::Message>>().await,
1089 }
1090 } => {
1091 if is_paused {
1092 if let Some(reply_subject) = msg.reply.clone() {
1094 let err = PassthroughError {
1095 error: "Agent is paused and cannot handle passthrough requests"
1096 .to_string(),
1097 };
1098 let payload = serde_json::to_vec(&err).unwrap_or_default();
1099 let _ = self.nats.publish(reply_subject, payload.into()).await;
1100 }
1101 } else {
1102 let worker = self.clone();
1103 tokio::spawn(async move {
1104 worker.handle_passthrough(msg).await;
1105 });
1106 }
1107 }
1108 _ = heartbeat_interval.tick() => {
1109 self.maybe_probe_model_availability().await;
1110 self.publish_heartbeat().await;
1111 }
1112 _ = drain_interval.tick() => {
1113 self.drain_buffer().await;
1114 }
1115 _ = conn_check_interval.tick() => {
1116 let current_state = self.nats.connection_state();
1117 if current_state != last_conn_state {
1118 if let Some(ref telemetry) = self.telemetry {
1119 use crate::telemetry::NatsConnectionState;
1120 let state: NatsConnectionState = (¤t_state).into();
1121 if matches!(
1122 state,
1123 NatsConnectionState::Reconnecting | NatsConnectionState::Connected
1124 ) && matches!(last_conn_state, NatsState::Disconnected)
1125 {
1126 reconnects_so_far += 1;
1127 }
1128 let conn_ctx = crate::telemetry::TelemetryContext::new(
1131 &self.agent_id,
1132 None,
1133 None,
1134 None,
1135 );
1136 crate::emit_event!(
1137 Some(telemetry),
1138 conn_ctx,
1139 NatsConnectionStateChanged {
1140 state,
1141 reconnects_so_far,
1142 pending_publish_depth: None,
1143 buffer_bytes: None,
1144 }
1145 );
1146 }
1147 last_conn_state = current_state;
1148 }
1149 }
1150 else => {
1151 error!("Both consumers closed. Exiting worker loop for {}", self.agent_id);
1152 break;
1153 }
1154 }
1155 }
1156
1157 warn!(
1158 "Worker loop exited for agent {}. Reconnecting...",
1159 self.agent_id
1160 );
1161 Ok(())
1162 }
1163
1164 async fn handle_manifest(&self, msg: async_nats::jetstream::Message) -> Result<()> {
1165 let manifest: JobManifest = match serde_json::from_slice(&msg.payload) {
1166 Ok(m) => m,
1167 Err(e) => {
1168 warn!("Failed to parse manifest: {}", e);
1169 let _ = msg.ack().await;
1170 return Ok(());
1171 }
1172 };
1173
1174 if manifest.agents.contains(&self.agent_id) {
1175 info!(
1176 "🔔 Agent {} selected for Job {}. Accepting.",
1177 self.agent_id, manifest.job_id
1178 );
1179 {
1180 let mut jobs = self.active_jobs.lock().unwrap();
1181 jobs.insert(manifest.job_id.clone());
1182 }
1183
1184 let ack_subject = format!(
1185 "{}.jobs.ack.{}.{}",
1186 self.config.api_prefix, manifest.job_id, self.agent_id
1187 );
1188 let mut ack_payload = serde_json::to_vec(&serde_json::json!({
1189 "agent_id": self.agent_id,
1190 "status": "Accepted",
1191 "timestamp": chrono::Utc::now().to_rfc3339()
1192 }))?;
1193
1194 if let Some(ref hook) = self.hook {
1196 hook.before_publish(&ack_subject, &mut ack_payload).await?;
1197 }
1198
1199 self.nats.publish(ack_subject, ack_payload.into()).await?;
1200
1201 let event_subject = format!(
1202 "{}.{}.result.event.agent_accepted",
1203 self.config.subject_prefix, manifest.job_id
1204 );
1205 let event_payload = serde_json::json!({
1206 "agent_id": self.agent_id,
1207 "status": "Online",
1208 "role": "Generalist"
1209 });
1210 self.nats
1211 .publish(event_subject, serde_json::to_vec(&event_payload)?.into())
1212 .await?;
1213
1214 if let Some(ref status) = self.status {
1216 let mut snap = status.write().await;
1217 snap.push_event(
1218 "agent_accepted",
1219 Some(&manifest.job_id),
1220 &format!("Accepted job manifest ({})", manifest.task_description),
1221 );
1222 }
1223 }
1224
1225 let _ = msg.ack().await;
1226 Ok(())
1227 }
1228
1229 async fn handle_round_summary(&self, msg: async_nats::Message) -> Result<()> {
1235 let summary: crate::events::RoundSummaryEvent = serde_json::from_slice(&msg.payload)
1236 .map_err(|e| {
1237 warn!("Failed to parse round_summary event: {}", e);
1238 e
1239 })?;
1240
1241 let session_id = session_id_from_subject(msg.subject.as_str(), &self.config.subject_prefix);
1243
1244 for entry in &summary.proposal_scores {
1252 if entry.agent_id == self.agent_id {
1253 if let Some(ref status) = self.status {
1254 let mut snap = status.write().await;
1255 let already_has = snap
1256 .recent_scores
1257 .iter()
1258 .any(|s| s.job_id == session_id && s.round == summary.round);
1259 if !already_has {
1260 snap.push_score(crate::status::ScoreEntry {
1261 timestamp: chrono::Utc::now().to_rfc3339(),
1262 job_id: session_id.clone(),
1263 round: summary.round,
1264 evaluator: "aggregated".into(),
1265 score: entry.aggregated_score,
1266 });
1267 }
1268 }
1269 break;
1270 }
1271 }
1272
1273 if self.completion_mw.is_some() {
1278 let winner = pick_winner(&summary.proposal_scores);
1279 let content = serde_json::json!({
1280 "round": summary.round,
1281 "proposal_scores": summary.proposal_scores,
1282 });
1283 let meta = serde_json::json!({ "winner": winner });
1284 if let Err(e) = self
1285 .run_stage_mw(
1286 &self.completion_mw,
1287 "complete",
1288 &session_id,
1289 summary.round,
1290 crate::middleware::MiddlewareStage::Completion,
1291 content,
1292 meta,
1293 )
1294 .await
1295 {
1296 warn!(agent_id = %self.agent_id, error = %e, "on_completion middleware error");
1297 }
1298 }
1299
1300 Ok(())
1301 }
1302
1303 async fn handle_job_complete(&self, msg: async_nats::Message) -> Result<()> {
1307 let event: crate::events::JobCompleteEvent =
1308 serde_json::from_slice(&msg.payload).map_err(|e| {
1309 warn!("Failed to parse job_complete event: {}", e);
1310 e
1311 })?;
1312
1313 let session_id = session_id_from_subject(msg.subject.as_str(), &self.config.subject_prefix);
1314 let (content, meta) = job_complete_payload(&event);
1315 match self
1316 .run_stage_mw(
1317 &self.job_complete_mw,
1318 "job_complete",
1319 &session_id,
1320 event.rounds_completed,
1321 crate::middleware::MiddlewareStage::JobComplete,
1322 content,
1323 meta,
1324 )
1325 .await
1326 {
1327 Ok(Some(verdict_content)) => {
1331 if let Some((subject, payload)) = crate::project_registry::advanced_notification(
1332 &verdict_content,
1333 &self.config.subject_prefix,
1334 ) {
1335 match self.nats.publish(subject.clone(), payload.into()).await {
1336 Ok(()) => {
1337 tracing::debug!(agent_id = %self.agent_id, subject = %subject, "published project_advanced")
1338 }
1339 Err(e) => {
1340 warn!(agent_id = %self.agent_id, subject = %subject, error = %e, "failed to publish project_advanced")
1341 }
1342 }
1343 }
1344 }
1345 Ok(None) => {}
1346 Err(e) => {
1347 warn!(agent_id = %self.agent_id, error = %e, "on_job_complete middleware error")
1348 }
1349 }
1350 Ok(())
1351 }
1352
1353 async fn handle_passthrough(&self, msg: async_nats::Message) {
1362 let reply_subject = match msg.reply.as_deref() {
1363 Some(s) if !s.is_empty() => s.to_string(),
1364 _ => {
1365 warn!(
1366 agent_id = %self.agent_id,
1367 "Passthrough message has no reply subject — ignoring"
1368 );
1369 return;
1370 }
1371 };
1372
1373 let request: PassthroughRequest = match serde_json::from_slice(&msg.payload) {
1374 Ok(r) => r,
1375 Err(e) => {
1376 warn!(agent_id = %self.agent_id, "Failed to parse passthrough request: {}", e);
1377 let err_payload = serde_json::to_vec(&PassthroughError {
1378 error: e.to_string(),
1379 })
1380 .unwrap_or_default();
1381 let _ = self.nats.publish(reply_subject, err_payload.into()).await;
1382 return;
1383 }
1384 };
1385
1386 info!(
1387 agent_id = %self.agent_id,
1388 session_id = %request.session_id,
1389 "Handling passthrough request"
1390 );
1391
1392 let chat_agent = match &self.chat_agent {
1393 Some(a) => a.clone(),
1394 None => {
1395 let err = PassthroughError {
1396 error: format!(
1397 "Agent '{}' does not support passthrough mode (ChatCapable not configured)",
1398 self.agent_id
1399 ),
1400 };
1401 let payload = serde_json::to_vec(&err).unwrap_or_default();
1402 let _ = self.nats.publish(reply_subject, payload.into()).await;
1403 return;
1404 }
1405 };
1406
1407 let messages: Vec<async_openai::types::ChatCompletionRequestMessage> = request
1409 .messages
1410 .into_iter()
1411 .filter_map(|m| {
1412 match m.role.as_str() {
1413 "user" => Some(async_openai::types::ChatCompletionRequestMessage::User(
1414 async_openai::types::ChatCompletionRequestUserMessage {
1415 content:
1416 async_openai::types::ChatCompletionRequestUserMessageContent::Text(
1417 m.content,
1418 ),
1419 name: None,
1420 },
1421 )),
1422 "assistant" => Some(
1423 async_openai::types::ChatCompletionRequestMessage::Assistant(
1424 async_openai::types::ChatCompletionRequestAssistantMessage {
1425 content: Some(
1426 async_openai::types::ChatCompletionRequestAssistantMessageContent::Text(m.content),
1427 ),
1428 ..Default::default()
1429 },
1430 ),
1431 ),
1432 "system" => Some(async_openai::types::ChatCompletionRequestMessage::System(
1433 async_openai::types::ChatCompletionRequestSystemMessage {
1434 content:
1435 async_openai::types::ChatCompletionRequestSystemMessageContent::Text(
1436 m.content,
1437 ),
1438 name: None,
1439 },
1440 )),
1441 other => {
1442 warn!(
1443 agent_id = %self.agent_id,
1444 "Unknown role '{}' in passthrough message — skipping", other
1445 );
1446 None
1447 }
1448 }
1449 })
1450 .collect();
1451
1452 if messages.is_empty() {
1453 warn!(
1454 agent_id = %self.agent_id,
1455 "Passthrough request contained no valid messages (all roles unknown) — rejecting"
1456 );
1457 let err = PassthroughError {
1458 error: "No valid messages provided for passthrough (all message roles unknown)"
1459 .to_string(),
1460 };
1461 let payload = serde_json::to_vec(&err).unwrap_or_default();
1462 let _ = self.nats.publish(reply_subject, payload.into()).await;
1463 return;
1464 }
1465
1466 const PASSTHROUGH_TIMEOUT_CAP_SECS: u64 = 600;
1479 let configured = self.agent_config.response_sla_secs;
1480 let passthrough_timeout = std::time::Duration::from_secs(if configured == 0 {
1481 PASSTHROUGH_TIMEOUT_CAP_SECS
1482 } else {
1483 configured.min(PASSTHROUGH_TIMEOUT_CAP_SECS)
1484 });
1485 let chat_result =
1486 tokio::time::timeout(passthrough_timeout, chat_agent.chat(messages)).await;
1487
1488 let response_content = match chat_result {
1489 Ok(Ok(content)) => content,
1490 Ok(Err(e)) => {
1491 warn!(agent_id = %self.agent_id, "Passthrough chat failed: {}", e);
1492 let err = PassthroughError {
1493 error: e.to_string(),
1494 };
1495 let payload = serde_json::to_vec(&err).unwrap_or_default();
1496 let _ = self.nats.publish(reply_subject, payload.into()).await;
1497 return;
1498 }
1499 Err(_) => {
1500 warn!(
1501 agent_id = %self.agent_id,
1502 timeout_secs = passthrough_timeout.as_secs(),
1503 "Passthrough chat timed out"
1504 );
1505 let err = PassthroughError {
1506 error: format!(
1507 "Passthrough request timed out after {}s",
1508 passthrough_timeout.as_secs()
1509 ),
1510 };
1511 let payload = serde_json::to_vec(&err).unwrap_or_default();
1512 let _ = self.nats.publish(reply_subject, payload.into()).await;
1513 return;
1514 }
1515 };
1516
1517 let response = PassthroughResponse {
1518 content: response_content,
1519 input_tokens: None,
1520 output_tokens: None,
1521 };
1522 let payload = match serde_json::to_vec(&response) {
1523 Ok(b) => b,
1524 Err(e) => {
1525 error!(agent_id = %self.agent_id, "Failed to serialize passthrough response: {}", e);
1526 let err = PassthroughError {
1527 error: format!("Failed to serialize response: {e}"),
1528 };
1529 let err_payload = serde_json::to_vec(&err).unwrap_or_default();
1530 let _ = self.nats.publish(reply_subject, err_payload.into()).await;
1531 return;
1532 }
1533 };
1534 let _ = self.nats.publish(reply_subject, payload.into()).await;
1535 }
1536
1537 #[allow(clippy::too_many_arguments)] async fn run_stage_mw(
1542 &self,
1543 pipeline: &Option<Arc<crate::middleware::pipeline::MiddlewarePipeline>>,
1544 action: &str,
1545 session_id: &str,
1546 round: u32,
1547 stage: crate::middleware::MiddlewareStage,
1548 content: serde_json::Value,
1549 metadata: serde_json::Value,
1550 ) -> Result<Option<serde_json::Value>> {
1551 match pipeline {
1552 Some(p) => run_stage_pipeline(
1553 p,
1554 &self.agent_id,
1555 action,
1556 session_id,
1557 round,
1558 stage,
1559 content,
1560 metadata,
1561 )
1562 .await
1563 .map(Some),
1564 None => Ok(None),
1565 }
1566 }
1567
1568 async fn handle_message(&self, msg: async_nats::jetstream::Message) -> Result<()> {
1569 let msg = std::sync::Arc::new(msg);
1572
1573 let msg_id = match msg.info() {
1575 Ok(info) => format!("{}-{}-{}", info.stream, info.stream_sequence, msg.subject),
1576 Err(_) => msg
1577 .headers
1578 .as_ref()
1579 .and_then(|h| h.get("Nats-Msg-Id").map(|v| v.to_string()))
1580 .unwrap_or_else(|| Uuid::new_v4().to_string()),
1581 };
1582
1583 if self.is_duplicate(&msg_id).await? {
1584 warn!(
1585 "♻️ Detected duplicate message {}. Acking and skipping.",
1586 msg_id
1587 );
1588 msg.ack()
1589 .await
1590 .map_err(|e| anyhow::anyhow!(e))
1591 .context("Failed to ack duplicate")?;
1592 return Ok(());
1593 }
1594
1595 info!("📨 Received Task: {} (Subject: {})", msg_id, msg.subject);
1596
1597 let task_received = Instant::now();
1599
1600 let mut context: AgentContext = match serde_json::from_slice(&msg.payload) {
1601 Ok(ctx) => ctx,
1602 Err(e) => {
1603 error!(
1604 msg_id = %msg_id,
1605 error = %e,
1606 "❌ Failed to deserialize AgentContext. Poison pill detected. Acking to discard."
1607 );
1608 if let Err(ack_err) = msg.ack().await {
1609 error!("Failed to ack poison pill: {}", ack_err);
1610 }
1611 return Ok(());
1612 }
1613 };
1614
1615 let subject_parts: Vec<&str> = msg.subject.split('.').collect();
1616 let prefix = &self.config.subject_prefix;
1617 let prefix_count = if prefix.is_empty() {
1618 0
1619 } else {
1620 prefix.split('.').count()
1621 };
1622 let session_id = subject_parts
1623 .get(prefix_count)
1624 .unwrap_or(&"global")
1625 .to_string();
1626 let action: String = subject_parts.last().unwrap_or(&"unknown").to_string();
1628 let action = action.as_str();
1629
1630 let work_start_subject = format!(
1632 "{}.{}.result.event.agent_working",
1633 self.config.subject_prefix, session_id
1634 );
1635 let work_start_payload = serde_json::json!({
1636 "agent_id": self.agent_id,
1637 "round": context.round_number,
1638 "action": action,
1639 "status": "Thinking"
1640 });
1641 if let Err(e) = self
1642 .nats
1643 .publish(
1644 work_start_subject,
1645 serde_json::to_vec(&work_start_payload)?.into(),
1646 )
1647 .await
1648 {
1649 warn!("Failed to publish work start event: {}", e);
1650 }
1651
1652 if let Some(ref status) = self.status {
1654 let mut snap = status.write().await;
1655 snap.push_event(
1656 "agent_working",
1657 Some(&session_id),
1658 &format!("Round {} {}", context.round_number, action),
1659 );
1660 }
1661
1662 context.store = Some(Arc::new(NatsScratchpadStore::new(
1664 self.scratchpad_kv.clone(),
1665 self.js.clone(),
1666 session_id.clone(),
1667 )) as Arc<dyn PersistenceStore>);
1668
1669 context.telemetry = self.telemetry.clone();
1670 context.event_store = Some(self.event_store());
1671
1672 let user_tool_names: Vec<&str> =
1674 context.user_tools.iter().map(|t| t.name.as_str()).collect();
1675 tracing::info!(
1676 agent = %self.agent.name(),
1677 user_tool_count = context.user_tools.len(),
1678 user_tools = ?user_tool_names,
1679 has_factory = self.user_tool_factory.is_some(),
1680 "user-tool wiring: names arriving in AgentContext (empty ⇒ ask_user won't be advertised)"
1681 );
1682 if !context.user_tools.is_empty() {
1683 if let Some(ref factory) = self.user_tool_factory {
1684 context.user_tool_handler = Some(factory.create(
1685 self.nats.clone(),
1686 self.js.clone(),
1687 session_id.clone(),
1688 self.agent.name(),
1689 context.phase_budget_remaining_secs,
1690 self.config.subject_prefix.clone(),
1691 ));
1692 }
1693 }
1694
1695 {
1697 let mut jobs = self.active_jobs.lock().unwrap();
1698 jobs.insert(session_id.clone());
1699 }
1700
1701 if let Some(ref status) = self.status {
1703 let mut snap = status.write().await;
1704 snap.current_job = Some(session_id.clone());
1705 snap.current_round = Some(context.round_number);
1706 snap.current_phase = Some(action.to_string());
1707 }
1708 {
1709 let mut event = self.new_event(AgentEventKind::TaskStarted);
1710 event.job_id = Some(session_id.clone());
1711 event.round = Some(context.round_number);
1712 event.phase = Some(action.to_string());
1713 event.detail = format!("Round {} {}", context.round_number, action);
1714 self.record_event(event).await;
1715 }
1716
1717 if action == "propose" && context.round_number > 1 {
1720 if let Some(score) = context.previous_own_score {
1721 if let Some(ref status) = self.status {
1722 let mut snap = status.write().await;
1723 let prev_round = context.round_number.saturating_sub(1);
1724 let already_has = snap
1725 .recent_scores
1726 .iter()
1727 .any(|s| s.job_id == session_id && s.round == prev_round);
1728 if !already_has {
1729 snap.push_score(crate::status::ScoreEntry {
1730 timestamp: chrono::Utc::now().to_rfc3339(),
1731 job_id: session_id.clone(),
1732 round: prev_round,
1733 evaluator: "aggregated".to_string(),
1734 score,
1735 });
1736 }
1737 }
1738 }
1739 }
1740
1741 let dispatch_delay_ms = task_received.elapsed().as_millis() as u64;
1742 let task_publish_ts = context.task_publish_ts;
1745 let agent_receive_ts = chrono::Utc::now().timestamp_millis();
1746 let job_age_at_accept_ms = task_publish_ts.map(|publish_ts| {
1747 agent_receive_ts
1748 .checked_sub(publish_ts)
1749 .map_or(0, |diff| diff.max(0))
1750 });
1751 crate::emit_for!(
1752 context,
1753 TaskAccepted {
1754 dispatch_delay_ms,
1755 task_publish_ts,
1756 job_age_at_accept_ms,
1757 }
1758 );
1759
1760 let msg_heartbeat = msg.clone();
1765 let hb_session = session_id.clone();
1766 let heartbeat_handle = tokio::spawn(async move {
1767 let mut interval = tokio::time::interval(std::time::Duration::from_secs(15));
1768 interval.tick().await; loop {
1770 interval.tick().await;
1771 if let Err(e) = msg_heartbeat
1772 .ack_with(async_nats::jetstream::AckKind::Progress)
1773 .await
1774 {
1775 tracing::warn!(
1776 session_id = %hb_session,
1777 "Failed to send task ack heartbeat: {}", e
1778 );
1779 break;
1780 }
1781 }
1782 });
1783
1784 if self.before_prompt_mw.is_some() {
1788 let content = serde_json::json!({
1789 "task_description": context.task_description,
1790 "user_injections": context.user_injections,
1791 });
1792 let meta = match &context.conversation_id {
1798 Some(cid) => serde_json::json!({ "conversation_id": cid }),
1799 None => serde_json::json!({}),
1800 };
1801 if let Some(new) = self
1802 .run_stage_mw(
1803 &self.before_prompt_mw,
1804 action,
1805 &session_id,
1806 context.round_number,
1807 crate::middleware::MiddlewareStage::BeforePrompt,
1808 content,
1809 meta,
1810 )
1811 .await?
1812 {
1813 if let Some(td) = new.get("task_description").and_then(|v| v.as_str()) {
1814 context.task_description = td.to_string();
1815 } else {
1816 tracing::debug!(
1817 agent_id = %self.agent_id,
1818 "before_prompt middleware returned no string `task_description` — transform dropped"
1819 );
1820 }
1821 if let Some(schema) = new.get("proposal_schema") {
1824 if schema.is_object() {
1825 context.forced_proposal_schema = Some(schema.clone());
1826 }
1827 }
1828 if let Some(wt) = new.get("agent_working_dir").and_then(|v| v.as_str()) {
1833 context.working_dir_override = Some(std::path::PathBuf::from(wt));
1834 }
1835 if let Some(adv) = crate::project_registry::ProjectAdvertisement::from_verdict(
1840 &new,
1841 &self.agent_id,
1842 std::env::var("HOSTNAME").ok().filter(|h| !h.is_empty()),
1843 ) {
1844 let subject =
1845 crate::project_registry::advert_subject(&self.config.subject_prefix);
1846 match serde_json::to_vec(&adv) {
1847 Ok(payload) => {
1848 if let Err(e) = self.nats.publish(subject.clone(), payload.into()).await
1849 {
1850 tracing::warn!(agent_id = %self.agent_id, subject = %subject, error = %e, "failed to publish project advertisement");
1851 }
1852 }
1853 Err(e) => {
1854 tracing::warn!(agent_id = %self.agent_id, error = %e, "failed to serialize project advertisement")
1855 }
1856 }
1857 }
1858 }
1859 }
1860
1861 context.submission_validator = build_submission_validator(
1866 action,
1867 &self.provider_response_mw,
1868 &self.agent_id,
1869 &session_id,
1870 context.round_number,
1871 );
1872
1873 let task_start = Instant::now();
1874 let execution_result = {
1879 const MAX_TASK_RETRIES: u32 = 2;
1880 let mut last_err = None;
1881 let mut attempt = 0u32;
1882 loop {
1883 attempt += 1;
1884 let result = async {
1885 match action {
1886 "propose" => {
1887 let mut proposal = self.agent.propose(&context).await?;
1893 if self.provider_response_mw.is_some() {
1894 if let Some(new) = self
1895 .run_stage_mw(
1896 &self.provider_response_mw,
1897 "propose",
1898 &session_id,
1899 context.round_number,
1900 crate::middleware::MiddlewareStage::ProviderResponse,
1901 serde_json::json!(proposal.content),
1902 serde_json::json!({}),
1903 )
1904 .await?
1905 {
1906 if let Some(c) = new.as_str() {
1907 proposal.content = c.to_string();
1908 }
1909 }
1910 }
1911 proposal.published_at_ms = chrono::Utc::now().timestamp_millis();
1912 serde_json::to_vec(&proposal).map_err(|e| anyhow::anyhow!(e))
1913 }
1914 "evaluate" => {
1915 let evaluations = self.agent.evaluate(&context).await?;
1916 let valid_ids: std::collections::HashSet<&str> =
1919 context.candidates.iter().map(|c| c.id.as_str()).collect();
1920 let publish_ts = chrono::Utc::now().timestamp_millis();
1921 let filtered: Vec<_> = evaluations
1922 .into_iter()
1923 .filter(|(target_id, _)| {
1924 if valid_ids.contains(target_id.as_str()) {
1925 true
1926 } else {
1927 tracing::warn!(
1928 target_id = %target_id,
1929 "Dropping evaluation with hallucinated target ID"
1930 );
1931 false
1932 }
1933 })
1934 .map(|(target_id, mut eval)| {
1935 eval.published_at_ms = publish_ts;
1936 (target_id, eval)
1937 })
1938 .collect();
1939 serde_json::to_vec(&filtered).map_err(|e| anyhow::anyhow!(e))
1940 }
1941 _ => Err(anyhow::anyhow!("Unknown action: {}", action)),
1942 }
1943 }
1944 .await;
1945
1946 match result {
1947 Ok(payload) => break Ok(payload),
1948 Err(e) => {
1949 let is_retryable = is_transient_error(&e);
1950 if is_retryable && attempt <= MAX_TASK_RETRIES {
1951 warn!(
1952 agent = %self.agent_id,
1953 attempt,
1954 max = MAX_TASK_RETRIES + 1,
1955 error = %e,
1956 "Transient task error, retrying after backoff"
1957 );
1958 tokio::time::sleep(std::time::Duration::from_secs(
1959 2u64.pow(attempt - 1),
1960 ))
1961 .await;
1962 last_err = Some(e);
1963 continue;
1964 }
1965 let _ = last_err; break Err(e);
1967 }
1968 }
1969 }
1970 };
1971 let task_duration_ms = task_start.elapsed().as_millis() as u64;
1972
1973 heartbeat_handle.abort();
1975
1976 match execution_result {
1977 Ok(mut response_payload) => {
1978 let content_preview =
1981 extract_content_preview(&response_payload, action, &context.candidates);
1982
1983 let reply_subject = format!(
1984 "{}.{}.result.{}.{}.{}",
1985 self.config.subject_prefix,
1986 session_id,
1987 context.round_number,
1988 self.agent_id,
1989 action
1990 );
1991
1992 let was_buffered = if let Some(ref buf) = self.response_buffer {
2011 self.mark_processed(&msg_id).await?;
2013 msg.ack()
2014 .await
2015 .map_err(|e| anyhow::anyhow!("buffer pre-ack failed: {}", e))?;
2016
2017 let hold = buf.hold_duration();
2018 let now = Instant::now();
2019 let entry = buffer::BufferedResponse {
2020 id: Uuid::new_v4().to_string(),
2021 action: action.to_string(),
2022 job_id: session_id.clone(),
2023 round: context.round_number,
2024 reply_subject,
2025 payload: response_payload,
2026 created_at: now,
2027 release_at: now + hold, ack_handle: Box::new(buffer::PreAckedHandle),
2029 msg_id: msg_id.clone(),
2030 annotations: Vec::new(),
2031 edited: false,
2032 stopped: self.agent_config.auto_stop,
2033 };
2034 buf.push_with_deadline(entry, task_received).await;
2035 info!(
2036 "📦 Buffered response: {} (SLA-based release, pre-acked)",
2037 msg_id
2038 );
2039
2040 if let Some(ref status) = self.status {
2042 let mut snap = status.write().await;
2043 snap.current_job = None;
2044 snap.current_round = None;
2045 snap.current_phase = None;
2046 snap.buffered_count = buf.len().await as u32;
2047 snap.push_event(
2048 "response_buffered",
2049 Some(&session_id),
2050 &format!(
2051 "Round {} {} buffered {}ms hold",
2052 context.round_number,
2053 action,
2054 hold.as_millis()
2055 ),
2056 );
2057 }
2058 true
2059 } else {
2060 if let Some(ref hook) = self.hook {
2062 hook.before_publish(&reply_subject, &mut response_payload)
2063 .await?;
2064 }
2065 self.nats
2066 .publish(reply_subject, response_payload.into())
2067 .await?;
2068 self.mark_processed(&msg_id).await?;
2069 msg.ack().await.map_err(|e| anyhow::anyhow!(e))?;
2070 info!("✅ Task Complete: {}", msg_id);
2071 false
2072 };
2073
2074 {
2076 let mut jobs = self.active_jobs.lock().unwrap();
2077 jobs.remove(&session_id);
2078 }
2079
2080 if let Some(ref status) = self.status {
2085 let mut snap = status.write().await;
2086 snap.current_job = None;
2087 snap.current_round = None;
2088 snap.current_phase = None;
2089 snap.push_task(TaskLogEntry {
2090 timestamp: chrono::Utc::now().to_rfc3339(),
2091 action: action.to_string(),
2092 job_id: session_id.clone(),
2093 round: context.round_number,
2094 status: "ok".into(),
2095 duration_ms: task_duration_ms,
2096 content_preview: content_preview.clone(),
2097 });
2098 if !was_buffered {
2099 snap.push_event(
2100 "task_complete",
2101 Some(&session_id),
2102 &format!("{} ok {}ms", action, task_duration_ms),
2103 );
2104 }
2105 }
2106 {
2107 let mut event = self.new_event(AgentEventKind::TaskCompleted);
2108 event.job_id = Some(session_id.clone());
2109 event.round = Some(context.round_number);
2110 event.phase = Some(action.to_string());
2111 event.status = Some("ok".to_string());
2112 event.detail = format!("{} ok {}ms", action, task_duration_ms);
2113 self.record_event(event).await;
2114 }
2115
2116 self.model_down_strikes.store(0, Ordering::Relaxed);
2120
2121 let phase_budget_remaining_ms =
2124 (context.phase_budget_remaining_secs * 1000.0) as i64;
2125 crate::emit_for!(
2126 context,
2127 TaskCompleted {
2128 duration_ms: task_duration_ms,
2129 dispatch_delay_ms,
2130 queue_wait_ms: None,
2131 phase_budget_remaining_ms,
2132 llm_attempts: None,
2133 tool_call_count: None,
2134 pending_publish_depth: None,
2135 }
2136 );
2137 }
2138 Err(e) => {
2139 let err_str = e.to_string();
2140 error!("❌ Task Execution Failed: {:?}", e);
2141
2142 let is_payment_error = err_str.contains("402 Payment Required")
2145 || err_str.contains("insufficient_quota")
2146 || err_str.contains("billing");
2147
2148 {
2150 let mut jobs = self.active_jobs.lock().unwrap();
2151 jobs.remove(&session_id);
2152 }
2153
2154 let suppress_error_event =
2158 is_payment_error && !self.agent_config.propagate_payment_error;
2159
2160 if suppress_error_event {
2161 warn!(
2162 "Payment error detected (propagate_payment_error=false) — pausing worker to avoid further API calls"
2163 );
2164 self.paused.store(true, Ordering::Relaxed);
2165 if let Some(ref buf) = self.response_buffer {
2166 buf.pause();
2167 }
2168 }
2169
2170 if self.model_down_detector.is_model_down(&err_str) {
2175 let strikes = self.model_down_strikes.fetch_add(1, Ordering::Relaxed) + 1;
2176 let cooldown = escalated_cooldown_ms(strikes);
2177 let until = chrono::Utc::now().timestamp_millis() as u64 + cooldown;
2178 self.model_down_until_ms.store(until, Ordering::Relaxed);
2179 warn!(
2180 agent_id = %self.agent_id,
2181 strikes,
2182 cooldown_secs = cooldown / 1000,
2183 "Remote model unavailable — reporting model_down until cooldown expires (escalating backoff)"
2184 );
2185 }
2186
2187 if !suppress_error_event {
2188 let reason = classify_abstention_reason(&err_str);
2196 let error_payload = serde_json::json!({
2197 "agent_id": self.agent_id,
2198 "round": context.round_number,
2199 "action": action,
2200 "error": err_str,
2201 "reason": reason,
2202 "status": "Failed"
2203 });
2204 let error_bytes = serde_json::to_vec(&error_payload)?;
2205
2206 let legacy_subject = format!(
2215 "{}.{}.result.event.agent_error",
2216 self.config.subject_prefix, session_id
2217 );
2218 if let Err(pub_err) = self
2219 .nats
2220 .publish(legacy_subject, error_bytes.clone().into())
2221 .await
2222 {
2223 warn!("Failed to publish legacy agent_error event: {}", pub_err);
2224 }
2225
2226 if should_publish_failure_marker(action, is_payment_error) {
2234 let failed_subject = failed_result_subject(
2235 &self.config.subject_prefix,
2236 &session_id,
2237 context.round_number,
2238 &self.agent_id,
2239 action,
2240 );
2241 if let Err(pub_err) =
2242 self.nats.publish(failed_subject, error_bytes.into()).await
2243 {
2244 warn!("Failed to publish round-scoped .failed marker: {}", pub_err);
2245 }
2246 }
2247 }
2248
2249 msg.ack().await.map_err(|e| anyhow::anyhow!(e))?;
2250
2251 if let Some(ref status) = self.status {
2253 let mut snap = status.write().await;
2254 snap.current_job = None;
2255 snap.current_round = None;
2256 snap.current_phase = None;
2257 snap.push_task(TaskLogEntry {
2258 timestamp: chrono::Utc::now().to_rfc3339(),
2259 action: action.to_string(),
2260 job_id: session_id.clone(),
2261 round: context.round_number,
2262 status: "error".into(),
2263 duration_ms: task_duration_ms,
2264 content_preview: Some(format!("Error: {}", err_str)),
2265 });
2266 snap.push_event(
2267 "agent_error",
2268 Some(&session_id),
2269 &format!("{} failed: {}", action, err_str),
2270 );
2271 }
2272 {
2273 let detail = format!("{} failed: {}", action, err_str);
2274 let mut failed = self.new_event(AgentEventKind::TaskFailed);
2275 failed.job_id = Some(session_id.clone());
2276 failed.round = Some(context.round_number);
2277 failed.phase = Some(action.to_string());
2278 failed.status = Some("error".to_string());
2279 failed.detail = detail.clone();
2280 self.record_event(failed).await;
2281
2282 let mut errored = self.new_event(AgentEventKind::AgentError);
2283 errored.job_id = Some(session_id.clone());
2284 errored.round = Some(context.round_number);
2285 errored.detail = detail;
2286 self.record_event(errored).await;
2287 }
2288
2289 let phase_budget_remaining_ms =
2290 (context.phase_budget_remaining_secs * 1000.0) as i64;
2291 let failure_class = if is_payment_error {
2294 TaskFailureClass::ToolError
2295 } else {
2296 TaskFailureClass::Timeout
2297 };
2298 crate::emit_for!(
2299 context,
2300 TaskFailed {
2301 duration_ms: task_duration_ms,
2302 dispatch_delay_ms,
2303 queue_wait_ms: None,
2304 phase_budget_remaining_ms,
2305 llm_attempts: None,
2306 tool_call_count: None,
2307 failure_class,
2308 pending_publish_depth: None,
2309 }
2310 );
2311 }
2312 }
2313 Ok(())
2314 }
2315
2316 async fn publish_heartbeat(&self) {
2318 let active_job = {
2319 let jobs = self.active_jobs.lock().unwrap();
2320 jobs.iter().next().cloned()
2321 };
2322 let hb_status = if active_job.is_some() {
2323 AgentLiveStatus::Busy
2324 } else {
2325 AgentLiveStatus::Idle
2326 };
2327 let uptime = self.start_time.elapsed().as_secs();
2328 let (tasks_completed, tasks_failed, last_error) = if let Some(ref status) = self.status {
2330 let snap = status.read().await;
2331 let err = snap
2332 .recent_tasks
2333 .iter()
2334 .find(|t| t.status == "error")
2335 .map(|t| {
2336 let msg = format!("{}: {}", t.action, t.job_id);
2337 msg.chars().take(120).collect::<String>()
2338 });
2339 (snap.tasks_completed, snap.tasks_failed, err)
2340 } else {
2341 (0, 0, None)
2342 };
2343
2344 let model_down = model_down_active(
2345 self.model_down_until_ms.load(Ordering::Relaxed),
2346 chrono::Utc::now().timestamp_millis() as u64,
2347 );
2348 let health =
2349 crate::agents::compute_agent_health(model_down, self.paused.load(Ordering::Relaxed));
2350 let heartbeat = AgentHeartbeat {
2351 agent_id: self.agent_id.clone(),
2352 status: hb_status,
2353 model_name: self.agent_config.model_name.clone(),
2354 provider_id: self.agent_config.provider_id.clone(),
2355 current_job: active_job.clone(),
2356 uptime_secs: uptime,
2357 timestamp: chrono::Utc::now().to_rfc3339(),
2358 input_price_per_mtok: self.agent_config.input_price_per_mtok,
2359 output_price_per_mtok: self.agent_config.output_price_per_mtok,
2360 chars_per_token: self.agent_config.chars_per_token,
2361 response_sla_secs: if self.agent_config.response_sla_secs > 0 {
2362 Some(self.agent_config.response_sla_secs)
2363 } else {
2364 None
2365 },
2366 temperature: Some(self.agent_config.temperature),
2367 frequency_penalty: self.agent_config.frequency_penalty,
2368 presence_penalty: self.agent_config.presence_penalty,
2369 max_tokens: Some(self.agent_config.max_tokens),
2370 context_window: Some(self.agent_config.context_window),
2371 tasks_completed,
2372 tasks_failed,
2373 last_error,
2374 capability_tags: self.agent_config.capability_tags.clone(),
2375 description: self.agent_config.description.clone(),
2376 signing_schemes: self.agent_config.signing_schemes.clone(),
2377 model_down,
2378 health,
2379 };
2380 let subject = format!(
2381 "{}.agent.heartbeat.{}",
2382 self.config.api_prefix, self.agent_id
2383 );
2384 match serde_json::to_vec(&heartbeat) {
2385 Ok(payload) => {
2386 if let Err(e) = self.nats.publish(subject, payload.into()).await {
2387 warn!("Failed to publish heartbeat: {}", e);
2388 }
2389 }
2390 Err(e) => {
2391 warn!(agent_id = %self.agent_id, "Failed to serialize heartbeat: {}", e);
2392 }
2393 }
2394
2395 if let Some(ref status) = self.status {
2397 let mut snap = status.write().await;
2398 snap.uptime_secs = uptime;
2399 snap.nats_connected = true;
2400 snap.current_job = active_job.clone();
2401 snap.push_event(
2402 "heartbeat",
2403 active_job.as_deref(),
2404 &format!(
2405 "{} uptime {}s",
2406 if active_job.is_some() { "busy" } else { "idle" },
2407 uptime
2408 ),
2409 );
2410 }
2411 }
2412
2413 async fn drain_buffer(&self) {
2419 let Some(ref buf) = self.response_buffer else {
2420 return;
2421 };
2422
2423 let divergence = if let Some(ref status) = self.status {
2428 let snap = status.read().await;
2429 let new_hold =
2430 buffer::compute_adaptive_hold(buf.base_hold_duration(), snap.mean_score, 3.0);
2431 buf.set_hold_duration(new_hold);
2432 buffer::compute_divergence(snap.mean_score, snap.score_std_dev)
2433 } else {
2434 None
2435 };
2436 let auto_released = buf.auto_release_if_eligible(divergence).await;
2437 if auto_released > 0 {
2438 let divergence_str =
2444 divergence.map_or_else(|| "n/a".to_string(), |d| format!("{d:.2}"));
2445 info!(
2446 "⚡ Auto-approved {} buffered entries for {} (divergence: {}, threshold: {:.2})",
2447 auto_released,
2448 self.agent_id,
2449 divergence_str,
2450 buf.auto_approve_threshold(),
2451 );
2452 }
2453
2454 let ready = buf.drain_ready().await;
2455 for mut entry in ready {
2456 let publish_payload = if !entry.annotations.is_empty() || entry.edited {
2458 Self::inject_annotations(&entry)
2459 } else {
2460 entry.payload.clone()
2461 };
2462
2463 let mut publish_payload =
2467 Self::restamp_published_at(&publish_payload, chrono::Utc::now().timestamp_millis());
2468
2469 if let Some(ref hook) = self.hook
2474 && let Err(e) = hook
2475 .before_publish(&entry.reply_subject, &mut publish_payload)
2476 .await
2477 {
2478 error!(
2479 "Failed to prepare buffered response {} for publish: {} — re-enqueuing",
2480 entry.id, e
2481 );
2482 entry.release_at = Instant::now() + std::time::Duration::from_secs(5);
2483 buf.push(entry).await;
2484 continue;
2485 }
2486
2487 if let Err(e) = self
2488 .nats
2489 .publish(entry.reply_subject.clone(), publish_payload.into())
2490 .await
2491 {
2492 error!(
2493 "Failed to publish buffered response {}: {} — re-enqueuing",
2494 entry.id, e
2495 );
2496 entry.release_at = Instant::now() + std::time::Duration::from_secs(5);
2501 buf.push(entry).await;
2502 continue;
2503 }
2504
2505 if !entry.annotations.is_empty() {
2507 let annotation_subject = format!(
2508 "{}.{}.annotations.{}.{}",
2509 self.config.subject_prefix,
2510 entry.job_id,
2511 entry.round,
2512 entry.id.get(..8).unwrap_or(&entry.id)
2513 );
2514 let annotation_payload = serde_json::json!({
2515 "entry_id": entry.id,
2516 "action": entry.action,
2517 "job_id": entry.job_id,
2518 "round": entry.round,
2519 "edited": entry.edited,
2520 "annotations": entry.annotations,
2521 });
2522 if let Err(e) = self
2523 .nats
2524 .publish(
2525 annotation_subject,
2526 serde_json::to_vec(&annotation_payload)
2527 .unwrap_or_default()
2528 .into(),
2529 )
2530 .await
2531 {
2532 warn!("Failed to publish annotation audit trail: {}", e);
2533 }
2534 }
2535
2536 if let Err(e) = self.mark_processed(&entry.msg_id).await {
2537 warn!("Failed to mark buffered response processed: {}", e);
2538 }
2539 if let Err(e) = entry.ack_handle.ack().await {
2540 warn!("Failed to ack buffered message: {}", e);
2541 }
2542
2543 let edit_marker = if entry.edited { " [EDITED]" } else { "" };
2544 let annotation_count = entry.annotations.len();
2545 info!(
2546 "✅ Buffer released: {} ({} r{}){} ({} annotation(s))",
2547 entry.id, entry.action, entry.round, edit_marker, annotation_count
2548 );
2549
2550 if let Some(ref status) = self.status {
2552 let mut snap = status.write().await;
2553 snap.buffered_count = buf.len().await as u32;
2554 let detail = if entry.edited {
2555 format!(
2556 "Round {} {} released from buffer (operator-edited)",
2557 entry.round, entry.action
2558 )
2559 } else if annotation_count > 0 {
2560 format!(
2561 "Round {} {} released from buffer ({} annotation(s))",
2562 entry.round, entry.action, annotation_count
2563 )
2564 } else {
2565 format!(
2566 "Round {} {} released from buffer",
2567 entry.round, entry.action
2568 )
2569 };
2570 snap.push_event("buffer_released", Some(&entry.job_id), &detail);
2571
2572 snap.push_event(
2575 "task_complete",
2576 Some(&entry.job_id),
2577 &format!("Round {} {} released", entry.round, entry.action),
2578 );
2579 }
2580 }
2581 }
2582
2583 fn restamp_published_at(bytes: &[u8], now_ms: i64) -> Vec<u8> {
2603 let Ok(mut value) = serde_json::from_slice::<serde_json::Value>(bytes) else {
2604 return bytes.to_vec();
2605 };
2606 let stamp = serde_json::Value::from(now_ms);
2607 if let Some(obj) = value.as_object_mut() {
2608 obj.insert("published_at_ms".to_string(), stamp);
2609 } else if let Some(arr) = value.as_array_mut() {
2610 for item in arr.iter_mut() {
2611 if let Some(pair) = item.as_array_mut()
2612 && pair.len() == 2
2613 && let Some(eval_obj) = pair[1].as_object_mut()
2614 {
2615 eval_obj.insert("published_at_ms".to_string(), stamp.clone());
2616 }
2617 }
2618 }
2619 serde_json::to_vec(&value).unwrap_or_else(|_| bytes.to_vec())
2620 }
2621
2622 fn inject_annotations(entry: &buffer::BufferedResponse) -> Vec<u8> {
2627 let Ok(mut value) = serde_json::from_slice::<serde_json::Value>(&entry.payload) else {
2628 warn!(
2629 "Buffer entry {} has non-JSON payload; skipping annotation injection",
2630 entry.id
2631 );
2632 return entry.payload.clone();
2633 };
2634 let annotations_json: Vec<serde_json::Value> = entry
2635 .annotations
2636 .iter()
2637 .filter_map(|a| serde_json::to_value(a).ok())
2638 .collect();
2639
2640 if let Some(obj) = value.as_object_mut() {
2641 if !annotations_json.is_empty() {
2643 obj.insert(
2644 "operator_annotations".to_string(),
2645 serde_json::Value::Array(annotations_json),
2646 );
2647 }
2648 if entry.edited {
2649 obj.insert(
2650 "edited_by".to_string(),
2651 serde_json::Value::String("operator".to_string()),
2652 );
2653 }
2654 serde_json::to_vec(&value).unwrap_or_else(|_| entry.payload.clone())
2655 } else if let Some(arr) = value.as_array_mut() {
2656 for item in arr.iter_mut() {
2659 if let Some(tuple) = item.as_array_mut() {
2660 if let Some(eval_obj) = tuple.get_mut(1).and_then(|v| v.as_object_mut()) {
2661 if !annotations_json.is_empty() {
2662 eval_obj.insert(
2663 "operator_annotations".to_string(),
2664 serde_json::Value::Array(annotations_json.clone()),
2665 );
2666 }
2667 if entry.edited {
2668 eval_obj.insert(
2669 "edited_by".to_string(),
2670 serde_json::Value::String("operator".to_string()),
2671 );
2672 }
2673 }
2674 }
2675 }
2676 serde_json::to_vec(&value).unwrap_or_else(|_| entry.payload.clone())
2677 } else {
2678 entry.payload.clone()
2679 }
2680 }
2681
2682 async fn is_duplicate(&self, msg_id: &str) -> Result<bool> {
2683 match self.processed_kv.get(msg_id).await {
2684 Ok(Some(_)) => Ok(true),
2685 Ok(None) => Ok(false),
2686 Err(e) => Err(anyhow::anyhow!("KV Get Error: {}", e)),
2687 }
2688 }
2689
2690 async fn mark_processed(&self, msg_id: &str) -> Result<()> {
2691 let val = std::time::SystemTime::now()
2692 .duration_since(std::time::UNIX_EPOCH)
2693 .unwrap_or_default()
2694 .as_secs()
2695 .to_string();
2696
2697 self.processed_kv
2698 .put(msg_id, val.into())
2699 .await
2700 .map_err(|e| anyhow::anyhow!("KV Put Error: {}", e))?;
2701 Ok(())
2702 }
2703}
2704
2705impl Clone for NatsNsedWorker {
2706 fn clone(&self) -> Self {
2707 Self {
2708 agent: self.agent.clone(),
2709 agent_config: self.agent_config.clone(),
2710 nats: self.nats.clone(),
2711 js: self.js.clone(),
2712 processed_kv: self.processed_kv.clone(),
2713 scratchpad_kv: self.scratchpad_kv.clone(),
2714 config: self.config.clone(),
2715 agent_id: self.agent_id.clone(),
2716 active_jobs: self.active_jobs.clone(),
2717 start_time: self.start_time,
2718 status: self.status.clone(),
2719 hook: self.hook.clone(),
2720 user_tool_factory: self.user_tool_factory.clone(),
2721 chat_agent: self.chat_agent.clone(),
2722 response_buffer: self.response_buffer.clone(),
2723 paused: self.paused.clone(),
2724 model_down_until_ms: self.model_down_until_ms.clone(),
2725 model_down_strikes: self.model_down_strikes.clone(),
2726 model_down_detector: self.model_down_detector.clone(),
2727 model_availability: self.model_availability.clone(),
2728 last_availability_probe_ms: self.last_availability_probe_ms.clone(),
2729 telemetry: self.telemetry.clone(),
2730 before_prompt_mw: self.before_prompt_mw.clone(),
2731 provider_response_mw: self.provider_response_mw.clone(),
2732 completion_mw: self.completion_mw.clone(),
2733 job_complete_mw: self.job_complete_mw.clone(),
2734 }
2735 }
2736}
2737
2738#[derive(Debug)]
2743struct MiddlewareBlocked {
2744 category: String,
2745 reason: String,
2746}
2747
2748impl std::fmt::Display for MiddlewareBlocked {
2749 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2750 write!(f, "middleware blocked ({}): {}", self.category, self.reason)
2751 }
2752}
2753
2754impl std::error::Error for MiddlewareBlocked {}
2755
2756#[allow(clippy::too_many_arguments)] async fn run_stage_pipeline(
2761 pipeline: &crate::middleware::pipeline::MiddlewarePipeline,
2762 agent_id: &str,
2763 action: &str,
2764 session_id: &str,
2765 round: u32,
2766 stage: crate::middleware::MiddlewareStage,
2767 content: serde_json::Value,
2768 metadata: serde_json::Value,
2769) -> Result<serde_json::Value> {
2770 let mut ctx = crate::middleware::MiddlewareContext {
2771 content,
2772 action: action.to_string(),
2773 agent_id: agent_id.to_string(),
2774 job_id: session_id.to_string(),
2775 round,
2776 stage,
2777 metadata,
2778 hook_state: std::collections::HashMap::new(),
2779 };
2780 match pipeline.run(&mut ctx).await {
2781 crate::middleware::pipeline::PipelineResult::Blocked {
2782 category, reason, ..
2783 } => Err(anyhow::Error::new(MiddlewareBlocked { category, reason })),
2784 _ => Ok(ctx.content),
2785 }
2786}
2787
2788#[derive(Debug)]
2796struct MiddlewareSubmissionValidator {
2797 pipeline: Arc<crate::middleware::pipeline::MiddlewarePipeline>,
2798 agent_id: String,
2799 session_id: String,
2800 round: u32,
2801}
2802
2803fn build_submission_validator(
2807 action: &str,
2808 provider_response_mw: &Option<Arc<crate::middleware::pipeline::MiddlewarePipeline>>,
2809 agent_id: &str,
2810 session_id: &str,
2811 round: u32,
2812) -> Option<Arc<dyn crate::agents::SubmissionValidator>> {
2813 if action != "propose" {
2814 return None;
2815 }
2816 let pipeline = provider_response_mw.clone()?;
2817 Some(Arc::new(MiddlewareSubmissionValidator {
2818 pipeline,
2819 agent_id: agent_id.to_string(),
2820 session_id: session_id.to_string(),
2821 round,
2822 }))
2823}
2824
2825#[async_trait::async_trait]
2826impl crate::agents::SubmissionValidator for MiddlewareSubmissionValidator {
2827 async fn validate(&self, content: &str) -> Option<String> {
2828 match run_stage_pipeline(
2829 &self.pipeline,
2830 &self.agent_id,
2831 "propose",
2832 &self.session_id,
2833 self.round,
2834 crate::middleware::MiddlewareStage::ProviderResponse,
2835 serde_json::json!(content),
2836 serde_json::json!({}),
2837 )
2838 .await
2839 {
2840 Err(e) => e
2841 .downcast_ref::<MiddlewareBlocked>()
2842 .map(|b| b.reason.clone()),
2843 Ok(_) => None,
2844 }
2845 }
2846}
2847
2848fn session_id_from_subject(subject: &str, prefix: &str) -> String {
2851 let prefix_count = if prefix.is_empty() {
2852 0
2853 } else {
2854 prefix.split('.').count()
2855 };
2856 subject
2857 .split('.')
2858 .nth(prefix_count)
2859 .unwrap_or("?")
2860 .to_string()
2861}
2862
2863fn job_complete_payload(
2866 event: &crate::events::JobCompleteEvent,
2867) -> (serde_json::Value, serde_json::Value) {
2868 let winner = event.best_proposal_author.clone();
2869 (
2870 serde_json::json!({
2871 "winner": winner,
2872 "score": event.best_proposal_score,
2873 "content": event.best_proposal_content,
2874 "rounds_completed": event.rounds_completed,
2875 }),
2876 serde_json::json!({
2877 "winner": winner,
2878 "finalized_by_user": event.finalized_by_user,
2879 }),
2880 )
2881}
2882
2883fn pick_winner(scores: &[crate::events::ProposalScoreEntry]) -> Option<String> {
2890 scores
2891 .iter()
2892 .max_by(|a, b| aggregated_score_cmp(a.aggregated_score, b.aggregated_score))
2893 .map(|e| e.agent_id.clone())
2894}
2895
2896pub(crate) fn aggregated_score_cmp(a: f32, b: f32) -> std::cmp::Ordering {
2899 use std::cmp::Ordering;
2900 match (a.is_nan(), b.is_nan()) {
2901 (true, true) => Ordering::Equal,
2902 (true, false) => Ordering::Less,
2903 (false, true) => Ordering::Greater,
2904 (false, false) => a.partial_cmp(&b).unwrap_or(Ordering::Equal),
2905 }
2906}
2907
2908fn extract_content_preview(
2913 payload: &[u8],
2914 action: &str,
2915 candidates: &[crate::agents::CandidateProposal],
2916) -> Option<String> {
2917 let val: serde_json::Value = serde_json::from_slice(payload).ok()?;
2920 let structured = match action {
2921 "propose" => {
2922 let content = val.get("content").and_then(|v| v.as_str()).unwrap_or("");
2924 if content.is_empty() {
2925 return None;
2926 }
2927 let thought = val
2928 .get("thought_process")
2929 .and_then(|v| v.as_str())
2930 .unwrap_or("");
2931 let mut obj = serde_json::Map::new();
2932 obj.insert("t".into(), serde_json::Value::String("p".into()));
2933 let c = if content.chars().count() > 2000 {
2935 let idx = content
2936 .char_indices()
2937 .nth(2000)
2938 .map(|(i, _)| i)
2939 .unwrap_or(content.len());
2940 format!("{}…", &content[..idx])
2941 } else {
2942 content.to_string()
2943 };
2944 obj.insert("c".into(), serde_json::Value::String(c));
2945 if !thought.is_empty() {
2946 let tp = if thought.chars().count() > 500 {
2947 let idx = thought
2948 .char_indices()
2949 .nth(500)
2950 .map(|(i, _)| i)
2951 .unwrap_or(thought.len());
2952 format!("{}…", &thought[..idx])
2953 } else {
2954 thought.to_string()
2955 };
2956 obj.insert("tp".into(), serde_json::Value::String(tp));
2957 }
2958 serde_json::Value::Object(obj)
2959 }
2960 "evaluate" => {
2961 let arr = val.as_array()?;
2963 let mut evals = Vec::new();
2964 let mut displayed_targets = std::collections::HashSet::new();
2965 for item in arr.iter().take(10) {
2966 if let Some(tuple) = item.as_array() {
2967 let target = tuple.first().and_then(|v| v.as_str()).unwrap_or("?");
2968 if let Some(eval_obj) = tuple.get(1) {
2969 displayed_targets.insert(target.to_string());
2970 let mut e = serde_json::Map::new();
2971 e.insert(
2972 "target".into(),
2973 serde_json::Value::String(target.to_string()),
2974 );
2975 if let Some(s) = eval_obj.get("score") {
2976 e.insert("s".into(), s.clone());
2977 }
2978 if let Some(j) = eval_obj.get("justification").and_then(|v| v.as_str()) {
2979 let jp = if j.chars().count() > 300 {
2980 let idx =
2981 j.char_indices().nth(300).map(|(i, _)| i).unwrap_or(j.len());
2982 format!("{}…", &j[..idx])
2983 } else {
2984 j.to_string()
2985 };
2986 e.insert("j".into(), serde_json::Value::String(jp));
2987 }
2988 if let Some(stance) = eval_obj.get("stance").and_then(|v| v.as_str()) {
2989 e.insert(
2990 "stance".into(),
2991 serde_json::Value::String(stance.to_string()),
2992 );
2993 }
2994 if let Some(tf) = eval_obj.get("textual_feedback").and_then(|v| v.as_str())
2995 {
2996 let tfp = if tf.chars().count() > 200 {
2997 let idx = tf
2998 .char_indices()
2999 .nth(200)
3000 .map(|(i, _)| i)
3001 .unwrap_or(tf.len());
3002 format!("{}…", &tf[..idx])
3003 } else {
3004 tf.to_string()
3005 };
3006 e.insert("tf".into(), serde_json::Value::String(tfp));
3007 }
3008 if let Some(cats) = eval_obj.get("category_scores") {
3009 e.insert("cats".into(), cats.clone());
3010 }
3011 if let Some(claims) = eval_obj.get("claim_assessments") {
3012 e.insert("claims".into(), claims.clone());
3013 }
3014 if let Some(disputes) = eval_obj.get("disagreements") {
3015 e.insert("disputes".into(), disputes.clone());
3016 }
3017 evals.push(serde_json::Value::Object(e));
3018 }
3019 }
3020 }
3021 if evals.is_empty() {
3022 return None;
3023 }
3024 let mut obj = serde_json::Map::new();
3025 obj.insert("t".into(), serde_json::Value::String("e".into()));
3026 obj.insert("evals".into(), serde_json::Value::Array(evals));
3027 if !candidates.is_empty() && !displayed_targets.is_empty() {
3030 let mut props = serde_json::Map::new();
3031 for cp in candidates
3032 .iter()
3033 .filter(|cp| displayed_targets.contains(&cp.id))
3034 {
3035 let c = &cp.proposal.content;
3036 let truncated = if c.chars().count() > 1000 {
3037 let idx = c
3038 .char_indices()
3039 .nth(1000)
3040 .map(|(i, _)| i)
3041 .unwrap_or(c.len());
3042 format!("{}…", &c[..idx])
3043 } else {
3044 c.clone()
3045 };
3046 props.insert(cp.id.clone(), serde_json::Value::String(truncated));
3047 }
3048 if !props.is_empty() {
3049 obj.insert("props".into(), serde_json::Value::Object(props));
3050 }
3051 }
3052 serde_json::Value::Object(obj)
3053 }
3054 _ => return None,
3055 };
3056 serde_json::to_string(&structured).ok()
3057}
3058
3059fn is_transient_error(err: &anyhow::Error) -> bool {
3064 let msg = err.to_string().to_lowercase();
3065 const PATTERNS: &[&str] = &[
3066 "broken pipe",
3067 "connection reset",
3068 "os error 32",
3069 "os error 104",
3070 "timed out",
3071 "connection closed",
3072 "unexpected eof",
3073 "stream closed",
3074 "connection refused",
3075 "network unreachable",
3076 "connection aborted",
3077 ];
3078 PATTERNS.iter().any(|p| msg.contains(p))
3079}
3080
3081const MODEL_DOWN_COOLDOWN_MS: u64 = 300_000; fn escalated_cooldown_ms(strikes: u64) -> u64 {
3099 const CAP_MS: u64 = 1_800_000; let shift = strikes.saturating_sub(1).min(20); MODEL_DOWN_COOLDOWN_MS
3102 .saturating_mul(1u64 << shift)
3103 .min(CAP_MS)
3104}
3105
3106pub trait ModelDownDetector: Send + Sync + std::fmt::Debug {
3118 fn is_model_down(&self, error: &str) -> bool;
3120}
3121
3122#[derive(Debug, Default, Clone)]
3136pub struct HeuristicModelDownDetector;
3137
3138impl ModelDownDetector for HeuristicModelDownDetector {
3139 fn is_model_down(&self, error: &str) -> bool {
3140 let e = error.to_ascii_lowercase();
3141 e.contains("status 404")
3142 || e.contains("404 not found")
3143 || e.contains("status 410")
3144 || e.contains("410 gone")
3145 || e.contains("model_not_found")
3146 || e.contains("model not found")
3147 || e.contains("does not exist")
3148 || e.contains("no such model")
3149 || e.contains("no longer available")
3150 }
3151}
3152
3153fn model_down_active(until_ms: u64, now_ms: u64) -> bool {
3157 until_ms != 0 && now_ms < until_ms
3158}
3159
3160fn availability_probe_due(last_ms: u64, now_ms: u64, interval_ms: u64) -> bool {
3164 last_ms == 0 || now_ms.saturating_sub(last_ms) >= interval_ms
3165}
3166
3167fn classify_abstention_reason(err: &str) -> String {
3168 let lower = err.to_lowercase();
3169 if lower.contains("failed to parse structured output") || lower.contains("missing field") {
3170 "parse_error".into()
3171 } else if lower.contains("max_iterations")
3172 || lower.contains("max iterations")
3173 || lower.contains("iteration budget")
3174 {
3175 "iter_budget_exhausted".into()
3176 } else if lower.contains("timeout") || lower.contains("timed out") {
3177 "timeout".into()
3178 } else if lower.contains("tool") && (lower.contains("error") || lower.contains("failed")) {
3179 "tool_error".into()
3180 } else {
3181 "error".into()
3182 }
3183}
3184
3185fn failed_result_subject(
3191 prefix: &str,
3192 session_id: &str,
3193 round: u32,
3194 agent_id: &str,
3195 action: &str,
3196) -> String {
3197 format!("{prefix}.{session_id}.result.{round}.{agent_id}.{action}.failed")
3198}
3199
3200fn should_publish_failure_marker(action: &str, is_payment_error: bool) -> bool {
3210 if is_payment_error {
3211 return false;
3212 }
3213 matches!(action, "propose" | "evaluate")
3214}
3215
3216#[cfg(test)]
3217mod tests {
3218 use super::*;
3219 use crate::middleware::pipeline::MiddlewarePipeline;
3220 use crate::middleware::{
3221 AgentMiddleware, MiddlewareContext, MiddlewareStage, MiddlewareVerdict,
3222 };
3223
3224 fn all_stages() -> Vec<MiddlewareStage> {
3230 use MiddlewareStage::*;
3231 vec![
3232 Edit,
3233 Release,
3234 ProviderResponse,
3235 BeforePrompt,
3236 Completion,
3237 JobComplete,
3238 ]
3239 }
3240
3241 #[derive(Debug)]
3242 struct ContentReplaceMock(serde_json::Value);
3243 #[async_trait::async_trait]
3244 impl AgentMiddleware for ContentReplaceMock {
3245 async fn execute(&self, _ctx: &MiddlewareContext) -> MiddlewareVerdict {
3247 MiddlewareVerdict::pass_with_content(self.0.clone())
3248 }
3249 fn name(&self) -> &str {
3250 "content-replace-mock"
3251 }
3252 fn stages(&self) -> Vec<MiddlewareStage> {
3253 all_stages()
3254 }
3255 }
3256
3257 #[derive(Debug)]
3258 struct BlockingMock;
3259 #[async_trait::async_trait]
3260 impl AgentMiddleware for BlockingMock {
3261 async fn execute(&self, _ctx: &MiddlewareContext) -> MiddlewareVerdict {
3263 MiddlewareVerdict::block("mock_block", "rejected by mock middleware")
3264 }
3265 fn name(&self) -> &str {
3266 "blocking-mock"
3267 }
3268 fn stages(&self) -> Vec<MiddlewareStage> {
3269 all_stages()
3270 }
3271 }
3272
3273 #[derive(Debug)]
3275 struct ContextEchoMock;
3276 #[async_trait::async_trait]
3277 impl AgentMiddleware for ContextEchoMock {
3278 async fn execute(&self, ctx: &MiddlewareContext) -> MiddlewareVerdict {
3280 MiddlewareVerdict::pass_with_content(serde_json::json!({
3281 "stage": format!("{:?}", ctx.stage),
3282 "agent": ctx.agent_id,
3283 "job": ctx.job_id,
3284 "round": ctx.round,
3285 "action": ctx.action,
3286 "meta": ctx.metadata,
3287 }))
3288 }
3289 fn name(&self) -> &str {
3290 "context-echo-mock"
3291 }
3292 fn stages(&self) -> Vec<MiddlewareStage> {
3293 all_stages()
3294 }
3295 }
3296
3297 #[tokio::test]
3298 async fn run_stage_pipeline_returns_transformed_content() {
3299 let p = MiddlewarePipeline::new(vec![Box::new(ContentReplaceMock(serde_json::json!(
3300 "changed"
3301 )))]);
3302 let out = run_stage_pipeline(
3303 &p,
3304 "AgentA",
3305 "propose",
3306 "job1",
3307 2,
3308 MiddlewareStage::ProviderResponse,
3309 serde_json::json!("orig"),
3310 serde_json::json!({}),
3311 )
3312 .await
3313 .unwrap();
3314 assert_eq!(out, serde_json::json!("changed"));
3315 }
3316
3317 #[tokio::test]
3318 async fn run_stage_pipeline_block_is_err() {
3319 let p = MiddlewarePipeline::new(vec![Box::new(BlockingMock)]);
3320 let r = run_stage_pipeline(
3321 &p,
3322 "A",
3323 "propose",
3324 "j",
3325 0,
3326 MiddlewareStage::BeforePrompt,
3327 serde_json::json!("x"),
3328 serde_json::json!({}),
3329 )
3330 .await;
3331 assert!(r.is_err(), "a blocking middleware must fail the hook");
3332 }
3333
3334 #[tokio::test]
3335 async fn run_stage_pipeline_builds_context_correctly() {
3336 let p = MiddlewarePipeline::new(vec![Box::new(ContextEchoMock)]);
3337 let out = run_stage_pipeline(
3338 &p,
3339 "AgentA",
3340 "job_complete",
3341 "sess9",
3342 3,
3343 MiddlewareStage::JobComplete,
3344 serde_json::json!("x"),
3345 serde_json::json!({"winner": "AgentA"}),
3346 )
3347 .await
3348 .unwrap();
3349 assert_eq!(out["agent"], "AgentA");
3350 assert_eq!(out["job"], "sess9");
3351 assert_eq!(out["round"], 3);
3352 assert_eq!(out["action"], "job_complete");
3353 assert_eq!(out["stage"], "JobComplete");
3354 assert_eq!(out["meta"]["winner"], "AgentA");
3355 }
3356
3357 #[tokio::test]
3360 async fn run_stage_pipeline_block_downcasts_to_typed_error() {
3361 let p = MiddlewarePipeline::new(vec![Box::new(BlockingMock)]);
3362 let e = run_stage_pipeline(
3363 &p,
3364 "A",
3365 "propose",
3366 "j",
3367 0,
3368 MiddlewareStage::ProviderResponse,
3369 serde_json::json!("x"),
3370 serde_json::json!({}),
3371 )
3372 .await
3373 .unwrap_err();
3374 let b = e
3375 .downcast_ref::<MiddlewareBlocked>()
3376 .expect("a Blocked verdict must surface as a typed MiddlewareBlocked error");
3377 assert_eq!(b.category, "mock_block");
3378 assert_eq!(b.reason, "rejected by mock middleware");
3379 }
3380
3381 use crate::agents::SubmissionValidator;
3382
3383 #[tokio::test]
3384 async fn submission_validator_maps_block_to_reason() {
3385 let v = MiddlewareSubmissionValidator {
3386 pipeline: Arc::new(MiddlewarePipeline::new(vec![Box::new(BlockingMock)])),
3387 agent_id: "AgentA".to_string(),
3388 session_id: "job1".to_string(),
3389 round: 0,
3390 };
3391 assert_eq!(
3392 v.validate("anything").await.as_deref(),
3393 Some("rejected by mock middleware"),
3394 "a Blocked verdict surfaces its reason for the react loop to feed back"
3395 );
3396 }
3397
3398 #[test]
3399 fn build_submission_validator_only_for_propose_with_pipeline() {
3400 let mw = Some(Arc::new(MiddlewarePipeline::new(vec![Box::new(
3401 BlockingMock,
3402 )])));
3403 assert!(
3404 build_submission_validator("propose", &mw, "A", "j", 0).is_some(),
3405 "propose + configured pipeline → a validator"
3406 );
3407 assert!(
3408 build_submission_validator("evaluate", &mw, "A", "j", 0).is_none(),
3409 "evaluations have no provider_response → no validator"
3410 );
3411 assert!(
3412 build_submission_validator("propose", &None, "A", "j", 0).is_none(),
3413 "no pipeline configured → no validator"
3414 );
3415 }
3416
3417 #[tokio::test]
3418 async fn submission_validator_passes_clean_content() {
3419 let v = MiddlewareSubmissionValidator {
3420 pipeline: Arc::new(MiddlewarePipeline::new(vec![Box::new(ContentReplaceMock(
3421 serde_json::json!("transformed"),
3422 ))])),
3423 agent_id: "AgentA".to_string(),
3424 session_id: "job1".to_string(),
3425 round: 0,
3426 };
3427 assert_eq!(
3428 v.validate("ok").await,
3429 None,
3430 "a passing pipeline accepts the submission (None)"
3431 );
3432 }
3433
3434 #[test]
3435 fn session_id_from_subject_cases() {
3436 assert_eq!(
3437 session_id_from_subject("nsed.sess1.result.event.job_complete", "nsed"),
3438 "sess1"
3439 );
3440 assert_eq!(
3441 session_id_from_subject("sess2.result.event.round_summary", ""),
3442 "sess2"
3443 );
3444 assert_eq!(
3445 session_id_from_subject("org.nsed.s42.result.event.x", "org.nsed"),
3446 "s42"
3447 );
3448 assert_eq!(
3449 session_id_from_subject("", "nsed"),
3450 "?",
3451 "malformed → sentinel, no panic"
3452 );
3453 }
3454
3455 #[test]
3456 fn job_complete_payload_maps_winner() {
3457 let ev = crate::events::JobCompleteEvent {
3458 best_proposal_author: "AgentB".into(),
3459 best_proposal_score: 7.5,
3460 best_proposal_content: "final".into(),
3461 rounds_completed: 4,
3462 finalized_by_user: Some("op".into()),
3463 ..Default::default()
3464 };
3465 let (content, meta) = job_complete_payload(&ev);
3466 assert_eq!(content["winner"], "AgentB");
3467 assert_eq!(content["score"], 7.5);
3468 assert_eq!(content["rounds_completed"], 4);
3469 assert_eq!(meta["winner"], "AgentB");
3470 assert_eq!(meta["finalized_by_user"], "op");
3471 }
3472
3473 #[test]
3474 fn pick_winner_selects_highest_score() {
3475 use crate::events::ProposalScoreEntry;
3476 let scores = vec![
3477 ProposalScoreEntry {
3478 agent_id: "alpha".into(),
3479 aggregated_score: 3.2,
3480 ..Default::default()
3481 },
3482 ProposalScoreEntry {
3483 agent_id: "beta".into(),
3484 aggregated_score: 6.5,
3485 ..Default::default()
3486 },
3487 ProposalScoreEntry {
3488 agent_id: "gamma".into(),
3489 aggregated_score: 1.0,
3490 ..Default::default()
3491 },
3492 ];
3493 assert_eq!(pick_winner(&scores).as_deref(), Some("beta"));
3494 assert_eq!(pick_winner(&[]), None);
3495 }
3496
3497 #[test]
3498 fn pick_winner_never_selects_a_nan_score() {
3499 use crate::events::ProposalScoreEntry;
3500 let scores = vec![
3504 ProposalScoreEntry {
3505 agent_id: "real".into(),
3506 aggregated_score: 4.0,
3507 ..Default::default()
3508 },
3509 ProposalScoreEntry {
3510 agent_id: "poisoned".into(),
3511 aggregated_score: f32::NAN,
3512 ..Default::default()
3513 },
3514 ];
3515 assert_eq!(pick_winner(&scores).as_deref(), Some("real"));
3516 }
3517
3518 #[test]
3519 fn aggregated_score_cmp_sorts_nan_lowest() {
3520 use std::cmp::Ordering;
3521 assert_eq!(aggregated_score_cmp(f32::NAN, 1.0), Ordering::Less);
3522 assert_eq!(aggregated_score_cmp(1.0, f32::NAN), Ordering::Greater);
3523 assert_eq!(aggregated_score_cmp(f32::NAN, f32::NAN), Ordering::Equal);
3524 assert_eq!(aggregated_score_cmp(2.0, 1.0), Ordering::Greater);
3525 }
3526
3527 #[test]
3528 fn test_worker_config_new_defaults() {
3529 let config = WorkerConfig::new(
3530 "nats://localhost:4222".to_string(),
3531 "test_stream".to_string(),
3532 "test_consumer".to_string(),
3533 );
3534
3535 assert_eq!(config.nats_url, "nats://localhost:4222");
3536 assert_eq!(config.stream_name, "test_stream");
3537 assert_eq!(config.consumer_name, "test_consumer");
3538 assert_eq!(config.subject_prefix, "nsed");
3539 assert_eq!(config.api_prefix, "sphera");
3540 assert_eq!(config.scratchpad_retention_secs, 86400 * 7);
3541 assert_eq!(config.max_concurrent_jobs, None);
3543 assert_eq!(config.max_ack_pending(), 0);
3544 }
3545
3546 #[test]
3547 fn test_worker_config_max_concurrent_jobs_maps_to_ack_pending() {
3548 let serialized = WorkerConfig::new(
3549 "nats://localhost:4222".to_string(),
3550 "s".to_string(),
3551 "c".to_string(),
3552 )
3553 .with_max_concurrent_jobs(1);
3554 assert_eq!(serialized.max_concurrent_jobs, Some(1));
3555 assert_eq!(
3556 serialized.max_ack_pending(),
3557 1,
3558 "1 job → serialized in-flight"
3559 );
3560
3561 let capped =
3562 WorkerConfig::new("u".into(), "s".into(), "c".into()).with_max_concurrent_jobs(4);
3563 assert_eq!(capped.max_ack_pending(), 4);
3564 }
3565
3566 #[test]
3567 fn test_worker_config_with_subject_prefix() {
3568 let config = WorkerConfig::new(
3569 "nats://localhost:4222".to_string(),
3570 "stream".to_string(),
3571 "consumer".to_string(),
3572 )
3573 .with_subject_prefix("nsed.test".to_string());
3574
3575 assert_eq!(config.subject_prefix, "nsed.test");
3576 }
3577
3578 #[test]
3579 fn test_worker_config_with_scratchpad_retention() {
3580 let config = WorkerConfig::new(
3581 "nats://localhost:4222".to_string(),
3582 "stream".to_string(),
3583 "consumer".to_string(),
3584 )
3585 .with_scratchpad_retention(3600);
3586
3587 assert_eq!(config.scratchpad_retention_secs, 3600);
3588 }
3589
3590 #[test]
3591 fn test_worker_config_with_zero_retention() {
3592 let config = WorkerConfig::new(
3593 "nats://localhost:4222".to_string(),
3594 "stream".to_string(),
3595 "consumer".to_string(),
3596 )
3597 .with_scratchpad_retention(0);
3598
3599 assert_eq!(config.scratchpad_retention_secs, 0);
3600 }
3601
3602 #[test]
3603 fn test_worker_config_chained_builders() {
3604 let config = WorkerConfig::new(
3605 "nats://test:4222".to_string(),
3606 "my_stream".to_string(),
3607 "my_consumer".to_string(),
3608 )
3609 .with_subject_prefix("prefix".to_string())
3610 .with_api_prefix("myapi".to_string())
3611 .with_scratchpad_retention(7200);
3612
3613 assert_eq!(config.nats_url, "nats://test:4222");
3614 assert_eq!(config.stream_name, "my_stream");
3615 assert_eq!(config.consumer_name, "my_consumer");
3616 assert_eq!(config.subject_prefix, "prefix");
3617 assert_eq!(config.api_prefix, "myapi");
3618 assert_eq!(config.scratchpad_retention_secs, 7200);
3619 }
3620
3621 #[test]
3622 fn test_job_manifest_deserialization() {
3623 let json = r#"{
3624 "job_id": "test-job-123",
3625 "task_description": "Solve math problem",
3626 "agents": ["agent1", "agent2", "agent3"],
3627 "rounds": 5,
3628 "timestamp": 1704067200
3629 }"#;
3630
3631 let manifest: JobManifest = serde_json::from_str(json).unwrap();
3632 assert_eq!(manifest.job_id, "test-job-123");
3633 assert_eq!(manifest.task_description, "Solve math problem");
3634 assert_eq!(manifest.agents.len(), 3);
3635 assert_eq!(manifest.rounds, 5);
3636 assert_eq!(manifest.timestamp, 1704067200);
3637 }
3638
3639 #[test]
3640 fn test_job_manifest_serialization_roundtrip() {
3641 let manifest = JobManifest {
3642 job_id: "roundtrip-test".to_string(),
3643 task_description: "Test description".to_string(),
3644 agents: vec!["alpha".to_string(), "beta".to_string()],
3645 rounds: 3,
3646 timestamp: 999999,
3647 };
3648
3649 let json = serde_json::to_string(&manifest).unwrap();
3650 let parsed: JobManifest = serde_json::from_str(&json).unwrap();
3651
3652 assert_eq!(parsed.job_id, manifest.job_id);
3653 assert_eq!(parsed.task_description, manifest.task_description);
3654 assert_eq!(parsed.agents, manifest.agents);
3655 assert_eq!(parsed.rounds, manifest.rounds);
3656 assert_eq!(parsed.timestamp, manifest.timestamp);
3657 }
3658
3659 #[test]
3660 fn test_dual_prefix_subject_architecture() {
3661 let subject_prefix = "nsed";
3662 let api_prefix = "sphera";
3663 let job_id = "job-123";
3664 let agent_id = "agent-001";
3665
3666 let ack_subject = format!("{}.jobs.ack.{}.{}", api_prefix, job_id, agent_id);
3667 assert_eq!(ack_subject, "sphera.jobs.ack.job-123.agent-001");
3668
3669 let manifest_filter = format!("{}.jobs.manifest.>", api_prefix);
3670 assert_eq!(manifest_filter, "sphera.jobs.manifest.>");
3671
3672 let heartbeat = format!("{}.agent.heartbeat.{}", api_prefix, agent_id);
3673 assert_eq!(heartbeat, "sphera.agent.heartbeat.agent-001");
3674
3675 let accepted_subject = format!("{}.{}.result.event.agent_accepted", subject_prefix, job_id);
3676 assert!(accepted_subject.starts_with("nsed."));
3677
3678 let task_filter = format!("{}.*.task.{}.*", subject_prefix, agent_id);
3679 assert_eq!(task_filter, "nsed.*.task.agent-001.*");
3680 }
3681
3682 #[test]
3683 fn test_session_id_extraction_single_segment_prefix() {
3684 let subject = "nsed.session-abc.task.agent1.propose";
3685 let prefix = "nsed";
3686
3687 let subject_parts: Vec<&str> = subject.split('.').collect();
3688 let prefix_count = prefix.split('.').count();
3689 let session_id = subject_parts
3690 .get(prefix_count)
3691 .unwrap_or(&"global")
3692 .to_string();
3693 let action = subject_parts.last().unwrap_or(&"unknown");
3694
3695 assert_eq!(session_id, "session-abc");
3696 assert_eq!(*action, "propose");
3697 }
3698
3699 #[test]
3700 fn test_session_id_extraction_multi_segment_prefix() {
3701 let subject = "nsed.v2.session-abc.task.agent1.evaluate";
3702 let prefix = "nsed.v2";
3703
3704 let subject_parts: Vec<&str> = subject.split('.').collect();
3705 let prefix_count = prefix.split('.').count();
3706 let session_id = subject_parts
3707 .get(prefix_count)
3708 .unwrap_or(&"global")
3709 .to_string();
3710 let action = subject_parts.last().unwrap_or(&"unknown");
3711
3712 assert_eq!(session_id, "session-abc");
3713 assert_eq!(*action, "evaluate");
3714 }
3715
3716 #[test]
3717 fn test_session_id_extraction_empty_prefix_fallback() {
3718 let subject = "session-abc.task.agent1.propose";
3719 let prefix = "";
3720
3721 let subject_parts: Vec<&str> = subject.split('.').collect();
3722 let prefix_count = if prefix.is_empty() {
3723 0
3724 } else {
3725 prefix.split('.').count()
3726 };
3727 let session_id = subject_parts
3728 .get(prefix_count)
3729 .unwrap_or(&"global")
3730 .to_string();
3731
3732 assert_eq!(session_id, "session-abc");
3733 }
3734
3735 #[test]
3736 fn test_worker_config_with_nats_auth() {
3737 let auth = NatsAuth {
3738 token: Some("my-secret-token".to_string()),
3739 username: None,
3740 password: None,
3741 inline_creds: None,
3742 creds_file: None,
3743 };
3744
3745 let config = WorkerConfig::new(
3746 "nats://localhost:4222".to_string(),
3747 "stream".to_string(),
3748 "consumer".to_string(),
3749 )
3750 .with_nats_auth(auth);
3751
3752 assert!(config.nats_auth.is_some());
3753 let auth = config.nats_auth.unwrap();
3754 assert_eq!(auth.token, Some("my-secret-token".to_string()));
3755 }
3756
3757 #[derive(Debug)]
3760 struct NoopHook;
3761
3762 #[async_trait]
3763 impl WorkerHook for NoopHook {}
3764
3765 #[tokio::test]
3766 async fn test_worker_hook_default_before_publish() {
3767 let hook = NoopHook;
3768 let mut payload = vec![1, 2, 3];
3769 let result = hook.before_publish("some.subject", &mut payload).await;
3770 assert!(result.is_ok());
3771 assert_eq!(payload, vec![1, 2, 3]);
3773 }
3774
3775 struct TestAckHandle;
3779
3780 #[async_trait]
3781 impl buffer::AckHandle for TestAckHandle {
3782 async fn ack(&self) -> anyhow::Result<()> {
3783 Ok(())
3784 }
3785 }
3786
3787 fn make_entry(
3788 payload: &[u8],
3789 edited: bool,
3790 annotations: Vec<crate::agents::OperatorAnnotation>,
3791 ) -> buffer::BufferedResponse {
3792 let now = std::time::Instant::now();
3793 buffer::BufferedResponse {
3794 id: "test-id".into(),
3795 action: "propose".into(),
3796 job_id: "job-1".into(),
3797 round: 1,
3798 reply_subject: "nsed.job-1.result.1.agent.propose".into(),
3799 payload: payload.to_vec(),
3800 created_at: now,
3801 release_at: now,
3802 ack_handle: Box::new(TestAckHandle),
3803 msg_id: "msg-test".into(),
3804 annotations,
3805 edited,
3806 stopped: false,
3807 }
3808 }
3809
3810 #[test]
3811 fn restamp_proposal_object_overwrites_published_at_ms() {
3812 let payload = br#"{"content":"hello","thought_process":"t","published_at_ms":1000}"#;
3813 let out = NatsNsedWorker::restamp_published_at(payload, 9999);
3814 let v: serde_json::Value = serde_json::from_slice(&out).unwrap();
3815 assert_eq!(v["published_at_ms"], 9999);
3816 assert_eq!(v["content"], "hello");
3817 }
3818
3819 #[test]
3820 fn restamp_proposal_object_inserts_when_field_missing() {
3821 let payload = br#"{"content":"hello","thought_process":"t"}"#;
3822 let out = NatsNsedWorker::restamp_published_at(payload, 4242);
3823 let v: serde_json::Value = serde_json::from_slice(&out).unwrap();
3824 assert_eq!(v["published_at_ms"], 4242);
3825 }
3826
3827 #[test]
3828 fn restamp_evaluation_array_stamps_each_entry() {
3829 let payload = br#"[["A",{"score":0.5,"published_at_ms":100}],["B",{"score":-0.5}]]"#;
3830 let out = NatsNsedWorker::restamp_published_at(payload, 7777);
3831 let v: serde_json::Value = serde_json::from_slice(&out).unwrap();
3832 let arr = v.as_array().unwrap();
3833 assert_eq!(arr.len(), 2);
3834 assert_eq!(arr[0][1]["published_at_ms"], 7777);
3835 assert_eq!(arr[1][1]["published_at_ms"], 7777);
3836 assert_eq!(arr[0][1]["score"], 0.5);
3837 }
3838
3839 #[test]
3840 fn restamp_returns_input_unchanged_on_invalid_json() {
3841 let payload = b"not-json{{";
3842 let out = NatsNsedWorker::restamp_published_at(payload, 1);
3843 assert_eq!(out, payload.to_vec());
3844 }
3845
3846 #[test]
3847 fn test_inject_annotations_proposal_object() {
3848 use crate::agents::{AnnotationType, OperatorAnnotation};
3849
3850 let payload = br#"{"content":"hello","thought_process":"think"}"#;
3851 let annotation = OperatorAnnotation {
3852 annotation_type: AnnotationType::Edit,
3853 comment: "Fixed wording".into(),
3854 timestamp: "2026-03-04T00:00:00Z".into(),
3855 original_content_hash: None,
3856 };
3857 let entry = make_entry(payload, true, vec![annotation]);
3858
3859 let result = NatsNsedWorker::inject_annotations(&entry);
3860 let val: serde_json::Value = serde_json::from_slice(&result).unwrap();
3861
3862 assert_eq!(val["content"], "hello");
3863 assert_eq!(val["thought_process"], "think");
3864 assert_eq!(val["edited_by"], "operator");
3865 assert!(val["operator_annotations"].is_array());
3866 assert_eq!(val["operator_annotations"].as_array().unwrap().len(), 1);
3867 assert_eq!(val["operator_annotations"][0]["comment"], "Fixed wording");
3868 }
3869
3870 #[test]
3871 fn test_inject_annotations_proposal_no_edit_no_annotations() {
3872 let payload = br#"{"content":"original"}"#;
3873 let entry = make_entry(payload, false, vec![]);
3874
3875 let result = NatsNsedWorker::inject_annotations(&entry);
3876 let val: serde_json::Value = serde_json::from_slice(&result).unwrap();
3877
3878 assert_eq!(val["content"], "original");
3879 assert!(val.get("edited_by").is_none());
3881 assert!(val.get("operator_annotations").is_none());
3882 }
3883
3884 #[test]
3885 fn test_inject_annotations_evaluation_array() {
3886 use crate::agents::{AnnotationType, OperatorAnnotation};
3887
3888 let payload = br#"[
3890 ["agent-A", {"score": 7.5, "justification": "Good work"}],
3891 ["agent-B", {"score": 4.0, "justification": "Needs improvement"}]
3892 ]"#;
3893 let annotation = OperatorAnnotation {
3894 annotation_type: AnnotationType::Edit,
3895 comment: "Adjusted scores".into(),
3896 timestamp: "2026-03-04T00:00:00Z".into(),
3897 original_content_hash: None,
3898 };
3899 let entry = make_entry(payload, true, vec![annotation]);
3900
3901 let result = NatsNsedWorker::inject_annotations(&entry);
3902 let val: serde_json::Value = serde_json::from_slice(&result).unwrap();
3903
3904 let arr = val.as_array().expect("should remain an array");
3905 assert_eq!(arr.len(), 2);
3906
3907 let first = arr[0].as_array().unwrap();
3909 assert_eq!(first[0], "agent-A");
3910 let eval_a = &first[1];
3911 assert_eq!(eval_a["score"], 7.5);
3912 assert_eq!(eval_a["edited_by"], "operator");
3913 assert!(eval_a["operator_annotations"].is_array());
3914 assert_eq!(
3915 eval_a["operator_annotations"][0]["comment"],
3916 "Adjusted scores"
3917 );
3918
3919 let second = arr[1].as_array().unwrap();
3921 assert_eq!(second[0], "agent-B");
3922 let eval_b = &second[1];
3923 assert_eq!(eval_b["score"], 4.0);
3924 assert_eq!(eval_b["edited_by"], "operator");
3925 assert!(eval_b["operator_annotations"].is_array());
3926 }
3927
3928 #[test]
3929 fn test_inject_annotations_evaluation_array_no_edit() {
3930 use crate::agents::{AnnotationType, OperatorAnnotation};
3931
3932 let payload = br#"[["agent-A", {"score": 5.0}]]"#;
3933 let annotation = OperatorAnnotation {
3935 annotation_type: AnnotationType::Comment,
3936 comment: "Reviewed".into(),
3937 timestamp: "2026-03-04T00:00:00Z".into(),
3938 original_content_hash: None,
3939 };
3940 let entry = make_entry(payload, false, vec![annotation]);
3941
3942 let result = NatsNsedWorker::inject_annotations(&entry);
3943 let val: serde_json::Value = serde_json::from_slice(&result).unwrap();
3944
3945 let arr = val.as_array().unwrap();
3946 let eval_obj = &arr[0].as_array().unwrap()[1];
3947 assert!(eval_obj["operator_annotations"].is_array());
3949 assert_eq!(eval_obj["operator_annotations"][0]["comment"], "Reviewed");
3950 assert!(eval_obj.get("edited_by").is_none());
3952 }
3953
3954 #[test]
3955 fn test_inject_annotations_non_json_passthrough() {
3956 let payload = b"this is not json";
3957 let entry = make_entry(payload, true, vec![]);
3958
3959 let result = NatsNsedWorker::inject_annotations(&entry);
3960 assert_eq!(result, payload.to_vec());
3962 }
3963
3964 #[test]
3967 fn test_round_summary_event_serde_roundtrip() {
3968 use crate::events::{ProposalScoreEntry, RoundSummaryEvent};
3969
3970 let event = RoundSummaryEvent {
3971 round: 1,
3972 convergence_score: 0.75,
3973 decisiveness: 0.75,
3974 net_support: vec![],
3975 cesaro_support: vec![],
3976 raw_distance: None,
3977 claim_convergence: None,
3978 total_claims: None,
3979 leader_claim_convergence: None,
3980 leader_total_claims: None,
3981 controversy_scores: vec![],
3982 proposal_scores: vec![
3983 ProposalScoreEntry {
3984 agent_id: "alpha".into(),
3985 aggregated_score: 6.5,
3986 category_breakdown: None,
3987 controversy_score: None,
3988 ..Default::default()
3989 },
3990 ProposalScoreEntry {
3991 agent_id: "beta".into(),
3992 aggregated_score: 3.2,
3993 category_breakdown: None,
3994 controversy_score: None,
3995 ..Default::default()
3996 },
3997 ],
3998 accumulated_evidence: None,
3999 evidence_target: None,
4000 positive_budget: None,
4001 du_dt: None,
4002 signed_consensus: None,
4003 t_opt: None,
4004 thermo_probability: None,
4005 ..Default::default()
4006 };
4007
4008 let json = serde_json::to_vec(&event).unwrap();
4009 let parsed: RoundSummaryEvent = serde_json::from_slice(&json).unwrap();
4010 assert_eq!(parsed.round, 1);
4011 assert_eq!(parsed.proposal_scores.len(), 2);
4012 assert_eq!(parsed.proposal_scores[0].agent_id, "alpha");
4013 assert!((parsed.proposal_scores[0].aggregated_score - 6.5).abs() < f32::EPSILON);
4014 assert_eq!(parsed.proposal_scores[1].agent_id, "beta");
4015 assert!((parsed.proposal_scores[1].aggregated_score - 3.2).abs() < f32::EPSILON);
4016 }
4017
4018 #[test]
4019 fn test_score_dedup_prevents_duplicate() {
4020 use crate::status::{AgentStatusSnapshot, ScoreEntry};
4021
4022 let mut snap = AgentStatusSnapshot::new("agent-1".into(), "gpt-4".into(), "p".into());
4023
4024 snap.push_score(ScoreEntry {
4026 timestamp: "t1".into(),
4027 job_id: "job-A".into(),
4028 round: 1,
4029 evaluator: "aggregated".into(),
4030 score: 6.0,
4031 });
4032 assert_eq!(snap.recent_scores.len(), 1);
4033
4034 let already_has = snap
4036 .recent_scores
4037 .iter()
4038 .any(|s| s.job_id == "job-A" && s.round == 1);
4039 assert!(already_has, "dedup guard should detect existing score");
4040
4041 let different_round = snap
4043 .recent_scores
4044 .iter()
4045 .any(|s| s.job_id == "job-A" && s.round == 2);
4046 assert!(!different_round, "different round should not match");
4047 }
4048
4049 #[test]
4050 fn test_score_extraction_from_propose_context() {
4051 use crate::status::{AgentStatusSnapshot, ScoreEntry};
4054
4055 let mut snap = AgentStatusSnapshot::new("agent-1".into(), "model".into(), "p".into());
4056
4057 let round_number: u32 = 3;
4060 let previous_own_score: Option<f32> = Some(4.5);
4061 let session_id = "job-X";
4062
4063 if let Some(score) = previous_own_score {
4064 let prev_round = round_number.saturating_sub(1);
4065 let already_has = snap
4066 .recent_scores
4067 .iter()
4068 .any(|s| s.job_id == session_id && s.round == prev_round);
4069 if !already_has {
4070 snap.push_score(ScoreEntry {
4071 timestamp: "t".into(),
4072 job_id: session_id.into(),
4073 round: prev_round,
4074 evaluator: "aggregated".into(),
4075 score,
4076 });
4077 }
4078 }
4079
4080 assert_eq!(snap.recent_scores.len(), 1);
4081 let entry = &snap.recent_scores[0];
4082 assert_eq!(entry.round, 2); assert!((entry.score - 4.5).abs() < f32::EPSILON);
4084 assert_eq!(entry.job_id, "job-X");
4085
4086 assert!((snap.mean_score.unwrap() - 4.5).abs() < f32::EPSILON);
4088 }
4089
4090 #[test]
4091 fn test_score_extraction_none_previous_score_no_push() {
4092 use crate::status::AgentStatusSnapshot;
4093
4094 let mut snap = AgentStatusSnapshot::new("agent-1".into(), "model".into(), "p".into());
4095 let round_number: u32 = 3;
4096 let previous_own_score: Option<f32> = None;
4097 let session_id = "job-X";
4098
4099 if let Some(score) = previous_own_score {
4101 let prev_round = round_number.saturating_sub(1);
4102 let already_has = snap
4103 .recent_scores
4104 .iter()
4105 .any(|s| s.job_id == session_id && s.round == prev_round);
4106 if !already_has {
4107 snap.push_score(crate::status::ScoreEntry {
4108 timestamp: "t".into(),
4109 job_id: session_id.into(),
4110 round: prev_round,
4111 evaluator: "aggregated".into(),
4112 score,
4113 });
4114 }
4115 }
4116
4117 assert!(snap.recent_scores.is_empty());
4119 assert!(snap.mean_score.is_none());
4120 }
4121
4122 #[test]
4123 fn test_score_extraction_round_1_saturating_sub() {
4124 use crate::status::{AgentStatusSnapshot, ScoreEntry};
4125
4126 let mut snap = AgentStatusSnapshot::new("agent-1".into(), "model".into(), "p".into());
4127
4128 let round_number: u32 = 1;
4130 let previous_own_score: Option<f32> = Some(5.0);
4131 let session_id = "job-Y";
4132
4133 if let Some(score) = previous_own_score {
4134 let prev_round = round_number.saturating_sub(1);
4135 snap.push_score(ScoreEntry {
4136 timestamp: "t".into(),
4137 job_id: session_id.into(),
4138 round: prev_round,
4139 evaluator: "aggregated".into(),
4140 score,
4141 });
4142 }
4143
4144 assert_eq!(snap.recent_scores.len(), 1);
4145 assert_eq!(snap.recent_scores[0].round, 0); }
4147
4148 #[test]
4149 fn test_score_extraction_skips_evaluate_action() {
4150 use crate::status::AgentStatusSnapshot;
4153
4154 let snap = AgentStatusSnapshot::new("agent-1".into(), "model".into(), "p".into());
4155 let action = "evaluate";
4156 let previous_own_score: Option<f32> = Some(7.0);
4157
4158 let pushed = if action == "propose" {
4159 previous_own_score.is_some()
4160 } else {
4161 false
4162 };
4163
4164 assert!(!pushed, "evaluate action should NOT extract scores");
4165 assert!(snap.recent_scores.is_empty());
4166 }
4167
4168 #[test]
4171 fn test_round_summary_unknown_agent_no_push() {
4172 use crate::events::{ProposalScoreEntry, RoundSummaryEvent};
4173 use crate::status::AgentStatusSnapshot;
4174
4175 let event = RoundSummaryEvent {
4176 round: 1,
4177 convergence_score: 0.5,
4178 decisiveness: 0.5,
4179 net_support: vec![],
4180 cesaro_support: vec![],
4181 raw_distance: None,
4182 claim_convergence: None,
4183 total_claims: None,
4184 leader_claim_convergence: None,
4185 leader_total_claims: None,
4186 controversy_scores: vec![],
4187 proposal_scores: vec![ProposalScoreEntry {
4188 agent_id: "other-agent".into(),
4189 aggregated_score: 8.0,
4190 category_breakdown: None,
4191 controversy_score: None,
4192 ..Default::default()
4193 }],
4194 accumulated_evidence: None,
4195 evidence_target: None,
4196 positive_budget: None,
4197 du_dt: None,
4198 signed_consensus: None,
4199 t_opt: None,
4200 thermo_probability: None,
4201 ..Default::default()
4202 };
4203
4204 let my_id = "my-agent";
4206 let mut snap = AgentStatusSnapshot::new(my_id.into(), "model".into(), "p".into());
4207
4208 for entry in &event.proposal_scores {
4209 if entry.agent_id == my_id {
4210 snap.push_score(crate::status::ScoreEntry {
4211 timestamp: "t".into(),
4212 job_id: "job".into(),
4213 round: event.round,
4214 evaluator: "aggregated".into(),
4215 score: entry.aggregated_score,
4216 });
4217 break;
4218 }
4219 }
4220
4221 assert!(snap.recent_scores.is_empty());
4223 assert!(snap.mean_score.is_none());
4224 }
4225
4226 #[test]
4227 fn test_round_summary_empty_proposal_scores_no_push() {
4228 use crate::events::RoundSummaryEvent;
4229 use crate::status::AgentStatusSnapshot;
4230
4231 let event = RoundSummaryEvent {
4232 round: 1,
4233 convergence_score: 0.0,
4234 decisiveness: 0.0,
4235 net_support: vec![],
4236 cesaro_support: vec![],
4237 raw_distance: None,
4238 claim_convergence: None,
4239 total_claims: None,
4240 leader_claim_convergence: None,
4241 leader_total_claims: None,
4242 controversy_scores: vec![],
4243 proposal_scores: vec![], accumulated_evidence: None,
4245 evidence_target: None,
4246 positive_budget: None,
4247 du_dt: None,
4248 signed_consensus: None,
4249 t_opt: None,
4250 thermo_probability: None,
4251 ..Default::default()
4252 };
4253
4254 let mut snap = AgentStatusSnapshot::new("agent-1".into(), "model".into(), "p".into());
4255
4256 for entry in &event.proposal_scores {
4257 if entry.agent_id == "agent-1" {
4258 snap.push_score(crate::status::ScoreEntry {
4259 timestamp: "t".into(),
4260 job_id: "job".into(),
4261 round: event.round,
4262 evaluator: "aggregated".into(),
4263 score: entry.aggregated_score,
4264 });
4265 break;
4266 }
4267 }
4268
4269 assert!(snap.recent_scores.is_empty());
4270 }
4271
4272 #[test]
4273 fn test_round_summary_subject_parsing_with_prefix() {
4274 let prefix = "nsed";
4276 let subject = "nsed.session-abc.result.event.round_summary";
4277
4278 let prefix_count = if prefix.is_empty() {
4279 0
4280 } else {
4281 prefix.split('.').count()
4282 };
4283 let session_id = subject
4284 .split('.')
4285 .nth(prefix_count)
4286 .unwrap_or("?")
4287 .to_string();
4288
4289 assert_eq!(session_id, "session-abc");
4290 }
4291
4292 #[test]
4293 fn test_round_summary_subject_parsing_empty_prefix() {
4294 let prefix = "";
4295 let subject = "session-xyz.result.event.round_summary";
4296
4297 let prefix_count = if prefix.is_empty() {
4298 0
4299 } else {
4300 prefix.split('.').count()
4301 };
4302 let session_id = subject
4303 .split('.')
4304 .nth(prefix_count)
4305 .unwrap_or("?")
4306 .to_string();
4307
4308 assert_eq!(session_id, "session-xyz");
4309 }
4310
4311 #[test]
4312 fn test_round_summary_subject_parsing_multi_segment_prefix() {
4313 let prefix = "org.nsed";
4314 let subject = "org.nsed.session-42.result.event.round_summary";
4315
4316 let prefix_count = if prefix.is_empty() {
4317 0
4318 } else {
4319 prefix.split('.').count()
4320 };
4321 let session_id = subject
4322 .split('.')
4323 .nth(prefix_count)
4324 .unwrap_or("?")
4325 .to_string();
4326
4327 assert_eq!(session_id, "session-42");
4328 }
4329
4330 #[test]
4331 fn test_round_summary_processes_score_for_own_agent() {
4332 use crate::events::{ProposalScoreEntry, RoundSummaryEvent};
4334 use crate::status::AgentStatusSnapshot;
4335
4336 let event = RoundSummaryEvent {
4337 round: 2,
4338 convergence_score: 0.7,
4339 decisiveness: 0.7,
4340 net_support: vec![],
4341 cesaro_support: vec![],
4342 raw_distance: None,
4343 claim_convergence: None,
4344 total_claims: None,
4345 leader_claim_convergence: None,
4346 leader_total_claims: None,
4347 controversy_scores: vec![],
4348 proposal_scores: vec![
4349 ProposalScoreEntry {
4350 agent_id: "other-agent".into(),
4351 aggregated_score: 6.0,
4352 category_breakdown: None,
4353 controversy_score: None,
4354 ..Default::default()
4355 },
4356 ProposalScoreEntry {
4357 agent_id: "my-agent".into(),
4358 aggregated_score: 8.5,
4359 category_breakdown: None,
4360 controversy_score: None,
4361 ..Default::default()
4362 },
4363 ],
4364 accumulated_evidence: None,
4365 evidence_target: None,
4366 positive_budget: None,
4367 du_dt: None,
4368 signed_consensus: None,
4369 t_opt: None,
4370 thermo_probability: None,
4371 ..Default::default()
4372 };
4373
4374 let my_id = "my-agent";
4375 let session_id = "job-123";
4376 let mut snap = AgentStatusSnapshot::new(my_id.into(), "model".into(), "p".into());
4377
4378 for entry in &event.proposal_scores {
4380 if entry.agent_id == my_id {
4381 let already_has = snap
4382 .recent_scores
4383 .iter()
4384 .any(|s| s.job_id == session_id && s.round == event.round);
4385 if !already_has {
4386 snap.push_score(crate::status::ScoreEntry {
4387 timestamp: "t".into(),
4388 job_id: session_id.into(),
4389 round: event.round,
4390 evaluator: "aggregated".into(),
4391 score: entry.aggregated_score,
4392 });
4393 }
4394 break;
4395 }
4396 }
4397
4398 assert_eq!(snap.recent_scores.len(), 1);
4399 assert!((snap.mean_score.unwrap() - 8.5).abs() < f32::EPSILON);
4400 assert_eq!(snap.recent_scores[0].round, 2);
4401 }
4402
4403 #[test]
4404 fn test_round_summary_dedup_prevents_double_push() {
4405 use crate::events::{ProposalScoreEntry, RoundSummaryEvent};
4407 use crate::status::AgentStatusSnapshot;
4408
4409 let event = RoundSummaryEvent {
4410 round: 1,
4411 convergence_score: 0.5,
4412 decisiveness: 0.5,
4413 net_support: vec![],
4414 cesaro_support: vec![],
4415 raw_distance: None,
4416 claim_convergence: None,
4417 total_claims: None,
4418 leader_claim_convergence: None,
4419 leader_total_claims: None,
4420 controversy_scores: vec![],
4421 proposal_scores: vec![ProposalScoreEntry {
4422 agent_id: "agent-1".into(),
4423 aggregated_score: 7.0,
4424 category_breakdown: None,
4425 controversy_score: None,
4426 ..Default::default()
4427 }],
4428 accumulated_evidence: None,
4429 evidence_target: None,
4430 positive_budget: None,
4431 du_dt: None,
4432 signed_consensus: None,
4433 t_opt: None,
4434 thermo_probability: None,
4435 ..Default::default()
4436 };
4437
4438 let my_id = "agent-1";
4439 let session_id = "job-x";
4440 let mut snap = AgentStatusSnapshot::new(my_id.into(), "m".into(), "p".into());
4441
4442 for _ in 0..2 {
4444 for entry in &event.proposal_scores {
4445 if entry.agent_id == my_id {
4446 let already_has = snap
4447 .recent_scores
4448 .iter()
4449 .any(|s| s.job_id == session_id && s.round == event.round);
4450 if !already_has {
4451 snap.push_score(crate::status::ScoreEntry {
4452 timestamp: "t".into(),
4453 job_id: session_id.into(),
4454 round: event.round,
4455 evaluator: "aggregated".into(),
4456 score: entry.aggregated_score,
4457 });
4458 }
4459 break;
4460 }
4461 }
4462 }
4463
4464 assert_eq!(
4465 snap.recent_scores.len(),
4466 1,
4467 "Dedup should prevent double push"
4468 );
4469 }
4470
4471 #[test]
4472 fn test_round_summary_zero_score_pushed() {
4473 use crate::status::AgentStatusSnapshot;
4476
4477 let mut snap = AgentStatusSnapshot::new("agent-1".into(), "m".into(), "p".into());
4478 snap.push_score(crate::status::ScoreEntry {
4479 timestamp: "t".into(),
4480 job_id: "job-1".into(),
4481 round: 1,
4482 evaluator: "aggregated".into(),
4483 score: 0.0,
4484 });
4485
4486 assert_eq!(
4487 snap.recent_scores.len(),
4488 1,
4489 "Zero score should be pushed (all scores are real)"
4490 );
4491 }
4492
4493 #[test]
4494 fn test_previous_own_score_skips_round_1() {
4495 use crate::status::AgentStatusSnapshot;
4498
4499 let mut snap = AgentStatusSnapshot::new("agent".into(), "m".into(), "p".into());
4500 let round_number: u32 = 1;
4501 let previous_own_score: Option<f32> = Some(5.0);
4502 let session_id = "job-1";
4503
4504 if round_number > 1 {
4506 if let Some(score) = previous_own_score {
4507 let prev_round = round_number.saturating_sub(1);
4508 snap.push_score(crate::status::ScoreEntry {
4509 timestamp: "t".into(),
4510 job_id: session_id.into(),
4511 round: prev_round,
4512 evaluator: "aggregated".into(),
4513 score,
4514 });
4515 }
4516 }
4517
4518 assert!(
4519 snap.recent_scores.is_empty(),
4520 "Round 1 should not push a score for round 0"
4521 );
4522 }
4523
4524 #[test]
4525 fn test_previous_own_score_pushes_for_round_2() {
4526 use crate::status::AgentStatusSnapshot;
4528
4529 let mut snap = AgentStatusSnapshot::new("agent".into(), "m".into(), "p".into());
4530 let round_number: u32 = 3;
4531 let previous_own_score: Option<f32> = Some(7.5);
4532 let session_id = "job-1";
4533
4534 if round_number > 1 {
4535 if let Some(score) = previous_own_score {
4536 let prev_round = round_number.saturating_sub(1);
4537 snap.push_score(crate::status::ScoreEntry {
4538 timestamp: "t".into(),
4539 job_id: session_id.into(),
4540 round: prev_round,
4541 evaluator: "aggregated".into(),
4542 score,
4543 });
4544 }
4545 }
4546
4547 assert_eq!(snap.recent_scores.len(), 1);
4548 assert_eq!(snap.recent_scores[0].round, 2);
4549 assert!((snap.recent_scores[0].score - 7.5).abs() < f32::EPSILON);
4550 }
4551
4552 #[test]
4555 fn test_inject_annotations_empty_eval_array() {
4556 let payload = b"[]";
4557 let entry = make_entry(payload, true, vec![]);
4558
4559 let result = NatsNsedWorker::inject_annotations(&entry);
4560 let val: serde_json::Value = serde_json::from_slice(&result).unwrap();
4561 assert!(val.as_array().unwrap().is_empty());
4562 }
4563
4564 #[test]
4565 fn test_inject_annotations_malformed_tuple_single_element() {
4566 let payload = br#"[["agent-A"]]"#;
4568 let entry = make_entry(payload, true, vec![]);
4569
4570 let result = NatsNsedWorker::inject_annotations(&entry);
4571 let val: serde_json::Value = serde_json::from_slice(&result).unwrap();
4572 let arr = val.as_array().unwrap();
4573 assert_eq!(arr.len(), 1);
4574 let inner = arr[0].as_array().unwrap();
4576 assert_eq!(inner.len(), 1);
4577 assert_eq!(inner[0], "agent-A");
4578 }
4579
4580 #[test]
4581 fn test_inject_annotations_tuple_non_object_at_index_1() {
4582 let payload = br#"[["agent-A", "not-an-object"]]"#;
4584 let entry = make_entry(payload, true, vec![]);
4585
4586 let result = NatsNsedWorker::inject_annotations(&entry);
4587 let val: serde_json::Value = serde_json::from_slice(&result).unwrap();
4588 let inner = val.as_array().unwrap()[0].as_array().unwrap();
4589 assert_eq!(inner[1], "not-an-object"); }
4591
4592 #[test]
4593 fn test_inject_annotations_mixed_valid_and_invalid_tuples() {
4594 use crate::agents::{AnnotationType, OperatorAnnotation};
4595
4596 let payload = br#"[
4598 ["agent-A", {"score": 5.0}],
4599 ["agent-B"],
4600 ["agent-C", {"score": 8.0}]
4601 ]"#;
4602 let annotation = OperatorAnnotation {
4603 annotation_type: AnnotationType::Edit,
4604 comment: "Fixed".into(),
4605 timestamp: "t".into(),
4606 original_content_hash: None,
4607 };
4608 let entry = make_entry(payload, true, vec![annotation]);
4609
4610 let result = NatsNsedWorker::inject_annotations(&entry);
4611 let val: serde_json::Value = serde_json::from_slice(&result).unwrap();
4612 let arr = val.as_array().unwrap();
4613
4614 let a = &arr[0].as_array().unwrap()[1];
4616 assert_eq!(a["edited_by"], "operator");
4617 assert!(a["operator_annotations"].is_array());
4618
4619 let b = arr[1].as_array().unwrap();
4621 assert_eq!(b.len(), 1); let c = &arr[2].as_array().unwrap()[1];
4625 assert_eq!(c["edited_by"], "operator");
4626 }
4627
4628 #[test]
4629 fn test_inject_annotations_non_array_item_in_eval_array() {
4630 let payload = br#"["just-a-string", 42, null]"#;
4632 let entry = make_entry(payload, true, vec![]);
4633
4634 let result = NatsNsedWorker::inject_annotations(&entry);
4635 let val: serde_json::Value = serde_json::from_slice(&result).unwrap();
4636 let arr = val.as_array().unwrap();
4638 assert_eq!(arr.len(), 3);
4639 }
4640
4641 async fn setup_nats() -> Option<async_nats::Client> {
4645 let nats_url =
4646 std::env::var("NATS_URL").unwrap_or_else(|_| "nats://localhost:4222".to_string());
4647 let client = connect_nats(&nats_url, None).await.ok()?;
4648 if client.connection_state() != NatsState::Connected {
4649 return None;
4650 }
4651 Some(client)
4652 }
4653
4654 #[tokio::test]
4662 async fn test_agent_task_redelivered_after_short_ack_wait() {
4663 let client = match setup_nats().await {
4664 Some(c) => c,
4665 None => {
4666 println!("Skipping test: NATS unavailable");
4667 return;
4668 }
4669 };
4670 let js = async_nats::jetstream::new(client.clone());
4671
4672 let unique_id = Uuid::new_v4();
4673 let stream_name = format!("agent_redeliver_test_{}", unique_id);
4674 let agent_id = "test-agent";
4675 let task_subject = format!("{}.session1.task.{}.propose", stream_name, agent_id);
4676
4677 js.create_stream(async_nats::jetstream::stream::Config {
4679 name: stream_name.clone(),
4680 subjects: vec![format!("{}.*.task.{}.>", stream_name, agent_id)],
4681 storage: async_nats::jetstream::stream::StorageType::Memory,
4682 ..Default::default()
4683 })
4684 .await
4685 .expect("create test stream");
4686
4687 let context = serde_json::json!({
4689 "task_description": "Test task for redelivery",
4690 "round_number": 1,
4691 "agent_ids": ["test-agent"],
4692 "session_id": "session1"
4693 });
4694 js.publish(
4695 task_subject.clone(),
4696 serde_json::to_vec(&context).unwrap().into(),
4697 )
4698 .await
4699 .expect("publish task");
4700
4701 let consumer_name = format!("agent_consumer_{}", unique_id);
4703 let stream = js.get_stream(&stream_name).await.expect("get stream");
4704 let consumer = stream
4705 .get_or_create_consumer(
4706 &consumer_name,
4707 async_nats::jetstream::consumer::pull::Config {
4708 durable_name: Some(consumer_name.clone()),
4709 filter_subject: format!("{}.*.task.{}.>", stream_name, agent_id),
4710 ack_policy: async_nats::jetstream::consumer::AckPolicy::Explicit,
4711 ack_wait: std::time::Duration::from_secs(3),
4712 ..Default::default()
4713 },
4714 )
4715 .await
4716 .expect("create consumer");
4717
4718 let mut messages = consumer.messages().await.expect("messages");
4720 let msg = tokio::time::timeout(std::time::Duration::from_secs(5), messages.next())
4721 .await
4722 .expect("timeout waiting for first delivery")
4723 .expect("stream ended")
4724 .expect("message error");
4725
4726 let payload: serde_json::Value = serde_json::from_slice(&msg.payload).expect("deserialize");
4728 assert_eq!(payload["task_description"], "Test task for redelivery");
4729
4730 drop(messages);
4732
4733 tokio::time::sleep(std::time::Duration::from_secs(4)).await;
4735
4736 let consumer2 = stream
4738 .get_or_create_consumer(
4739 &consumer_name,
4740 async_nats::jetstream::consumer::pull::Config {
4741 durable_name: Some(consumer_name.clone()),
4742 filter_subject: format!("{}.*.task.{}.>", stream_name, agent_id),
4743 ack_policy: async_nats::jetstream::consumer::AckPolicy::Explicit,
4744 ack_wait: std::time::Duration::from_secs(3),
4745 ..Default::default()
4746 },
4747 )
4748 .await
4749 .expect("rebind consumer");
4750
4751 let mut messages2 = consumer2.messages().await.expect("messages2");
4752 let redelivered = tokio::time::timeout(std::time::Duration::from_secs(5), messages2.next())
4753 .await
4754 .expect("message should be redelivered within ack_wait window")
4755 .expect("stream ended")
4756 .expect("message error");
4757
4758 let payload2: serde_json::Value =
4759 serde_json::from_slice(&redelivered.payload).expect("deserialize redelivery");
4760 assert_eq!(
4761 payload2["task_description"], "Test task for redelivery",
4762 "Redelivered message should be the same task"
4763 );
4764
4765 if let Ok(info) = redelivered.info() {
4767 assert!(
4768 info.delivered > 1,
4769 "Message should have been delivered more than once (redelivery). Got: {}",
4770 info.delivered
4771 );
4772 }
4773
4774 let _ = redelivered.ack().await;
4776 let _ = js.delete_stream(&stream_name).await;
4777 }
4778
4779 #[tokio::test]
4786 async fn test_progress_heartbeat_prevents_premature_redelivery() {
4787 let client = match setup_nats().await {
4788 Some(c) => c,
4789 None => {
4790 println!("Skipping test: NATS unavailable");
4791 return;
4792 }
4793 };
4794 let js = async_nats::jetstream::new(client.clone());
4795
4796 let unique_id = Uuid::new_v4();
4797 let stream_name = format!("agent_hb_test_{}", unique_id);
4798 let agent_id = "hb-agent";
4799 let task_subject = format!("{}.session1.task.{}.propose", stream_name, agent_id);
4800
4801 js.create_stream(async_nats::jetstream::stream::Config {
4802 name: stream_name.clone(),
4803 subjects: vec![format!("{}.*.task.{}.>", stream_name, agent_id)],
4804 storage: async_nats::jetstream::stream::StorageType::Memory,
4805 ..Default::default()
4806 })
4807 .await
4808 .expect("create test stream");
4809
4810 let context = serde_json::json!({
4811 "task_description": "Heartbeat test task",
4812 "round_number": 1,
4813 "agent_ids": ["hb-agent"],
4814 "session_id": "session1"
4815 });
4816 js.publish(
4817 task_subject.clone(),
4818 serde_json::to_vec(&context).unwrap().into(),
4819 )
4820 .await
4821 .expect("publish task");
4822
4823 let consumer_name = format!("hb_consumer_{}", unique_id);
4824 let stream = js.get_stream(&stream_name).await.expect("get stream");
4825 let consumer = stream
4826 .get_or_create_consumer(
4827 &consumer_name,
4828 async_nats::jetstream::consumer::pull::Config {
4829 durable_name: Some(consumer_name.clone()),
4830 filter_subject: format!("{}.*.task.{}.>", stream_name, agent_id),
4831 ack_policy: async_nats::jetstream::consumer::AckPolicy::Explicit,
4832 ack_wait: std::time::Duration::from_secs(3), ..Default::default()
4834 },
4835 )
4836 .await
4837 .expect("create consumer");
4838
4839 let mut messages = consumer.messages().await.expect("messages");
4840 let msg = tokio::time::timeout(std::time::Duration::from_secs(5), messages.next())
4841 .await
4842 .expect("timeout")
4843 .expect("stream ended")
4844 .expect("message error");
4845
4846 tokio::time::sleep(std::time::Duration::from_secs(2)).await;
4848 msg.ack_with(async_nats::jetstream::AckKind::Progress)
4849 .await
4850 .expect("progress ack");
4851
4852 tokio::time::sleep(std::time::Duration::from_secs(2)).await;
4854
4855 msg.ack().await.expect("final ack");
4857
4858 drop(messages);
4860
4861 let consumer3 = stream
4864 .get_or_create_consumer(
4865 &consumer_name,
4866 async_nats::jetstream::consumer::pull::Config {
4867 durable_name: Some(consumer_name.clone()),
4868 filter_subject: format!("{}.*.task.{}.>", stream_name, agent_id),
4869 ack_policy: async_nats::jetstream::consumer::AckPolicy::Explicit,
4870 ack_wait: std::time::Duration::from_secs(3),
4871 ..Default::default()
4872 },
4873 )
4874 .await
4875 .expect("rebind consumer");
4876
4877 let mut messages3 = consumer3.messages().await.expect("messages3");
4878 let result =
4879 tokio::time::timeout(std::time::Duration::from_secs(4), messages3.next()).await;
4880
4881 assert!(
4883 result.is_err(),
4884 "No message should be redelivered after successful Progress+Ack"
4885 );
4886
4887 let _ = js.delete_stream(&stream_name).await;
4889 }
4890
4891 #[derive(Debug, Clone)]
4896 struct MockAgent;
4897
4898 #[async_trait]
4899 impl NsedAgent for MockAgent {
4900 async fn propose(&self, _context: &AgentContext) -> Result<crate::agents::Proposal> {
4901 Ok(crate::agents::Proposal {
4902 content: "mock".into(),
4903 ..Default::default()
4904 })
4905 }
4906 async fn evaluate(
4907 &self,
4908 _context: &AgentContext,
4909 ) -> Result<Vec<(String, crate::agents::Evaluation)>> {
4910 Ok(vec![])
4911 }
4912 fn name(&self) -> String {
4913 "mock-agent".into()
4914 }
4915 }
4916
4917 #[tokio::test]
4922 async fn test_worker_pause_resume() {
4923 let client = match setup_nats().await {
4924 Some(c) => c,
4925 None => {
4926 println!("Skipping test: NATS unavailable");
4927 return;
4928 }
4929 };
4930 drop(client); let config = WorkerConfig::new(
4933 std::env::var("NATS_URL").unwrap_or_else(|_| "nats://localhost:4222".to_string()),
4934 format!("test_pause_resume_{}", Uuid::new_v4()),
4935 format!("consumer_pause_resume_{}", Uuid::new_v4()),
4936 );
4937 let agent_config = AgentConfig {
4938 name: "mock-agent".into(),
4939 provider_id: "test".into(),
4940 model_name: "test-model".into(),
4941 ..Default::default()
4942 };
4943
4944 let worker = NatsNsedWorker::new(MockAgent, agent_config, config, None)
4945 .await
4946 .expect("worker creation should succeed");
4947
4948 assert!(!worker.is_paused(), "worker should start unpaused");
4950
4951 worker.pause();
4953 assert!(worker.is_paused(), "worker should be paused after pause()");
4954
4955 worker.resume();
4957 assert!(
4958 !worker.is_paused(),
4959 "worker should be unpaused after resume()"
4960 );
4961
4962 worker.pause();
4964 worker.pause();
4965 assert!(worker.is_paused(), "double pause should still be paused");
4966
4967 worker.resume();
4969 assert!(!worker.is_paused(), "single resume should unpause");
4970 }
4971
4972 #[tokio::test]
4973 async fn test_worker_with_response_buffer() {
4974 let client = match setup_nats().await {
4975 Some(c) => c,
4976 None => {
4977 println!("Skipping test: NATS unavailable");
4978 return;
4979 }
4980 };
4981 drop(client);
4982
4983 let config = WorkerConfig::new(
4984 std::env::var("NATS_URL").unwrap_or_else(|_| "nats://localhost:4222".to_string()),
4985 format!("test_buffer_{}", Uuid::new_v4()),
4986 format!("consumer_buffer_{}", Uuid::new_v4()),
4987 );
4988 let agent_config = AgentConfig {
4989 name: "mock-agent".into(),
4990 provider_id: "test".into(),
4991 model_name: "test-model".into(),
4992 ..Default::default()
4993 };
4994
4995 let worker = NatsNsedWorker::new(MockAgent, agent_config.clone(), config.clone(), None)
4997 .await
4998 .expect("worker creation should succeed");
4999 assert!(worker.response_buffer().is_none(), "no buffer by default");
5000
5001 let config2 = WorkerConfig::new(
5003 std::env::var("NATS_URL").unwrap_or_else(|_| "nats://localhost:4222".to_string()),
5004 format!("test_buffer2_{}", Uuid::new_v4()),
5005 format!("consumer_buffer2_{}", Uuid::new_v4()),
5006 );
5007 let worker = NatsNsedWorker::new(MockAgent, agent_config, config2, None)
5008 .await
5009 .expect("worker creation should succeed")
5010 .with_response_buffer(std::time::Duration::from_secs(30));
5011
5012 assert!(
5013 worker.response_buffer().is_some(),
5014 "buffer should be set after with_response_buffer()"
5015 );
5016
5017 let handle = worker.pause_handle();
5019 assert!(
5020 !handle.load(Ordering::Relaxed),
5021 "handle should start as false"
5022 );
5023
5024 worker.pause();
5026 assert!(
5027 handle.load(Ordering::Relaxed),
5028 "handle should reflect paused state"
5029 );
5030
5031 assert!(
5033 worker.response_buffer().unwrap().is_paused(),
5034 "buffer should be paused when worker is paused"
5035 );
5036
5037 worker.resume();
5039 assert!(
5040 !handle.load(Ordering::Relaxed),
5041 "handle should reflect unpaused state"
5042 );
5043 assert!(
5044 !worker.response_buffer().unwrap().is_paused(),
5045 "buffer should be unpaused when worker is resumed"
5046 );
5047 }
5048
5049 #[tokio::test]
5057 async fn test_worker_pause_handle_external_mutation() {
5058 let client = match setup_nats().await {
5059 Some(c) => c,
5060 None => {
5061 println!("Skipping test: NATS unavailable");
5062 return;
5063 }
5064 };
5065 drop(client);
5066
5067 let config = WorkerConfig::new(
5068 std::env::var("NATS_URL").unwrap_or_else(|_| "nats://localhost:4222".to_string()),
5069 format!("test_handle_{}", Uuid::new_v4()),
5070 format!("consumer_handle_{}", Uuid::new_v4()),
5071 );
5072 let agent_config = AgentConfig {
5073 name: "mock-agent".into(),
5074 provider_id: "test".into(),
5075 model_name: "test-model".into(),
5076 ..Default::default()
5077 };
5078
5079 let worker = NatsNsedWorker::new(MockAgent, agent_config, config, None)
5080 .await
5081 .expect("worker creation should succeed");
5082
5083 let handle = worker.pause_handle();
5084
5085 handle.store(true, Ordering::Relaxed);
5087 assert!(
5088 worker.is_paused(),
5089 "is_paused() should reflect external handle mutation"
5090 );
5091
5092 handle.store(false, Ordering::Relaxed);
5093 assert!(
5094 !worker.is_paused(),
5095 "is_paused() should reflect external handle un-mutation"
5096 );
5097 }
5098
5099 #[test]
5104 fn test_inject_annotations_primitive_json_value_passthrough() {
5105 for payload in &[
5108 br#""just a string""#.to_vec(),
5109 b"42".to_vec(),
5110 b"true".to_vec(),
5111 b"null".to_vec(),
5112 ] {
5113 let entry = make_entry(payload, true, vec![]);
5114 let result = NatsNsedWorker::inject_annotations(&entry);
5115 let original: serde_json::Value = serde_json::from_slice(payload).unwrap();
5117 let returned: serde_json::Value = serde_json::from_slice(&result).unwrap();
5118 assert_eq!(
5119 original, returned,
5120 "primitive JSON should pass through unchanged"
5121 );
5122 }
5123 }
5124
5125 #[test]
5126 fn test_inject_annotations_proposal_edited_no_annotations() {
5127 let payload = br#"{"content":"hello"}"#;
5130 let entry = make_entry(payload, true, vec![]);
5131
5132 let result = NatsNsedWorker::inject_annotations(&entry);
5133 let val: serde_json::Value = serde_json::from_slice(&result).unwrap();
5134
5135 assert_eq!(val["edited_by"], "operator");
5136 assert!(
5137 val.get("operator_annotations").is_none(),
5138 "empty annotations should not produce operator_annotations key"
5139 );
5140 }
5141
5142 #[test]
5143 fn test_inject_annotations_proposal_annotations_no_edit() {
5144 use crate::agents::{AnnotationType, OperatorAnnotation};
5147
5148 let payload = br#"{"content":"hello"}"#;
5149 let annotation = OperatorAnnotation {
5150 annotation_type: AnnotationType::Comment,
5151 comment: "Reviewed".into(),
5152 timestamp: "t".into(),
5153 original_content_hash: None,
5154 };
5155 let entry = make_entry(payload, false, vec![annotation]);
5156
5157 let result = NatsNsedWorker::inject_annotations(&entry);
5158 let val: serde_json::Value = serde_json::from_slice(&result).unwrap();
5159
5160 assert!(val["operator_annotations"].is_array());
5161 assert_eq!(val["operator_annotations"].as_array().unwrap().len(), 1);
5162 assert!(
5163 val.get("edited_by").is_none(),
5164 "should NOT add edited_by when edited=false"
5165 );
5166 }
5167
5168 #[test]
5169 fn test_inject_annotations_proposal_multiple_annotations() {
5170 use crate::agents::{AnnotationType, OperatorAnnotation};
5171
5172 let payload = br#"{"content":"test"}"#;
5173 let annotations = vec![
5174 OperatorAnnotation {
5175 annotation_type: AnnotationType::Comment,
5176 comment: "First comment".into(),
5177 timestamp: "t1".into(),
5178 original_content_hash: None,
5179 },
5180 OperatorAnnotation {
5181 annotation_type: AnnotationType::Edit,
5182 comment: "Edited".into(),
5183 timestamp: "t2".into(),
5184 original_content_hash: Some("hash123".into()),
5185 },
5186 OperatorAnnotation {
5187 annotation_type: AnnotationType::Comment,
5188 comment: "Final LGTM".into(),
5189 timestamp: "t3".into(),
5190 original_content_hash: None,
5191 },
5192 ];
5193 let entry = make_entry(payload, true, annotations);
5194
5195 let result = NatsNsedWorker::inject_annotations(&entry);
5196 let val: serde_json::Value = serde_json::from_slice(&result).unwrap();
5197
5198 assert_eq!(val["edited_by"], "operator");
5199 let ann_arr = val["operator_annotations"].as_array().unwrap();
5200 assert_eq!(ann_arr.len(), 3);
5201 assert_eq!(ann_arr[0]["comment"], "First comment");
5202 assert_eq!(ann_arr[1]["comment"], "Edited");
5203 assert_eq!(ann_arr[2]["comment"], "Final LGTM");
5204 }
5205
5206 #[test]
5207 fn test_inject_annotations_eval_edited_no_annotations() {
5208 let payload = br#"[["agent-A", {"score": 5.0}], ["agent-B", {"score": 7.0}]]"#;
5211 let entry = make_entry(payload, true, vec![]);
5212
5213 let result = NatsNsedWorker::inject_annotations(&entry);
5214 let val: serde_json::Value = serde_json::from_slice(&result).unwrap();
5215
5216 let arr = val.as_array().unwrap();
5217 for item in arr {
5218 let eval = &item.as_array().unwrap()[1];
5219 assert_eq!(eval["edited_by"], "operator");
5220 assert!(
5221 eval.get("operator_annotations").is_none(),
5222 "no annotations should produce no operator_annotations key"
5223 );
5224 }
5225 }
5226
5227 #[test]
5228 fn test_inject_annotations_eval_non_array_items_skipped() {
5229 use crate::agents::{AnnotationType, OperatorAnnotation};
5232
5233 let payload = br#"[42, ["agent-A", {"score": 5.0}], null, "string"]"#;
5234 let annotation = OperatorAnnotation {
5235 annotation_type: AnnotationType::Comment,
5236 comment: "test".into(),
5237 timestamp: "t".into(),
5238 original_content_hash: None,
5239 };
5240 let entry = make_entry(payload, true, vec![annotation]);
5241
5242 let result = NatsNsedWorker::inject_annotations(&entry);
5243 let val: serde_json::Value = serde_json::from_slice(&result).unwrap();
5244 let arr = val.as_array().unwrap();
5245 assert_eq!(arr.len(), 4);
5246 let eval_a = &arr[1].as_array().unwrap()[1];
5248 assert_eq!(eval_a["edited_by"], "operator");
5249 assert!(eval_a["operator_annotations"].is_array());
5250 assert_eq!(arr[0], 42);
5252 assert!(arr[2].is_null());
5253 assert_eq!(arr[3], "string");
5254 }
5255
5256 fn is_payment_error(err_str: &str) -> bool {
5263 err_str.contains("402 Payment Required")
5264 || err_str.contains("insufficient_quota")
5265 || err_str.contains("billing")
5266 }
5267
5268 fn should_suppress_error_event(err_str: &str, propagate_payment_error: bool) -> bool {
5269 is_payment_error(err_str) && !propagate_payment_error
5270 }
5271
5272 #[test]
5273 fn test_payment_error_402() {
5274 assert!(is_payment_error("HTTP error: 402 Payment Required"));
5275 assert!(is_payment_error("402 Payment Required: no credits"));
5276 }
5277
5278 #[test]
5279 fn test_payment_error_insufficient_quota() {
5280 assert!(is_payment_error("OpenAI error: insufficient_quota"));
5281 assert!(is_payment_error("insufficient_quota for this model"));
5282 }
5283
5284 #[test]
5285 fn test_payment_error_billing() {
5286 assert!(is_payment_error("Your billing account has been suspended"));
5287 assert!(is_payment_error("billing information required"));
5288 }
5289
5290 #[test]
5291 fn test_non_payment_errors_not_detected() {
5292 assert!(!is_payment_error("500 Internal Server Error"));
5293 assert!(!is_payment_error("Connection timeout"));
5294 assert!(!is_payment_error("rate limit exceeded"));
5295 assert!(!is_payment_error("model not found"));
5296 assert!(!is_payment_error(""));
5297 }
5298
5299 #[test]
5300 fn test_suppress_error_when_payment_and_not_propagate() {
5301 assert!(should_suppress_error_event("402 Payment Required", false));
5303 assert!(should_suppress_error_event("insufficient_quota", false));
5304 assert!(should_suppress_error_event("billing issue", false));
5305 }
5306
5307 #[test]
5308 fn test_no_suppress_when_payment_and_propagate() {
5309 assert!(!should_suppress_error_event("402 Payment Required", true));
5311 assert!(!should_suppress_error_event("insufficient_quota", true));
5312 }
5313
5314 #[test]
5315 fn test_no_suppress_when_not_payment_error() {
5316 assert!(!should_suppress_error_event(
5318 "500 Internal Server Error",
5319 false
5320 ));
5321 assert!(!should_suppress_error_event("Connection refused", true));
5322 }
5323
5324 #[test]
5329 fn test_heartbeat_status_idle_when_no_active_jobs() {
5330 let active_jobs: HashSet<String> = HashSet::new();
5331 let active_job = active_jobs.iter().next().cloned();
5332 let status = if active_job.is_some() {
5333 AgentLiveStatus::Busy
5334 } else {
5335 AgentLiveStatus::Idle
5336 };
5337 assert_eq!(status, AgentLiveStatus::Idle);
5338 assert!(active_job.is_none());
5339 }
5340
5341 #[test]
5342 fn test_heartbeat_status_busy_when_active_job() {
5343 let mut active_jobs: HashSet<String> = HashSet::new();
5344 active_jobs.insert("session-123".to_string());
5345 let active_job = active_jobs.iter().next().cloned();
5346 let status = if active_job.is_some() {
5347 AgentLiveStatus::Busy
5348 } else {
5349 AgentLiveStatus::Idle
5350 };
5351 assert_eq!(status, AgentLiveStatus::Busy);
5352 assert!(active_job.is_some());
5353 }
5354
5355 #[test]
5356 fn test_heartbeat_status_busy_with_multiple_active_jobs() {
5357 let mut active_jobs: HashSet<String> = HashSet::new();
5358 active_jobs.insert("session-1".to_string());
5359 active_jobs.insert("session-2".to_string());
5360 let active_job = active_jobs.iter().next().cloned();
5361 assert!(active_job.is_some());
5363 let status = if active_job.is_some() {
5364 AgentLiveStatus::Busy
5365 } else {
5366 AgentLiveStatus::Idle
5367 };
5368 assert_eq!(status, AgentLiveStatus::Busy);
5369 }
5370
5371 #[test]
5376 fn test_heartbeat_error_extraction_no_status() {
5377 let status: Option<&str> = None;
5379 let (tasks_completed, tasks_failed, last_error): (u64, u64, Option<String>) =
5380 if status.is_some() {
5381 unreachable!()
5382 } else {
5383 (0, 0, None)
5384 };
5385 assert_eq!(tasks_completed, 0);
5386 assert_eq!(tasks_failed, 0);
5387 assert!(last_error.is_none());
5388 }
5389
5390 #[test]
5391 fn test_heartbeat_error_extraction_from_task_log() {
5392 use crate::status::{AgentStatusSnapshot, TaskLogEntry};
5393
5394 let mut snap = AgentStatusSnapshot::new("agent-1".into(), "model".into(), "p".into());
5395
5396 snap.push_task(TaskLogEntry {
5398 timestamp: "t1".into(),
5399 action: "propose".into(),
5400 job_id: "job-1".into(),
5401 round: 1,
5402 status: "ok".into(),
5403 duration_ms: 100,
5404 content_preview: None,
5405 });
5406
5407 snap.push_task(TaskLogEntry {
5409 timestamp: "t2".into(),
5410 action: "evaluate".into(),
5411 job_id: "job-2".into(),
5412 round: 1,
5413 status: "error".into(),
5414 duration_ms: 200,
5415 content_preview: Some("Error: connection timeout".into()),
5416 });
5417
5418 let err = snap
5420 .recent_tasks
5421 .iter()
5422 .find(|t| t.status == "error")
5423 .map(|t| {
5424 let msg = format!("{}: {}", t.action, t.job_id);
5425 msg.chars().take(120).collect::<String>()
5426 });
5427
5428 assert!(err.is_some());
5429 assert_eq!(err.unwrap(), "evaluate: job-2");
5430 }
5431
5432 #[test]
5433 fn test_heartbeat_error_truncation_120_chars() {
5434 use crate::status::{AgentStatusSnapshot, TaskLogEntry};
5435
5436 let mut snap = AgentStatusSnapshot::new("agent-1".into(), "model".into(), "p".into());
5437
5438 let long_job_id = "a".repeat(200);
5440 snap.push_task(TaskLogEntry {
5441 timestamp: "t1".into(),
5442 action: "propose".into(),
5443 job_id: long_job_id.clone(),
5444 round: 1,
5445 status: "error".into(),
5446 duration_ms: 500,
5447 content_preview: None,
5448 });
5449
5450 let err = snap
5451 .recent_tasks
5452 .iter()
5453 .find(|t| t.status == "error")
5454 .map(|t| {
5455 let msg = format!("{}: {}", t.action, t.job_id);
5456 msg.chars().take(120).collect::<String>()
5457 });
5458
5459 assert!(err.is_some());
5460 let err_msg = err.unwrap();
5461 assert_eq!(
5462 err_msg.chars().count(),
5463 120,
5464 "error should be truncated to 120 chars"
5465 );
5466 assert!(err_msg.starts_with("propose: "));
5467 }
5468
5469 #[test]
5470 fn test_heartbeat_error_no_error_tasks() {
5471 use crate::status::{AgentStatusSnapshot, TaskLogEntry};
5472
5473 let mut snap = AgentStatusSnapshot::new("agent-1".into(), "model".into(), "p".into());
5474
5475 snap.push_task(TaskLogEntry {
5477 timestamp: "t1".into(),
5478 action: "propose".into(),
5479 job_id: "job-1".into(),
5480 round: 1,
5481 status: "ok".into(),
5482 duration_ms: 100,
5483 content_preview: None,
5484 });
5485
5486 let err = snap
5487 .recent_tasks
5488 .iter()
5489 .find(|t| t.status == "error")
5490 .map(|t| {
5491 let msg = format!("{}: {}", t.action, t.job_id);
5492 msg.chars().take(120).collect::<String>()
5493 });
5494
5495 assert!(err.is_none());
5496 }
5497
5498 #[test]
5503 fn test_extract_preview_proposal_basic() {
5504 let payload = serde_json::json!({
5505 "content": "Hello world proposal",
5506 "thought_process": "I thought about it"
5507 });
5508 let bytes = serde_json::to_vec(&payload).unwrap();
5509
5510 let preview = extract_content_preview(&bytes, "propose", &[]);
5511 assert!(preview.is_some());
5512
5513 let parsed: serde_json::Value = serde_json::from_str(&preview.unwrap()).unwrap();
5514 assert_eq!(parsed["t"], "p");
5515 assert_eq!(parsed["c"], "Hello world proposal");
5516 assert_eq!(parsed["tp"], "I thought about it");
5517 }
5518
5519 #[test]
5520 fn test_extract_preview_proposal_no_thought_process() {
5521 let payload = serde_json::json!({
5522 "content": "Simple proposal"
5523 });
5524 let bytes = serde_json::to_vec(&payload).unwrap();
5525
5526 let preview = extract_content_preview(&bytes, "propose", &[]);
5527 assert!(preview.is_some());
5528
5529 let parsed: serde_json::Value = serde_json::from_str(&preview.unwrap()).unwrap();
5530 assert_eq!(parsed["t"], "p");
5531 assert_eq!(parsed["c"], "Simple proposal");
5532 assert!(parsed.get("tp").is_none());
5534 }
5535
5536 #[test]
5537 fn test_extract_preview_proposal_empty_content() {
5538 let payload = serde_json::json!({
5539 "content": "",
5540 "thought_process": "I thought but had nothing to say"
5541 });
5542 let bytes = serde_json::to_vec(&payload).unwrap();
5543
5544 let preview = extract_content_preview(&bytes, "propose", &[]);
5545 assert!(preview.is_none(), "empty content should return None");
5546 }
5547
5548 #[test]
5549 fn test_extract_preview_proposal_truncates_long_content() {
5550 let long_content = "x".repeat(3000);
5551 let payload = serde_json::json!({
5552 "content": long_content,
5553 "thought_process": ""
5554 });
5555 let bytes = serde_json::to_vec(&payload).unwrap();
5556
5557 let preview = extract_content_preview(&bytes, "propose", &[]);
5558 assert!(preview.is_some());
5559
5560 let parsed: serde_json::Value = serde_json::from_str(&preview.unwrap()).unwrap();
5561 let c = parsed["c"].as_str().unwrap();
5562 assert!(
5564 c.chars().count() <= 2002,
5565 "content should be truncated to ~2000 chars, got {}",
5566 c.chars().count()
5567 );
5568 assert!(
5569 c.ends_with('\u{2026}'),
5570 "truncated content should end with ellipsis"
5571 );
5572 }
5573
5574 #[test]
5575 fn test_extract_preview_proposal_truncates_long_thought_process() {
5576 let long_tp = "y".repeat(1000);
5577 let payload = serde_json::json!({
5578 "content": "brief",
5579 "thought_process": long_tp
5580 });
5581 let bytes = serde_json::to_vec(&payload).unwrap();
5582
5583 let preview = extract_content_preview(&bytes, "propose", &[]);
5584 assert!(preview.is_some());
5585
5586 let parsed: serde_json::Value = serde_json::from_str(&preview.unwrap()).unwrap();
5587 let tp = parsed["tp"].as_str().unwrap();
5588 assert!(
5590 tp.chars().count() <= 502,
5591 "tp should be truncated to ~500 chars, got {}",
5592 tp.chars().count()
5593 );
5594 assert!(
5595 tp.ends_with('\u{2026}'),
5596 "truncated tp should end with ellipsis"
5597 );
5598 }
5599
5600 #[test]
5601 fn test_extract_preview_evaluation_basic() {
5602 let payload = serde_json::json!([
5603 ["agent-A", {"score": 7.5, "justification": "Good work", "stance": "agree"}],
5604 ["agent-B", {"score": 4.0, "justification": "Needs improvement", "textual_feedback": "Try harder"}]
5605 ]);
5606 let bytes = serde_json::to_vec(&payload).unwrap();
5607
5608 let preview = extract_content_preview(&bytes, "evaluate", &[]);
5609 assert!(preview.is_some());
5610
5611 let parsed: serde_json::Value = serde_json::from_str(&preview.unwrap()).unwrap();
5612 assert_eq!(parsed["t"], "e");
5613 let evals = parsed["evals"].as_array().unwrap();
5614 assert_eq!(evals.len(), 2);
5615
5616 assert_eq!(evals[0]["target"], "agent-A");
5617 assert_eq!(evals[0]["s"], 7.5);
5618 assert_eq!(evals[0]["j"], "Good work");
5619 assert_eq!(evals[0]["stance"], "agree");
5620
5621 assert_eq!(evals[1]["target"], "agent-B");
5622 assert_eq!(evals[1]["s"], 4.0);
5623 assert_eq!(evals[1]["tf"], "Try harder");
5624 }
5625
5626 #[test]
5627 fn test_extract_preview_evaluation_empty_array() {
5628 let payload = serde_json::json!([]);
5629 let bytes = serde_json::to_vec(&payload).unwrap();
5630
5631 let preview = extract_content_preview(&bytes, "evaluate", &[]);
5632 assert!(preview.is_none(), "empty eval array should return None");
5633 }
5634
5635 #[test]
5636 fn test_extract_preview_evaluation_truncates_justification() {
5637 let long_justification = "z".repeat(500);
5638 let payload = serde_json::json!([
5639 ["agent-A", {"score": 5.0, "justification": long_justification}]
5640 ]);
5641 let bytes = serde_json::to_vec(&payload).unwrap();
5642
5643 let preview = extract_content_preview(&bytes, "evaluate", &[]);
5644 assert!(preview.is_some());
5645
5646 let parsed: serde_json::Value = serde_json::from_str(&preview.unwrap()).unwrap();
5647 let j = parsed["evals"][0]["j"].as_str().unwrap();
5648 assert!(
5649 j.chars().count() <= 302,
5650 "justification should be truncated to ~300 chars, got {}",
5651 j.chars().count()
5652 );
5653 assert!(
5654 j.ends_with('\u{2026}'),
5655 "truncated justification should end with ellipsis"
5656 );
5657 }
5658
5659 #[test]
5660 fn test_extract_preview_evaluation_truncates_textual_feedback() {
5661 let long_tf = "w".repeat(400);
5662 let payload = serde_json::json!([
5663 ["agent-A", {"score": 5.0, "textual_feedback": long_tf}]
5664 ]);
5665 let bytes = serde_json::to_vec(&payload).unwrap();
5666
5667 let preview = extract_content_preview(&bytes, "evaluate", &[]);
5668 assert!(preview.is_some());
5669
5670 let parsed: serde_json::Value = serde_json::from_str(&preview.unwrap()).unwrap();
5671 let tf = parsed["evals"][0]["tf"].as_str().unwrap();
5672 assert!(
5673 tf.chars().count() <= 202,
5674 "tf should be truncated to ~200 chars, got {}",
5675 tf.chars().count()
5676 );
5677 assert!(
5678 tf.ends_with('\u{2026}'),
5679 "truncated tf should end with ellipsis"
5680 );
5681 }
5682
5683 #[test]
5684 fn test_extract_preview_evaluation_with_category_scores() {
5685 let payload = serde_json::json!([
5686 ["agent-A", {
5687 "score": 6.0,
5688 "justification": "OK",
5689 "category_scores": {"accuracy": 7, "clarity": 8}
5690 }]
5691 ]);
5692 let bytes = serde_json::to_vec(&payload).unwrap();
5693
5694 let preview = extract_content_preview(&bytes, "evaluate", &[]);
5695 assert!(preview.is_some());
5696
5697 let parsed: serde_json::Value = serde_json::from_str(&preview.unwrap()).unwrap();
5698 let cats = &parsed["evals"][0]["cats"];
5699 assert_eq!(cats["accuracy"], 7);
5700 assert_eq!(cats["clarity"], 8);
5701 }
5702
5703 #[test]
5704 fn test_extract_preview_evaluation_with_claim_assessments() {
5705 let payload = serde_json::json!([
5706 ["agent-A", {
5707 "score": 6.0,
5708 "justification": "OK",
5709 "claim_assessments": [{"claim": "X", "verdict": "agree"}]
5710 }]
5711 ]);
5712 let bytes = serde_json::to_vec(&payload).unwrap();
5713
5714 let preview = extract_content_preview(&bytes, "evaluate", &[]);
5715 assert!(preview.is_some());
5716
5717 let parsed: serde_json::Value = serde_json::from_str(&preview.unwrap()).unwrap();
5718 let claims = &parsed["evals"][0]["claims"];
5719 assert!(claims.is_array());
5720 assert_eq!(claims[0]["claim"], "X");
5721 }
5722
5723 #[test]
5724 fn test_extract_preview_evaluation_with_disagreements() {
5725 let payload = serde_json::json!([
5726 ["agent-A", {
5727 "score": 3.0,
5728 "justification": "Bad",
5729 "disagreements": ["point 1", "point 2"]
5730 }]
5731 ]);
5732 let bytes = serde_json::to_vec(&payload).unwrap();
5733
5734 let preview = extract_content_preview(&bytes, "evaluate", &[]);
5735 assert!(preview.is_some());
5736
5737 let parsed: serde_json::Value = serde_json::from_str(&preview.unwrap()).unwrap();
5738 let disputes = &parsed["evals"][0]["disputes"];
5739 assert!(disputes.is_array());
5740 assert_eq!(disputes.as_array().unwrap().len(), 2);
5741 }
5742
5743 #[test]
5744 fn test_extract_preview_evaluation_caps_at_10_entries() {
5745 let mut evals = Vec::new();
5747 for i in 0..15 {
5748 evals.push(serde_json::json!([
5749 format!("agent-{}", i),
5750 {"score": i as f64, "justification": "ok"}
5751 ]));
5752 }
5753 let payload = serde_json::Value::Array(evals);
5754 let bytes = serde_json::to_vec(&payload).unwrap();
5755
5756 let preview = extract_content_preview(&bytes, "evaluate", &[]);
5757 assert!(preview.is_some());
5758
5759 let parsed: serde_json::Value = serde_json::from_str(&preview.unwrap()).unwrap();
5760 let eval_arr = parsed["evals"].as_array().unwrap();
5761 assert_eq!(eval_arr.len(), 10, "should cap at 10 eval entries");
5762 }
5763
5764 #[test]
5765 fn test_extract_preview_unknown_action_returns_none() {
5766 let payload = serde_json::json!({"content": "test"});
5767 let bytes = serde_json::to_vec(&payload).unwrap();
5768
5769 let preview = extract_content_preview(&bytes, "unknown_action", &[]);
5770 assert!(preview.is_none(), "unknown action should return None");
5771 }
5772
5773 #[test]
5774 fn test_extract_preview_invalid_json_returns_none() {
5775 let bytes = b"not valid json at all";
5776 let preview = extract_content_preview(bytes, "propose", &[]);
5777 assert!(preview.is_none(), "invalid JSON should return None");
5778 }
5779
5780 #[test]
5781 fn test_extract_preview_proposal_missing_content_field() {
5782 let payload = serde_json::json!({"thought_process": "thinking..."});
5783 let bytes = serde_json::to_vec(&payload).unwrap();
5784
5785 let preview = extract_content_preview(&bytes, "propose", &[]);
5786 assert!(
5788 preview.is_none(),
5789 "missing content field should return None"
5790 );
5791 }
5792
5793 #[test]
5794 fn test_extract_preview_evaluation_malformed_tuple() {
5795 let payload = serde_json::json!(["not-a-tuple", 42]);
5797 let bytes = serde_json::to_vec(&payload).unwrap();
5798
5799 let preview = extract_content_preview(&bytes, "evaluate", &[]);
5800 assert!(
5801 preview.is_none(),
5802 "non-tuple items should produce empty evals → None"
5803 );
5804 }
5805
5806 #[test]
5807 fn test_extract_preview_evaluation_tuple_missing_eval_obj() {
5808 let payload = serde_json::json!([["agent-A"]]);
5810 let bytes = serde_json::to_vec(&payload).unwrap();
5811
5812 let preview = extract_content_preview(&bytes, "evaluate", &[]);
5813 assert!(
5814 preview.is_none(),
5815 "tuple without eval obj at index 1 should produce empty evals → None"
5816 );
5817 }
5818
5819 #[test]
5824 fn test_worker_config_with_api_prefix() {
5825 let config = WorkerConfig::new(
5826 "nats://localhost:4222".to_string(),
5827 "stream".to_string(),
5828 "consumer".to_string(),
5829 )
5830 .with_api_prefix("my_api".to_string());
5831
5832 assert_eq!(config.api_prefix, "my_api");
5833 assert_eq!(config.subject_prefix, "nsed");
5835 assert_eq!(config.scratchpad_retention_secs, 86400 * 7);
5836 }
5837
5838 #[test]
5843 fn test_round_summary_subject_parsing_missing_segment_fallback() {
5844 let prefix = "nsed.v2.extra"; let subject = "nsed.v2.extra"; let prefix_count = if prefix.is_empty() {
5849 0
5850 } else {
5851 prefix.split('.').count()
5852 };
5853 let session_id = subject
5854 .split('.')
5855 .nth(prefix_count) .unwrap_or("?")
5857 .to_string();
5858
5859 assert_eq!(session_id, "?", "missing segment should fallback to '?'");
5860 }
5861
5862 #[test]
5863 fn test_round_summary_subject_parsing_single_segment() {
5864 let prefix = "nsed";
5866 let subject = "nsed.my-session";
5867
5868 let prefix_count = prefix.split('.').count(); let session_id = subject
5870 .split('.')
5871 .nth(prefix_count)
5872 .unwrap_or("?")
5873 .to_string();
5874
5875 assert_eq!(session_id, "my-session");
5876 }
5877
5878 #[tokio::test]
5883 async fn test_auto_approve_enabled_by_default() {
5884 let buf = buffer::ResponseBuffer::new(std::time::Duration::from_secs(60));
5885 assert!(
5886 buf.is_auto_approve(),
5887 "auto-approve should be ON by default"
5888 );
5889 }
5890
5891 #[tokio::test]
5892 async fn test_auto_approve_enable_disable() {
5893 let buf = buffer::ResponseBuffer::new(std::time::Duration::from_secs(60));
5894 assert!(buf.is_auto_approve());
5895 buf.set_auto_approve(false);
5896 assert!(!buf.is_auto_approve());
5897 buf.set_auto_approve(true);
5898 assert!(buf.is_auto_approve());
5899 }
5900
5901 #[tokio::test]
5902 async fn test_auto_approve_threshold_default() {
5903 let buf = buffer::ResponseBuffer::new(std::time::Duration::from_secs(60));
5907 let threshold = buf.auto_approve_threshold();
5908 assert!(
5909 (threshold - 1.0).abs() < 0.01,
5910 "default threshold should be 1.0 (release everything), got {}",
5911 threshold
5912 );
5913 }
5914
5915 #[tokio::test]
5916 async fn test_auto_approve_threshold_set() {
5917 let buf = buffer::ResponseBuffer::new(std::time::Duration::from_secs(60));
5918 buf.set_auto_approve_threshold(0.3);
5919 assert!((buf.auto_approve_threshold() - 0.3).abs() < 0.01);
5920 }
5921
5922 #[tokio::test]
5923 async fn test_auto_approve_threshold_clamps() {
5924 let buf = buffer::ResponseBuffer::new(std::time::Duration::from_secs(60));
5925 buf.set_auto_approve_threshold(2.0);
5926 assert!(
5927 (buf.auto_approve_threshold() - 1.0).abs() < 0.01,
5928 "should clamp to 1.0"
5929 );
5930 buf.set_auto_approve_threshold(-0.5);
5931 assert!(
5932 (buf.auto_approve_threshold() - 0.0).abs() < 0.01,
5933 "should clamp to 0.0"
5934 );
5935 }
5936
5937 #[tokio::test]
5938 async fn test_auto_release_disabled_returns_zero() {
5939 let buf = buffer::ResponseBuffer::new(std::time::Duration::from_secs(60));
5940 buf.set_auto_approve(false);
5942 let count = buf.auto_release_if_eligible(Some(0.1)).await;
5943 assert_eq!(count, 0);
5944 }
5945
5946 #[tokio::test]
5947 async fn test_auto_release_above_threshold_returns_zero() {
5948 let buf = buffer::ResponseBuffer::new(std::time::Duration::from_secs(60));
5949 buf.set_auto_approve(true);
5950 buf.set_auto_approve_threshold(0.3);
5951
5952 let now = std::time::Instant::now();
5954 buf.push(buffer::BufferedResponse {
5955 id: "ar-1".into(),
5956 action: "propose".into(),
5957 job_id: "j".into(),
5958 round: 1,
5959 reply_subject: "s".into(),
5960 payload: b"{}".to_vec(),
5961 created_at: now,
5962 release_at: now + std::time::Duration::from_secs(3600),
5963 ack_handle: Box::new(TestAckHandle),
5964 msg_id: "m".into(),
5965 annotations: vec![],
5966 edited: false,
5967 stopped: false,
5968 })
5969 .await;
5970
5971 let count = buf.auto_release_if_eligible(Some(0.5)).await;
5973 assert_eq!(count, 0);
5974 }
5975
5976 #[tokio::test]
5977 async fn test_auto_release_below_threshold_marks_entries() {
5978 let buf = buffer::ResponseBuffer::new(std::time::Duration::from_secs(60));
5979 buf.set_auto_approve(true);
5980 buf.set_auto_approve_threshold(0.5);
5981
5982 let now = std::time::Instant::now();
5983 for i in 0..3 {
5984 buf.push(buffer::BufferedResponse {
5985 id: format!("ar-{}", i),
5986 action: "propose".into(),
5987 job_id: "j".into(),
5988 round: 1,
5989 reply_subject: "s".into(),
5990 payload: b"{}".to_vec(),
5991 created_at: now,
5992 release_at: now + std::time::Duration::from_secs(3600),
5993 ack_handle: Box::new(TestAckHandle),
5994 msg_id: format!("m-{}", i),
5995 annotations: vec![],
5996 edited: false,
5997 stopped: false,
5998 })
5999 .await;
6000 }
6001
6002 let count = buf.auto_release_if_eligible(Some(0.2)).await;
6004 assert_eq!(count, 3);
6005
6006 let drained = buf.drain_ready().await;
6008 assert_eq!(drained.len(), 3);
6009 }
6010
6011 #[tokio::test]
6012 async fn test_auto_release_none_divergence_releases() {
6013 let buf = buffer::ResponseBuffer::new(std::time::Duration::from_secs(60));
6016 buf.set_auto_approve(true);
6017
6018 let now = std::time::Instant::now();
6019 buf.push(buffer::BufferedResponse {
6020 id: "ar-none".into(),
6021 action: "propose".into(),
6022 job_id: "j".into(),
6023 round: 1,
6024 reply_subject: "s".into(),
6025 payload: b"{}".to_vec(),
6026 created_at: now,
6027 release_at: now + std::time::Duration::from_secs(3600),
6028 ack_handle: Box::new(TestAckHandle),
6029 msg_id: "m-none".into(),
6030 annotations: vec![],
6031 edited: false,
6032 stopped: false,
6033 })
6034 .await;
6035
6036 let count = buf.auto_release_if_eligible(None).await;
6037 assert_eq!(count, 1);
6038 }
6039
6040 #[tokio::test]
6041 async fn test_auto_release_skips_stopped_entries() {
6042 let buf = buffer::ResponseBuffer::new(std::time::Duration::from_secs(60));
6043 buf.set_auto_approve(true);
6044 buf.set_auto_approve_threshold(0.5);
6045
6046 let now = std::time::Instant::now();
6047 buf.push(buffer::BufferedResponse {
6048 id: "ar-stop".into(),
6049 action: "propose".into(),
6050 job_id: "j".into(),
6051 round: 1,
6052 reply_subject: "s".into(),
6053 payload: b"{}".to_vec(),
6054 created_at: now,
6055 release_at: now + std::time::Duration::from_secs(3600),
6056 ack_handle: Box::new(TestAckHandle),
6057 msg_id: "m-stop".into(),
6058 annotations: vec![],
6059 edited: false,
6060 stopped: true,
6061 })
6062 .await;
6063
6064 let count = buf.auto_release_if_eligible(Some(0.1)).await;
6066 assert_eq!(count, 0);
6067 }
6068
6069 #[tokio::test]
6070 async fn test_auto_release_skips_already_ready_entries() {
6071 let buf = buffer::ResponseBuffer::new(std::time::Duration::from_secs(60));
6073 buf.set_auto_approve(true);
6074 buf.set_auto_approve_threshold(0.5);
6075
6076 let now = std::time::Instant::now();
6077 buf.push(buffer::BufferedResponse {
6078 id: "ar-past".into(),
6079 action: "propose".into(),
6080 job_id: "j".into(),
6081 round: 1,
6082 reply_subject: "s".into(),
6083 payload: b"{}".to_vec(),
6084 created_at: now,
6085 release_at: now, ack_handle: Box::new(TestAckHandle),
6087 msg_id: "m-past".into(),
6088 annotations: vec![],
6089 edited: false,
6090 stopped: false,
6091 })
6092 .await;
6093
6094 let count = buf.auto_release_if_eligible(Some(0.1)).await;
6097 assert_eq!(count, 0);
6098 }
6099
6100 #[test]
6105 fn test_compute_adaptive_hold_negative_score() {
6106 let base = std::time::Duration::from_secs(10);
6107 let hold = buffer::compute_adaptive_hold(base, Some(-0.5), 3.0);
6110 let expected = std::time::Duration::from_secs(30);
6111 assert!(
6112 (hold.as_secs_f64() - expected.as_secs_f64()).abs() < 0.5,
6113 "negative score should increase hold; hold={:?}",
6114 hold
6115 );
6116 }
6117
6118 #[test]
6119 fn test_compute_adaptive_hold_large_positive_score() {
6120 let base = std::time::Duration::from_secs(10);
6121 let hold = buffer::compute_adaptive_hold(base, Some(3.0), 3.0);
6124 let expected_secs = 13.75;
6125 assert!(
6126 (hold.as_secs_f64() - expected_secs).abs() < 0.5,
6127 "large positive score should reduce hold; hold={:?}",
6128 hold
6129 );
6130 }
6131
6132 #[test]
6133 fn test_compute_adaptive_hold_zero_amplification() {
6134 let base = std::time::Duration::from_secs(10);
6135 let hold = buffer::compute_adaptive_hold(base, Some(0.2), 0.0);
6137 assert_eq!(hold, base);
6138 }
6139
6140 #[test]
6141 fn test_compute_adaptive_hold_zero_base() {
6142 let base = std::time::Duration::ZERO;
6143 let hold = buffer::compute_adaptive_hold(base, Some(0.2), 3.0);
6145 assert_eq!(hold, std::time::Duration::ZERO);
6146 }
6147
6148 #[test]
6149 fn test_compute_divergence_high_std_dev() {
6150 let div = buffer::compute_divergence(Some(0.9), Some(1.2));
6152 assert!((div.unwrap() - 1.0).abs() < 0.01);
6156 }
6157
6158 #[test]
6159 fn test_compute_divergence_zero_score() {
6160 let div = buffer::compute_divergence(Some(0.0), None);
6162 assert!((div.unwrap() - 0.5).abs() < 0.01);
6163 }
6164
6165 #[test]
6166 fn test_compute_divergence_high_positive_score() {
6167 let div = buffer::compute_divergence(Some(3.0), None);
6169 assert!((div.unwrap() - 0.125).abs() < 0.01);
6170 }
6171
6172 #[test]
6173 fn test_compute_divergence_negative_score() {
6174 let div = buffer::compute_divergence(Some(-1.0), None);
6176 assert!((div.unwrap() - 0.75).abs() < 0.01);
6177 }
6178
6179 #[test]
6184 fn test_agent_heartbeat_serialization() {
6185 let heartbeat = crate::agents::AgentHeartbeat {
6186 agent_id: "test-agent".into(),
6187 status: AgentLiveStatus::Busy,
6188 model_name: "gpt-4".into(),
6189 provider_id: "openai".into(),
6190 current_job: Some("job-123".into()),
6191 uptime_secs: 3600,
6192 timestamp: "2026-03-06T12:00:00Z".into(),
6193 input_price_per_mtok: Some(10.0),
6194 output_price_per_mtok: Some(30.0),
6195 chars_per_token: Some(4.0),
6196 response_sla_secs: Some(300),
6197 temperature: Some(0.7),
6198 frequency_penalty: None,
6199 presence_penalty: None,
6200 max_tokens: Some(4096),
6201 context_window: Some(128000),
6202 tasks_completed: 42,
6203 tasks_failed: 3,
6204 last_error: Some("evaluate: job-abc".into()),
6205 ..Default::default()
6206 };
6207
6208 let json = serde_json::to_value(&heartbeat).unwrap();
6209 assert_eq!(json["agent_id"], "test-agent");
6210 assert_eq!(json["status"], "busy");
6211 assert_eq!(json["current_job"], "job-123");
6212 assert_eq!(json["uptime_secs"], 3600);
6213 assert_eq!(json["tasks_completed"], 42);
6214 assert_eq!(json["tasks_failed"], 3);
6215 assert_eq!(json["last_error"], "evaluate: job-abc");
6216
6217 let deserialized: crate::agents::AgentHeartbeat = serde_json::from_value(json).unwrap();
6219 assert_eq!(deserialized.agent_id, "test-agent");
6220 assert_eq!(deserialized.status, AgentLiveStatus::Busy);
6221 assert_eq!(deserialized.tasks_completed, 42);
6222 }
6223
6224 #[test]
6225 fn test_agent_heartbeat_idle_no_error() {
6226 let heartbeat = crate::agents::AgentHeartbeat {
6227 agent_id: "idle-agent".into(),
6228 status: AgentLiveStatus::Idle,
6229 model_name: "claude".into(),
6230 provider_id: "anthropic".into(),
6231 current_job: None,
6232 uptime_secs: 10,
6233 timestamp: "t".into(),
6234 input_price_per_mtok: None,
6235 output_price_per_mtok: None,
6236 chars_per_token: None,
6237 response_sla_secs: None,
6238 temperature: None,
6239 frequency_penalty: None,
6240 presence_penalty: None,
6241 max_tokens: None,
6242 context_window: None,
6243 tasks_completed: 0,
6244 tasks_failed: 0,
6245 last_error: None,
6246 ..Default::default()
6247 };
6248
6249 let json = serde_json::to_value(&heartbeat).unwrap();
6250 assert_eq!(json["status"], "idle");
6251 assert!(json["current_job"].is_null());
6252 assert_eq!(json["tasks_completed"], 0);
6253 assert_eq!(json["tasks_failed"], 0);
6254 }
6255
6256 #[test]
6261 fn test_job_manifest_empty_agents() {
6262 let manifest = JobManifest {
6263 job_id: "empty-agents".into(),
6264 task_description: "test".into(),
6265 agents: vec![],
6266 rounds: 1,
6267 timestamp: 0,
6268 };
6269
6270 let json = serde_json::to_string(&manifest).unwrap();
6271 let parsed: JobManifest = serde_json::from_str(&json).unwrap();
6272 assert!(parsed.agents.is_empty());
6273 }
6274
6275 #[test]
6276 fn test_job_manifest_contains_check() {
6277 let manifest = JobManifest {
6278 job_id: "check-test".into(),
6279 task_description: "test".into(),
6280 agents: vec!["alpha".into(), "beta".into(), "gamma".into()],
6281 rounds: 3,
6282 timestamp: 100,
6283 };
6284
6285 assert!(manifest.agents.contains(&"alpha".to_string()));
6287 assert!(manifest.agents.contains(&"beta".to_string()));
6288 assert!(!manifest.agents.contains(&"delta".to_string()));
6289 }
6290
6291 #[test]
6298 fn test_scoped_key_format() {
6299 let scope_prefix = "session-abc";
6300 let key = "my_data";
6301 let scoped = format!("{}.{}", scope_prefix, key);
6302 assert_eq!(scoped, "session-abc.my_data");
6303 }
6304
6305 #[test]
6306 fn test_scoped_key_with_dots_in_prefix() {
6307 let scope_prefix = "org.team.session";
6308 let key = "scratchpad";
6309 let scoped = format!("{}.{}", scope_prefix, key);
6310 assert_eq!(scoped, "org.team.session.scratchpad");
6311 }
6312
6313 #[test]
6318 fn test_extract_preview_proposal_unicode_content_truncation() {
6319 let long_content: String = "\u{4e16}\u{754c}".repeat(1200); let payload = serde_json::json!({
6323 "content": long_content,
6324 "thought_process": ""
6325 });
6326 let bytes = serde_json::to_vec(&payload).unwrap();
6327
6328 let preview = extract_content_preview(&bytes, "propose", &[]);
6329 assert!(preview.is_some());
6330
6331 let parsed: serde_json::Value = serde_json::from_str(&preview.unwrap()).unwrap();
6332 let c = parsed["c"].as_str().unwrap();
6333 assert!(c.chars().count() <= 2002);
6335 assert!(c.ends_with('\u{2026}'));
6336 }
6338
6339 #[test]
6340 fn test_extract_preview_proposal_unicode_thought_truncation() {
6341 let long_tp: String = "\u{1f600}".repeat(600); let payload = serde_json::json!({
6344 "content": "short content",
6345 "thought_process": long_tp
6346 });
6347 let bytes = serde_json::to_vec(&payload).unwrap();
6348
6349 let preview = extract_content_preview(&bytes, "propose", &[]);
6350 assert!(preview.is_some());
6351
6352 let parsed: serde_json::Value = serde_json::from_str(&preview.unwrap()).unwrap();
6353 let tp = parsed["tp"].as_str().unwrap();
6354 assert!(tp.chars().count() <= 502);
6355 assert!(tp.ends_with('\u{2026}'));
6356 }
6357
6358 #[test]
6359 fn test_extract_preview_proposal_exactly_2000_chars_no_truncation() {
6360 let exact_content: String = "a".repeat(2000);
6361 let payload = serde_json::json!({
6362 "content": exact_content,
6363 });
6364 let bytes = serde_json::to_vec(&payload).unwrap();
6365
6366 let preview = extract_content_preview(&bytes, "propose", &[]);
6367 assert!(preview.is_some());
6368
6369 let parsed: serde_json::Value = serde_json::from_str(&preview.unwrap()).unwrap();
6370 let c = parsed["c"].as_str().unwrap();
6371 assert_eq!(c.chars().count(), 2000);
6373 assert!(!c.ends_with('\u{2026}'));
6374 }
6375
6376 #[test]
6377 fn test_extract_preview_proposal_exactly_2001_chars_truncated() {
6378 let content: String = "b".repeat(2001);
6379 let payload = serde_json::json!({
6380 "content": content,
6381 });
6382 let bytes = serde_json::to_vec(&payload).unwrap();
6383
6384 let preview = extract_content_preview(&bytes, "propose", &[]);
6385 assert!(preview.is_some());
6386
6387 let parsed: serde_json::Value = serde_json::from_str(&preview.unwrap()).unwrap();
6388 let c = parsed["c"].as_str().unwrap();
6389 assert_eq!(c.chars().count(), 2001);
6391 assert!(c.ends_with('\u{2026}'));
6392 }
6393
6394 #[test]
6395 fn test_extract_preview_proposal_thought_exactly_500_no_truncation() {
6396 let tp: String = "c".repeat(500);
6397 let payload = serde_json::json!({
6398 "content": "hello",
6399 "thought_process": tp
6400 });
6401 let bytes = serde_json::to_vec(&payload).unwrap();
6402
6403 let preview = extract_content_preview(&bytes, "propose", &[]);
6404 assert!(preview.is_some());
6405
6406 let parsed: serde_json::Value = serde_json::from_str(&preview.unwrap()).unwrap();
6407 let tp_out = parsed["tp"].as_str().unwrap();
6408 assert_eq!(tp_out.chars().count(), 500);
6409 assert!(!tp_out.ends_with('\u{2026}'));
6410 }
6411
6412 #[test]
6413 fn test_extract_preview_proposal_empty_thought_process_omitted() {
6414 let payload = serde_json::json!({
6416 "content": "some content",
6417 "thought_process": ""
6418 });
6419 let bytes = serde_json::to_vec(&payload).unwrap();
6420
6421 let preview = extract_content_preview(&bytes, "propose", &[]);
6422 assert!(preview.is_some());
6423
6424 let parsed: serde_json::Value = serde_json::from_str(&preview.unwrap()).unwrap();
6425 assert!(
6426 parsed.get("tp").is_none(),
6427 "empty thought_process should not produce tp key"
6428 );
6429 }
6430
6431 #[test]
6432 fn test_extract_preview_proposal_content_is_number_returns_none() {
6433 let payload = serde_json::json!({
6435 "content": 42,
6436 "thought_process": "thinking"
6437 });
6438 let bytes = serde_json::to_vec(&payload).unwrap();
6439
6440 let preview = extract_content_preview(&bytes, "propose", &[]);
6441 assert!(preview.is_none());
6443 }
6444
6445 #[test]
6450 fn test_extract_preview_eval_unicode_justification_truncation() {
6451 let long_j: String = "\u{4e16}".repeat(400); let payload = serde_json::json!([
6453 ["agent-A", {"score": 6.0, "justification": long_j}]
6454 ]);
6455 let bytes = serde_json::to_vec(&payload).unwrap();
6456
6457 let preview = extract_content_preview(&bytes, "evaluate", &[]);
6458 assert!(preview.is_some());
6459
6460 let parsed: serde_json::Value = serde_json::from_str(&preview.unwrap()).unwrap();
6461 let j = parsed["evals"][0]["j"].as_str().unwrap();
6462 assert!(j.chars().count() <= 302);
6463 assert!(j.ends_with('\u{2026}'));
6464 }
6465
6466 #[test]
6467 fn test_extract_preview_eval_unicode_textual_feedback_truncation() {
6468 let long_tf: String = "\u{1f44d}".repeat(300); let payload = serde_json::json!([
6470 ["agent-A", {"score": 5.0, "textual_feedback": long_tf}]
6471 ]);
6472 let bytes = serde_json::to_vec(&payload).unwrap();
6473
6474 let preview = extract_content_preview(&bytes, "evaluate", &[]);
6475 assert!(preview.is_some());
6476
6477 let parsed: serde_json::Value = serde_json::from_str(&preview.unwrap()).unwrap();
6478 let tf = parsed["evals"][0]["tf"].as_str().unwrap();
6479 assert!(tf.chars().count() <= 202);
6480 assert!(tf.ends_with('\u{2026}'));
6481 }
6482
6483 #[test]
6484 fn test_extract_preview_eval_justification_exactly_300_no_truncation() {
6485 let j: String = "d".repeat(300);
6486 let payload = serde_json::json!([
6487 ["agent-A", {"score": 7.0, "justification": j}]
6488 ]);
6489 let bytes = serde_json::to_vec(&payload).unwrap();
6490
6491 let preview = extract_content_preview(&bytes, "evaluate", &[]);
6492 assert!(preview.is_some());
6493
6494 let parsed: serde_json::Value = serde_json::from_str(&preview.unwrap()).unwrap();
6495 let j_out = parsed["evals"][0]["j"].as_str().unwrap();
6496 assert_eq!(j_out.chars().count(), 300);
6497 assert!(!j_out.ends_with('\u{2026}'));
6498 }
6499
6500 #[test]
6501 fn test_extract_preview_eval_textual_feedback_exactly_200_no_truncation() {
6502 let tf: String = "e".repeat(200);
6503 let payload = serde_json::json!([
6504 ["agent-A", {"score": 7.0, "textual_feedback": tf}]
6505 ]);
6506 let bytes = serde_json::to_vec(&payload).unwrap();
6507
6508 let preview = extract_content_preview(&bytes, "evaluate", &[]);
6509 assert!(preview.is_some());
6510
6511 let parsed: serde_json::Value = serde_json::from_str(&preview.unwrap()).unwrap();
6512 let tf_out = parsed["evals"][0]["tf"].as_str().unwrap();
6513 assert_eq!(tf_out.chars().count(), 200);
6514 assert!(!tf_out.ends_with('\u{2026}'));
6515 }
6516
6517 #[test]
6518 fn test_extract_preview_eval_missing_score_field() {
6519 let payload = serde_json::json!([
6521 ["agent-A", {"justification": "OK"}]
6522 ]);
6523 let bytes = serde_json::to_vec(&payload).unwrap();
6524
6525 let preview = extract_content_preview(&bytes, "evaluate", &[]);
6526 assert!(preview.is_some());
6527
6528 let parsed: serde_json::Value = serde_json::from_str(&preview.unwrap()).unwrap();
6529 let eval = &parsed["evals"][0];
6530 assert_eq!(eval["target"], "agent-A");
6531 assert_eq!(eval["j"], "OK");
6532 assert!(
6533 eval.get("s").is_none(),
6534 "no score field in source → no s in preview"
6535 );
6536 }
6537
6538 #[test]
6539 fn test_extract_preview_eval_missing_target_id() {
6540 let payload = serde_json::json!([
6542 [42, {"score": 5.0, "justification": "test"}]
6543 ]);
6544 let bytes = serde_json::to_vec(&payload).unwrap();
6545
6546 let preview = extract_content_preview(&bytes, "evaluate", &[]);
6547 assert!(preview.is_some());
6548
6549 let parsed: serde_json::Value = serde_json::from_str(&preview.unwrap()).unwrap();
6550 assert_eq!(parsed["evals"][0]["target"], "?");
6551 }
6552
6553 #[test]
6554 fn test_extract_preview_eval_all_optional_fields_present() {
6555 let payload = serde_json::json!([
6558 ["agent-A", {
6559 "score": 7.5,
6560 "justification": "Good analysis",
6561 "stance": "strongly_agree",
6562 "textual_feedback": "Well argued points",
6563 "category_scores": {"accuracy": 8, "clarity": 9, "depth": 7},
6564 "claim_assessments": [
6565 {"claim": "The earth is round", "verdict": "agree"},
6566 {"claim": "Water is wet", "verdict": "agree"}
6567 ],
6568 "disagreements": ["Minor factual error in paragraph 2"]
6569 }]
6570 ]);
6571 let bytes = serde_json::to_vec(&payload).unwrap();
6572
6573 let preview = extract_content_preview(&bytes, "evaluate", &[]);
6574 assert!(preview.is_some());
6575
6576 let parsed: serde_json::Value = serde_json::from_str(&preview.unwrap()).unwrap();
6577 let eval = &parsed["evals"][0];
6578 assert_eq!(eval["target"], "agent-A");
6579 assert_eq!(eval["s"], 7.5);
6580 assert_eq!(eval["j"], "Good analysis");
6581 assert_eq!(eval["stance"], "strongly_agree");
6582 assert_eq!(eval["tf"], "Well argued points");
6583 assert_eq!(eval["cats"]["accuracy"], 8);
6584 assert_eq!(eval["cats"]["clarity"], 9);
6585 assert_eq!(eval["claims"].as_array().unwrap().len(), 2);
6586 assert_eq!(eval["disputes"].as_array().unwrap().len(), 1);
6587 }
6588
6589 #[test]
6590 fn test_extract_preview_eval_minimal_eval_object() {
6591 let payload = serde_json::json!([["agent-A", {}]]);
6594 let bytes = serde_json::to_vec(&payload).unwrap();
6595
6596 let preview = extract_content_preview(&bytes, "evaluate", &[]);
6597 assert!(preview.is_some());
6598
6599 let parsed: serde_json::Value = serde_json::from_str(&preview.unwrap()).unwrap();
6600 let eval = &parsed["evals"][0];
6601 assert_eq!(eval["target"], "agent-A");
6602 assert!(eval.get("s").is_none());
6604 assert!(eval.get("j").is_none());
6605 assert!(eval.get("stance").is_none());
6606 assert!(eval.get("tf").is_none());
6607 assert!(eval.get("cats").is_none());
6608 assert!(eval.get("claims").is_none());
6609 assert!(eval.get("disputes").is_none());
6610 }
6611
6612 #[test]
6617 fn test_extract_preview_eval_props_truncated() {
6618 let long_content = "x".repeat(1200);
6620 let candidates = vec![crate::agents::CandidateProposal {
6621 id: "Candidate_A".to_string(),
6622 proposal: crate::agents::Proposal {
6623 content: long_content.clone(),
6624 ..Default::default()
6625 },
6626 }];
6627 let payload = serde_json::json!([["Candidate_A", {"score": 0.8, "justification": "ok"}]]);
6628 let bytes = serde_json::to_vec(&payload).unwrap();
6629
6630 let preview = extract_content_preview(&bytes, "evaluate", &candidates);
6631 assert!(preview.is_some());
6632 let parsed: serde_json::Value = serde_json::from_str(&preview.unwrap()).unwrap();
6633 let props = parsed.get("props").expect("should have props");
6634 let val = props["Candidate_A"].as_str().unwrap();
6635 assert!(val.ends_with('…'), "should be truncated");
6636 assert_eq!(val.chars().count(), 1001);
6638 }
6639
6640 #[test]
6641 fn test_extract_preview_eval_props_short_content() {
6642 let short_content = "short proposal".to_string();
6644 let candidates = vec![crate::agents::CandidateProposal {
6645 id: "Candidate_B".to_string(),
6646 proposal: crate::agents::Proposal {
6647 content: short_content.clone(),
6648 ..Default::default()
6649 },
6650 }];
6651 let payload = serde_json::json!([["Candidate_B", {"score": 0.5, "justification": "meh"}]]);
6652 let bytes = serde_json::to_vec(&payload).unwrap();
6653
6654 let preview = extract_content_preview(&bytes, "evaluate", &candidates);
6655 assert!(preview.is_some());
6656 let parsed: serde_json::Value = serde_json::from_str(&preview.unwrap()).unwrap();
6657 let props = parsed.get("props").expect("should have props");
6658 assert_eq!(props["Candidate_B"].as_str().unwrap(), "short proposal");
6659 }
6660
6661 #[test]
6662 fn test_extract_preview_eval_props_filters_to_displayed_targets() {
6663 let candidates = vec![
6665 crate::agents::CandidateProposal {
6666 id: "Candidate_A".to_string(),
6667 proposal: crate::agents::Proposal {
6668 content: "proposal A".into(),
6669 ..Default::default()
6670 },
6671 },
6672 crate::agents::CandidateProposal {
6673 id: "Candidate_B".to_string(),
6674 proposal: crate::agents::Proposal {
6675 content: "proposal B".into(),
6676 ..Default::default()
6677 },
6678 },
6679 ];
6680 let payload = serde_json::json!([["Candidate_A", {"score": 0.9, "justification": "good"}]]);
6682 let bytes = serde_json::to_vec(&payload).unwrap();
6683
6684 let preview = extract_content_preview(&bytes, "evaluate", &candidates);
6685 let parsed: serde_json::Value = serde_json::from_str(&preview.unwrap()).unwrap();
6686 let props = parsed.get("props").expect("should have props");
6687 assert!(
6688 props.get("Candidate_A").is_some(),
6689 "displayed target should be in props"
6690 );
6691 assert!(
6692 props.get("Candidate_B").is_none(),
6693 "non-displayed target should be filtered out"
6694 );
6695 }
6696
6697 #[test]
6702 fn test_compute_divergence_only_std_dev_no_score() {
6703 let div = buffer::compute_divergence(None, Some(0.3));
6706 assert!(div.is_some());
6707 assert!((div.unwrap() - 0.3).abs() < 0.01);
6708 }
6709
6710 #[test]
6711 fn test_compute_divergence_both_score_and_std_dev() {
6712 let div = buffer::compute_divergence(Some(0.7), Some(0.35));
6716 assert!((div.unwrap() - 0.35).abs() < 0.01);
6717 }
6718
6719 #[test]
6720 fn test_compute_divergence_score_dominates() {
6721 let div = buffer::compute_divergence(Some(-2.0), Some(0.05));
6725 assert!((div.unwrap() - 0.833).abs() < 0.02);
6726 }
6727
6728 #[test]
6729 fn test_compute_divergence_high_score_low_std() {
6730 let div = buffer::compute_divergence(Some(5.0), Some(0.02));
6734 assert!((div.unwrap() - 0.083).abs() < 0.02);
6735 }
6736
6737 #[test]
6738 fn test_compute_divergence_none_none() {
6739 let div = buffer::compute_divergence(None, None);
6740 assert!(div.is_none());
6741 }
6742
6743 #[test]
6744 fn test_compute_divergence_perfect_score_zero_std() {
6745 let div = buffer::compute_divergence(Some(3.0), Some(0.0));
6748 assert!((div.unwrap() - 0.125).abs() < 0.01);
6749 }
6750
6751 #[test]
6756 fn test_check_flags_low_score_triggers_flag() {
6757 use crate::status::{AgentStatusSnapshot, ScoreEntry};
6758
6759 let mut snap = AgentStatusSnapshot::new("a".into(), "m".into(), "p".into());
6760
6761 for i in 0..3 {
6763 snap.push_score(ScoreEntry {
6764 timestamp: format!("t{}", i),
6765 job_id: "j".into(),
6766 round: i as u32 + 1,
6767 evaluator: "e".into(),
6768 score: -0.5,
6769 });
6770 }
6771
6772 assert!(snap.is_flagged, "agent should be flagged for low scores");
6773 assert!(snap.flag_reason.as_ref().unwrap().contains("Low scores"));
6774 }
6775
6776 #[test]
6777 fn test_check_flags_high_divergence_triggers_flag() {
6778 use crate::status::{AgentStatusSnapshot, ScoreEntry};
6779
6780 let mut snap = AgentStatusSnapshot::new("a".into(), "m".into(), "p".into());
6781
6782 snap.push_score(ScoreEntry {
6784 timestamp: "t1".into(),
6785 job_id: "j".into(),
6786 round: 1,
6787 evaluator: "e".into(),
6788 score: -2.0,
6789 });
6790 snap.push_score(ScoreEntry {
6791 timestamp: "t2".into(),
6792 job_id: "j".into(),
6793 round: 2,
6794 evaluator: "e".into(),
6795 score: 2.0,
6796 });
6797
6798 assert!(snap.score_std_dev.unwrap() > 1.5);
6801 assert!(
6802 snap.is_flagged,
6803 "agent should be flagged for high divergence"
6804 );
6805 assert!(snap.flag_reason.as_ref().unwrap().contains("divergence"));
6806 }
6807
6808 #[test]
6809 fn test_check_flags_clears_when_conditions_no_longer_met() {
6810 use crate::status::{AgentStatusSnapshot, ScoreEntry};
6811
6812 let mut snap = AgentStatusSnapshot::new("a".into(), "m".into(), "p".into());
6813
6814 for i in 0..3 {
6816 snap.push_score(ScoreEntry {
6817 timestamp: format!("t{}", i),
6818 job_id: "j".into(),
6819 round: i as u32 + 1,
6820 evaluator: "e".into(),
6821 score: -0.5,
6822 });
6823 }
6824 assert!(snap.is_flagged);
6825
6826 for i in 3..6 {
6828 snap.push_score(ScoreEntry {
6829 timestamp: format!("t{}", i),
6830 job_id: "j".into(),
6831 round: i as u32 + 1,
6832 evaluator: "e".into(),
6833 score: 0.8,
6834 });
6835 }
6836
6837 assert!(!snap.is_flagged, "flag should be cleared after good scores");
6840 }
6841
6842 #[test]
6843 fn test_check_flags_not_flagged_with_good_scores() {
6844 use crate::status::{AgentStatusSnapshot, ScoreEntry};
6845
6846 let mut snap = AgentStatusSnapshot::new("a".into(), "m".into(), "p".into());
6847
6848 for i in 0..5 {
6850 snap.push_score(ScoreEntry {
6851 timestamp: format!("t{}", i),
6852 job_id: "j".into(),
6853 round: i as u32 + 1,
6854 evaluator: "e".into(),
6855 score: 0.7 + (i as f32 * 0.05),
6856 });
6857 }
6858
6859 assert!(!snap.is_flagged, "good scores should not flag the agent");
6860 assert!(snap.flag_reason.is_none());
6861 }
6862
6863 #[test]
6864 fn test_check_flags_fewer_than_3_scores_no_low_score_flag() {
6865 use crate::status::{AgentStatusSnapshot, ScoreEntry};
6866
6867 let mut snap = AgentStatusSnapshot::new("a".into(), "m".into(), "p".into());
6868
6869 snap.push_score(ScoreEntry {
6871 timestamp: "t1".into(),
6872 job_id: "j".into(),
6873 round: 1,
6874 evaluator: "e".into(),
6875 score: 1.0,
6876 });
6877 snap.push_score(ScoreEntry {
6878 timestamp: "t2".into(),
6879 job_id: "j".into(),
6880 round: 2,
6881 evaluator: "e".into(),
6882 score: 1.0,
6883 });
6884
6885 assert!(
6888 !snap.is_flagged,
6889 "fewer than 3 scores should not trigger low-score flag"
6890 );
6891 }
6892
6893 #[test]
6894 fn test_check_flags_low_score_takes_priority_over_high_divergence() {
6895 use crate::status::{AgentStatusSnapshot, ScoreEntry};
6896
6897 let mut snap = AgentStatusSnapshot::new("a".into(), "m".into(), "p".into());
6898
6899 snap.push_score(ScoreEntry {
6903 timestamp: "t1".into(),
6904 job_id: "j".into(),
6905 round: 1,
6906 evaluator: "e".into(),
6907 score: -0.5,
6908 });
6909 snap.push_score(ScoreEntry {
6910 timestamp: "t2".into(),
6911 job_id: "j".into(),
6912 round: 2,
6913 evaluator: "e".into(),
6914 score: -0.4,
6915 });
6916 snap.push_score(ScoreEntry {
6917 timestamp: "t3".into(),
6918 job_id: "j".into(),
6919 round: 3,
6920 evaluator: "e".into(),
6921 score: -0.6,
6922 });
6923
6924 assert!(snap.is_flagged);
6925 assert!(
6926 snap.flag_reason.as_ref().unwrap().contains("Low scores"),
6927 "low score flag should take priority, got: {:?}",
6928 snap.flag_reason
6929 );
6930 }
6931
6932 #[test]
6937 fn test_push_score_single_score_no_std_dev() {
6938 use crate::status::{AgentStatusSnapshot, ScoreEntry};
6939
6940 let mut snap = AgentStatusSnapshot::new("a".into(), "m".into(), "p".into());
6941 snap.push_score(ScoreEntry {
6942 timestamp: "t".into(),
6943 job_id: "j".into(),
6944 round: 1,
6945 evaluator: "e".into(),
6946 score: 5.0,
6947 });
6948
6949 assert_eq!(snap.mean_score, Some(5.0));
6950 assert!(
6951 snap.score_std_dev.is_none(),
6952 "single score should have no std_dev"
6953 );
6954 }
6955
6956 #[test]
6957 fn test_push_score_two_identical_scores_zero_std_dev() {
6958 use crate::status::{AgentStatusSnapshot, ScoreEntry};
6959
6960 let mut snap = AgentStatusSnapshot::new("a".into(), "m".into(), "p".into());
6961 snap.push_score(ScoreEntry {
6962 timestamp: "t1".into(),
6963 job_id: "j".into(),
6964 round: 1,
6965 evaluator: "e".into(),
6966 score: 6.0,
6967 });
6968 snap.push_score(ScoreEntry {
6969 timestamp: "t2".into(),
6970 job_id: "j".into(),
6971 round: 2,
6972 evaluator: "e".into(),
6973 score: 6.0,
6974 });
6975
6976 assert_eq!(snap.mean_score, Some(6.0));
6977 assert!((snap.score_std_dev.unwrap() - 0.0).abs() < f32::EPSILON);
6978 }
6979
6980 #[test]
6981 fn test_push_score_trims_beyond_max() {
6982 use crate::status::{AgentStatusSnapshot, ScoreEntry};
6983
6984 let mut snap = AgentStatusSnapshot::new("a".into(), "m".into(), "p".into());
6985
6986 for i in 0..55 {
6988 snap.push_score(ScoreEntry {
6989 timestamp: format!("t{}", i),
6990 job_id: "j".into(),
6991 round: i as u32 + 1,
6992 evaluator: "e".into(),
6993 score: 5.0,
6994 });
6995 }
6996
6997 assert_eq!(
6998 snap.recent_scores.len(),
6999 50,
7000 "should trim to MAX_RECENT_SCORES"
7001 );
7002 assert_eq!(snap.recent_scores.front().unwrap().round, 6);
7004 }
7005
7006 #[test]
7011 fn test_worker_config_debug_output() {
7012 let config = WorkerConfig::new(
7013 "nats://localhost:4222".to_string(),
7014 "test_stream".to_string(),
7015 "test_consumer".to_string(),
7016 );
7017 let debug = format!("{:?}", config);
7018 assert!(debug.contains("WorkerConfig"));
7019 assert!(debug.contains("nats://localhost:4222"));
7020 assert!(debug.contains("test_stream"));
7021 assert!(debug.contains("test_consumer"));
7022 assert!(debug.contains("nsed"));
7023 assert!(debug.contains("sphera"));
7024 }
7025
7026 #[test]
7031 fn test_job_manifest_debug_output() {
7032 let manifest = JobManifest {
7033 job_id: "debug-test".into(),
7034 task_description: "test desc".into(),
7035 agents: vec!["a1".into()],
7036 rounds: 2,
7037 timestamp: 12345,
7038 };
7039 let debug = format!("{:?}", manifest);
7040 assert!(debug.contains("JobManifest"));
7041 assert!(debug.contains("debug-test"));
7042 assert!(debug.contains("test desc"));
7043 }
7044
7045 #[test]
7050 fn test_config_patch_apply_all_fields() {
7051 use crate::agents::AgentConfig;
7052 use crate::control_plane::ConfigPatch;
7053
7054 let mut config = AgentConfig {
7055 name: "test".into(),
7056 provider_id: "p".into(),
7057 model_name: "m".into(),
7058 temperature: 0.7,
7059 ..Default::default()
7060 };
7061
7062 let patch = ConfigPatch {
7063 temperature: Some(1.5),
7064 frequency_penalty: Some(0.5),
7065 presence_penalty: Some(-0.3),
7066 persona: Some("friendly helper".into()),
7067 textual_feedback: Some(true),
7068 max_react_iterations: Some(3),
7069 max_retries: Some(1),
7070 };
7071 patch.apply(&mut config).expect("valid patch");
7072
7073 assert_eq!(config.temperature, 1.5);
7074 assert_eq!(config.frequency_penalty, Some(0.5));
7075 assert_eq!(config.presence_penalty, Some(-0.3));
7076 assert_eq!(config.persona, Some("friendly helper".into()));
7077 assert!(config.textual_feedback);
7078 assert_eq!(config.max_react_iterations, Some(3));
7079 assert_eq!(config.max_retries, Some(1));
7080 }
7081
7082 #[test]
7083 fn test_config_patch_apply_empty_patch_no_change() {
7084 use crate::agents::AgentConfig;
7085 use crate::control_plane::ConfigPatch;
7086
7087 let mut config = AgentConfig {
7088 name: "test".into(),
7089 provider_id: "p".into(),
7090 model_name: "m".into(),
7091 temperature: 0.7,
7092 frequency_penalty: Some(0.2),
7093 ..Default::default()
7094 };
7095
7096 let patch = ConfigPatch::default();
7097 patch.apply(&mut config).expect("empty patch");
7098
7099 assert_eq!(config.temperature, 0.7);
7100 assert_eq!(config.frequency_penalty, Some(0.2));
7101 }
7102
7103 #[test]
7108 fn test_config_patch_rejects_unknown_fields() {
7109 use crate::control_plane::ConfigPatch;
7110
7111 let json = r#"{"temperature": 0.5, "unknown_field": true}"#;
7112 let result: Result<ConfigPatch, _> = serde_json::from_str(json);
7113 assert!(
7114 result.is_err(),
7115 "unknown fields should be rejected due to deny_unknown_fields"
7116 );
7117 }
7118
7119 #[test]
7124 fn test_inject_annotations_deeply_nested_eval() {
7125 use crate::agents::{AnnotationType, OperatorAnnotation};
7126
7127 let payload = br#"[["agent-A", {"score": 5.0}, "extra-data"]]"#;
7129 let annotation = OperatorAnnotation {
7130 annotation_type: AnnotationType::Edit,
7131 comment: "test".into(),
7132 timestamp: "t".into(),
7133 original_content_hash: None,
7134 };
7135 let entry = make_entry(payload, true, vec![annotation]);
7136
7137 let result = NatsNsedWorker::inject_annotations(&entry);
7138 let val: serde_json::Value = serde_json::from_slice(&result).unwrap();
7139 let inner = val.as_array().unwrap()[0].as_array().unwrap();
7140 assert_eq!(inner[1]["edited_by"], "operator");
7142 assert!(inner[1]["operator_annotations"].is_array());
7143 assert_eq!(inner.len(), 3);
7145 assert_eq!(inner[2], "extra-data");
7146 }
7147
7148 #[test]
7153 fn test_compute_adaptive_hold_medium_score() {
7154 let base = std::time::Duration::from_secs(10);
7155 let hold = buffer::compute_adaptive_hold(base, Some(0.5), 3.0);
7158 let expected_secs = 20.0;
7159 assert!(
7160 (hold.as_secs_f64() - expected_secs).abs() < 0.5,
7161 "score 0.5 should give ~2x base; got {:?}",
7162 hold
7163 );
7164 }
7165
7166 #[tokio::test]
7171 async fn test_buffer_response_sla_matches_hold() {
7172 let buf = buffer::ResponseBuffer::new(std::time::Duration::from_secs(300));
7174 assert_eq!(
7175 buf.response_sla(),
7176 Some(std::time::Duration::from_secs(300))
7177 );
7178
7179 let buf_short = buffer::ResponseBuffer::new(std::time::Duration::from_secs(5));
7180 assert_eq!(
7181 buf_short.response_sla(),
7182 Some(std::time::Duration::from_secs(5))
7183 );
7184 }
7185
7186 #[test]
7191 fn test_agent_live_status_serialization() {
7192 let busy_json = serde_json::to_string(&AgentLiveStatus::Busy).unwrap();
7193 assert_eq!(busy_json, "\"busy\"");
7194
7195 let idle_json = serde_json::to_string(&AgentLiveStatus::Idle).unwrap();
7196 assert_eq!(idle_json, "\"idle\"");
7197
7198 let parsed: AgentLiveStatus = serde_json::from_str(&busy_json).unwrap();
7200 assert_eq!(parsed, AgentLiveStatus::Busy);
7201 }
7202
7203 #[test]
7208 fn test_error_rate_all_failures() {
7209 use crate::status::{AgentStatusSnapshot, TaskLogEntry};
7210
7211 let mut snap = AgentStatusSnapshot::new("a".into(), "m".into(), "p".into());
7212 for i in 0..5 {
7213 snap.push_task(TaskLogEntry {
7214 timestamp: format!("t{}", i),
7215 action: "propose".into(),
7216 job_id: format!("j{}", i),
7217 round: 1,
7218 status: "error".into(),
7219 duration_ms: 10,
7220 content_preview: None,
7221 });
7222 }
7223 assert!(
7224 (snap.error_rate - 1.0).abs() < f32::EPSILON,
7225 "all failures should give 100% error rate"
7226 );
7227 }
7228
7229 #[test]
7230 fn test_error_rate_all_successes() {
7231 use crate::status::{AgentStatusSnapshot, TaskLogEntry};
7232
7233 let mut snap = AgentStatusSnapshot::new("a".into(), "m".into(), "p".into());
7234 for i in 0..5 {
7235 snap.push_task(TaskLogEntry {
7236 timestamp: format!("t{}", i),
7237 action: "evaluate".into(),
7238 job_id: format!("j{}", i),
7239 round: 1,
7240 status: "ok".into(),
7241 duration_ms: 10,
7242 content_preview: None,
7243 });
7244 }
7245 assert!(
7246 (snap.error_rate - 0.0).abs() < f32::EPSILON,
7247 "all successes should give 0% error rate"
7248 );
7249 }
7250
7251 #[tokio::test]
7256 async fn test_drain_stale_drains_stopped_entries_from_other_jobs() {
7257 let buf = buffer::ResponseBuffer::new(std::time::Duration::from_secs(300));
7259
7260 let now = std::time::Instant::now();
7261 buf.push(buffer::BufferedResponse {
7262 id: "stopped-stale".into(),
7263 action: "propose".into(),
7264 job_id: "old-job".into(),
7265 round: 1,
7266 reply_subject: "s".into(),
7267 payload: b"{}".to_vec(),
7268 created_at: now,
7269 release_at: now + std::time::Duration::from_secs(3600),
7270 ack_handle: Box::new(TestAckHandle),
7271 msg_id: "m".into(),
7272 annotations: vec![],
7273 edited: false,
7274 stopped: true,
7275 })
7276 .await;
7277
7278 let stale = buf.drain_stale("current-job").await;
7279 assert_eq!(stale.len(), 1, "stopped stale entries should be drained");
7280 assert_eq!(stale[0].id, "stopped-stale");
7281 }
7282
7283 #[tokio::test]
7288 async fn test_buffer_list_overdue_entry_negative_release() {
7289 let buf = buffer::ResponseBuffer::new(std::time::Duration::from_secs(300));
7290
7291 let now = std::time::Instant::now();
7292 buf.push(buffer::BufferedResponse {
7294 id: "overdue-1".into(),
7295 action: "propose".into(),
7296 job_id: "j".into(),
7297 round: 1,
7298 reply_subject: "s".into(),
7299 payload: b"{}".to_vec(),
7300 created_at: now,
7301 release_at: now, ack_handle: Box::new(TestAckHandle),
7303 msg_id: "m".into(),
7304 annotations: vec![],
7305 edited: false,
7306 stopped: true, })
7308 .await;
7309
7310 tokio::time::sleep(std::time::Duration::from_millis(5)).await;
7312
7313 let list = buf.list().await;
7314 assert_eq!(list.len(), 1);
7315 assert!(
7316 list[0].release_in_ms <= 0,
7317 "overdue entry should have negative release_in_ms, got {}",
7318 list[0].release_in_ms
7319 );
7320 assert!(list[0].stopped);
7321 }
7322
7323 #[tokio::test]
7328 async fn test_buffer_entry_detail_serde_flatten() {
7329 let buf = buffer::ResponseBuffer::new(std::time::Duration::from_secs(60));
7330 let payload = serde_json::json!({"content": "test proposal"});
7331
7332 let now = std::time::Instant::now();
7333 buf.push(buffer::BufferedResponse {
7334 id: "serde-1".into(),
7335 action: "propose".into(),
7336 job_id: "job-abc".into(),
7337 round: 2,
7338 reply_subject: "s".into(),
7339 payload: serde_json::to_vec(&payload).unwrap(),
7340 created_at: now,
7341 release_at: now + std::time::Duration::from_secs(60),
7342 ack_handle: Box::new(TestAckHandle),
7343 msg_id: "msg-serde".into(),
7344 annotations: vec![],
7345 edited: false,
7346 stopped: false,
7347 })
7348 .await;
7349
7350 let detail = buf.get_detail("serde-1").await.unwrap();
7351 let json = serde_json::to_value(&detail).unwrap();
7353 assert_eq!(json["id"], "serde-1");
7354 assert_eq!(json["action"], "propose");
7355 assert_eq!(json["job_id"], "job-abc");
7356 assert_eq!(json["round"], 2);
7357 assert!(json["age_ms"].is_number());
7358 assert!(json["release_in_ms"].is_number());
7359 assert_eq!(json["stopped"], false);
7360 assert_eq!(json["content"]["content"], "test proposal");
7361 }
7362
7363 #[tokio::test]
7368 async fn test_update_payload_with_annotation_unknown_id() {
7369 use crate::agents::{AnnotationType, OperatorAnnotation};
7370
7371 let buf = buffer::ResponseBuffer::new(std::time::Duration::from_secs(30));
7372 buf.push(buffer::BufferedResponse {
7373 id: "exists".into(),
7374 action: "propose".into(),
7375 job_id: "j".into(),
7376 round: 1,
7377 reply_subject: "s".into(),
7378 payload: b"{}".to_vec(),
7379 created_at: std::time::Instant::now(),
7380 release_at: std::time::Instant::now() + std::time::Duration::from_secs(30),
7381 ack_handle: Box::new(TestAckHandle),
7382 msg_id: "m".into(),
7383 annotations: vec![],
7384 edited: false,
7385 stopped: false,
7386 })
7387 .await;
7388
7389 let annotation = OperatorAnnotation {
7390 annotation_type: AnnotationType::Edit,
7391 comment: "edit".into(),
7392 timestamp: "t".into(),
7393 original_content_hash: None,
7394 };
7395
7396 let result = buf
7397 .update_payload_with_annotation("nonexistent", b"new payload".to_vec(), annotation)
7398 .await;
7399 assert!(!result, "should return false for unknown ID");
7400 assert_eq!(buf.len().await, 1, "existing entry should be unaffected");
7401 }
7402
7403 #[test]
7408 fn test_nats_auth_inline_creds_only_is_configured() {
7409 use crate::nats_utils::NatsAuth;
7410 let auth = NatsAuth {
7411 inline_creds: Some("creds-data".into()),
7412 ..Default::default()
7413 };
7414 assert!(auth.is_configured());
7415 }
7416
7417 #[test]
7418 fn test_nats_auth_creds_file_only_is_configured() {
7419 use crate::nats_utils::NatsAuth;
7420 let auth = NatsAuth {
7421 creds_file: Some("/path/to/creds".into()),
7422 ..Default::default()
7423 };
7424 assert!(auth.is_configured());
7425 }
7426
7427 #[test]
7432 fn test_proposal_serde_roundtrip() {
7433 let proposal = crate::agents::Proposal {
7434 content: "My proposal content".into(),
7435 thought_process: "I considered alternatives".into(),
7436 final_scratchpad: Some("notes".into()),
7437 ..Default::default()
7438 };
7439 let json = serde_json::to_string(&proposal).unwrap();
7440 let parsed: crate::agents::Proposal = serde_json::from_str(&json).unwrap();
7441 assert_eq!(parsed.content, "My proposal content");
7442 assert_eq!(parsed.thought_process, "I considered alternatives");
7443 assert_eq!(parsed.final_scratchpad, Some("notes".into()));
7444 }
7445
7446 #[test]
7447 fn test_evaluation_serde_roundtrip() {
7448 let eval = crate::agents::Evaluation {
7449 score: 7.5,
7450 justification: "Well-reasoned".into(),
7451 stance: Some(crate::agents::Stance::Agree),
7452 ..Default::default()
7453 };
7454 let json = serde_json::to_string(&eval).unwrap();
7455 let parsed: crate::agents::Evaluation = serde_json::from_str(&json).unwrap();
7456 assert!((parsed.score - 7.5).abs() < f32::EPSILON);
7457 assert_eq!(parsed.justification, "Well-reasoned");
7458 assert_eq!(parsed.stance, Some(crate::agents::Stance::Agree));
7459 }
7460
7461 #[test]
7466 fn test_extract_preview_empty_action_string_returns_none() {
7467 let payload = serde_json::json!({"content": "test"});
7468 let bytes = serde_json::to_vec(&payload).unwrap();
7469
7470 let preview = extract_content_preview(&bytes, "", &[]);
7471 assert!(preview.is_none(), "empty action string should return None");
7472 }
7473
7474 fn filter_evaluations(
7484 candidates: &[crate::agents::CandidateProposal],
7485 evaluations: Vec<(String, crate::agents::Evaluation)>,
7486 ) -> Vec<(String, crate::agents::Evaluation)> {
7487 let valid_ids: std::collections::HashSet<&str> =
7488 candidates.iter().map(|c| c.id.as_str()).collect();
7489 evaluations
7490 .into_iter()
7491 .filter(|(target_id, _)| valid_ids.contains(target_id.as_str()))
7492 .collect()
7493 }
7494
7495 fn make_candidate(id: &str) -> crate::agents::CandidateProposal {
7496 crate::agents::CandidateProposal {
7497 id: id.to_string(),
7498 proposal: crate::agents::Proposal::default(),
7499 }
7500 }
7501
7502 fn make_eval(target: &str, score: f32) -> (String, crate::agents::Evaluation) {
7503 (
7504 target.to_string(),
7505 crate::agents::Evaluation {
7506 score,
7507 ..Default::default()
7508 },
7509 )
7510 }
7511
7512 #[test]
7513 fn test_filter_mixed_valid_and_hallucinated() {
7514 let candidates = vec![
7515 make_candidate("Candidate_A"),
7516 make_candidate("Candidate_B"),
7517 make_candidate("Candidate_C"),
7518 ];
7519 let evaluations = vec![
7520 make_eval("Candidate_A", 0.8),
7521 make_eval("...", 0.5), make_eval("Candidate_B", 0.6),
7523 make_eval("UNKNOWN_X", 0.9), make_eval("Candidate_C", 0.7),
7525 ];
7526
7527 let filtered = filter_evaluations(&candidates, evaluations);
7528 assert_eq!(filtered.len(), 3);
7529 assert_eq!(filtered[0].0, "Candidate_A");
7530 assert_eq!(filtered[1].0, "Candidate_B");
7531 assert_eq!(filtered[2].0, "Candidate_C");
7532
7533 let bytes = serde_json::to_vec(&filtered).unwrap();
7535 let parsed: Vec<(String, crate::agents::Evaluation)> =
7536 serde_json::from_slice(&bytes).unwrap();
7537 assert_eq!(parsed.len(), 3);
7538 }
7539
7540 #[test]
7541 fn test_filter_all_invalid_returns_empty() {
7542 let candidates = vec![make_candidate("Candidate_A"), make_candidate("Candidate_B")];
7543 let evaluations = vec![
7544 make_eval("...", 0.5),
7545 make_eval("HALLUCINATED", 0.9),
7546 make_eval("", 0.1),
7547 ];
7548
7549 let filtered = filter_evaluations(&candidates, evaluations);
7550 assert!(filtered.is_empty());
7551
7552 let bytes = serde_json::to_vec(&filtered).unwrap();
7553 assert_eq!(bytes, b"[]");
7554 }
7555
7556 #[test]
7557 fn test_filter_all_valid_passes_through() {
7558 let candidates = vec![make_candidate("Candidate_A"), make_candidate("Candidate_B")];
7559 let evaluations = vec![make_eval("Candidate_A", 0.8), make_eval("Candidate_B", 0.6)];
7560
7561 let filtered = filter_evaluations(&candidates, evaluations);
7562 assert_eq!(filtered.len(), 2);
7563 }
7564
7565 #[test]
7566 fn test_filter_empty_evaluations() {
7567 let candidates = vec![make_candidate("Candidate_A")];
7568 let filtered = filter_evaluations(&candidates, vec![]);
7569 assert!(filtered.is_empty());
7570 }
7571
7572 #[test]
7573 fn test_buffer_entry_summary_serialization() {
7574 let summary = buffer::BufferEntrySummary {
7575 id: "sum-1".into(),
7576 action: "propose".into(),
7577 job_id: "job-xyz".into(),
7578 round: 3,
7579 age_ms: 5000,
7580 release_in_ms: -200,
7581 stopped: true,
7582 };
7583 let json = serde_json::to_value(&summary).unwrap();
7584 assert_eq!(json["id"], "sum-1");
7585 assert_eq!(json["action"], "propose");
7586 assert_eq!(json["job_id"], "job-xyz");
7587 assert_eq!(json["round"], 3);
7588 assert_eq!(json["age_ms"], 5000);
7589 assert_eq!(json["release_in_ms"], -200);
7590 assert_eq!(json["stopped"], true);
7591 }
7592
7593 #[test]
7596 fn test_is_transient_error_matches_known_patterns() {
7597 let cases = [
7598 "broken pipe",
7599 "Connection reset by peer",
7600 "os error 32",
7601 "os error 104",
7602 "operation timed out",
7603 "connection closed before message completed",
7604 "unexpected eof during handshake",
7605 "stream closed",
7606 "connection refused",
7607 "network unreachable",
7608 "connection aborted",
7609 ];
7610 for msg in cases {
7611 let err = anyhow::anyhow!("{msg}");
7612 assert!(is_transient_error(&err), "expected transient for: {msg}");
7613 }
7614 }
7615
7616 #[test]
7617 fn test_is_transient_error_case_insensitive() {
7618 let err = anyhow::anyhow!("BROKEN PIPE in TLS layer");
7619 assert!(is_transient_error(&err));
7620
7621 let err = anyhow::anyhow!("Connection Reset By Peer");
7622 assert!(is_transient_error(&err));
7623 }
7624
7625 #[test]
7626 fn test_is_transient_error_rejects_non_transient() {
7627 let cases = [
7628 "invalid API key",
7629 "401 Unauthorized",
7630 "model not found",
7631 "rate limit exceeded",
7632 "JSON parse error",
7633 "",
7634 ];
7635 for msg in cases {
7636 let err = anyhow::anyhow!("{msg}");
7637 assert!(
7638 !is_transient_error(&err),
7639 "expected non-transient for: {msg}"
7640 );
7641 }
7642 }
7643
7644 #[test]
7645 fn test_is_transient_error_embedded_in_message() {
7646 let err = anyhow::anyhow!("sending proposal failed: broken pipe (os error 32)");
7648 assert!(is_transient_error(&err));
7649 }
7650
7651 #[test]
7652 fn classify_parse_error() {
7653 let r = classify_abstention_reason(
7654 "Failed to parse structured output after 4 attempts. Last error: missing field `evaluations`",
7655 );
7656 assert_eq!(r, "parse_error");
7657 }
7658
7659 #[test]
7660 fn classify_iter_budget() {
7661 assert_eq!(
7662 classify_abstention_reason("agent loop exhausted iteration budget"),
7663 "iter_budget_exhausted"
7664 );
7665 assert_eq!(
7666 classify_abstention_reason("hit max_iterations cap"),
7667 "iter_budget_exhausted"
7668 );
7669 }
7670
7671 #[test]
7672 fn classify_timeout() {
7673 assert_eq!(
7674 classify_abstention_reason("upstream timed out after 60s"),
7675 "timeout"
7676 );
7677 }
7678
7679 #[test]
7680 fn classify_tool_error() {
7681 assert_eq!(
7682 classify_abstention_reason("tool 'user_grep_repo' failed: out of sandbox"),
7683 "tool_error"
7684 );
7685 }
7686
7687 #[test]
7688 fn classify_fallback() {
7689 assert_eq!(classify_abstention_reason("kaboom"), "error");
7690 }
7691
7692 #[test]
7693 fn a_retired_model_counts_as_down() {
7694 let d = HeuristicModelDownDetector;
7695 assert!(d.is_model_down("propose failed: api error (status 410)"));
7701 assert!(d.is_model_down("API request failed with status 410 Gone"));
7702
7703 assert!(d.is_model_down("this model has been deprecated and is no longer available"));
7705 assert!(d.is_model_down("model is no longer available"));
7706
7707 assert!(!d.is_model_down("job sphera_jobs-410 completed"));
7711 assert!(!d.is_model_down("bad request (status 400)"));
7712 }
7713
7714 #[test]
7715 fn heuristic_detector_flags_only_model_unavailable() {
7716 let d = HeuristicModelDownDetector;
7717 assert!(d.is_model_down("API request failed with status 404 Not Found"));
7719 assert!(d.is_model_down("error: model_not_found"));
7720 assert!(d.is_model_down("The model `x` does not exist"));
7721 assert!(d.is_model_down("no such model: gpt-9"));
7722 assert!(!d.is_model_down("402 Payment Required"));
7724 assert!(!d.is_model_down("429 Too Many Requests"));
7725 assert!(!d.is_model_down("500 Internal Server Error"));
7726 assert!(!d.is_model_down("upstream timed out after 60s"));
7727 assert!(!d.is_model_down("job sphera_jobs-404 completed"));
7729 }
7730
7731 #[test]
7732 fn model_down_detector_is_pluggable() {
7733 #[derive(Debug)]
7735 struct AlwaysDown;
7736 impl ModelDownDetector for AlwaysDown {
7737 fn is_model_down(&self, _error: &str) -> bool {
7738 true
7739 }
7740 }
7741 let d: Arc<dyn ModelDownDetector> = Arc::new(AlwaysDown);
7742 assert!(d.is_model_down("anything"));
7743 }
7744
7745 #[test]
7746 fn availability_probe_due_throttles_to_the_interval() {
7747 assert!(availability_probe_due(0, 1_000, 300_000));
7749 assert!(!availability_probe_due(1_000, 1_500, 300_000));
7751 assert!(availability_probe_due(1_000, 301_000, 300_000));
7753 assert!(availability_probe_due(1_000, 301_000, 300_000));
7755 }
7756
7757 #[test]
7758 fn model_down_active_respects_the_cooldown_deadline() {
7759 assert!(!model_down_active(0, 1_000));
7761 assert!(model_down_active(5_000, 4_999));
7763 assert!(!model_down_active(5_000, 5_000));
7765 assert!(!model_down_active(5_000, 6_000));
7766 }
7767
7768 #[test]
7769 fn escalated_cooldown_doubles_per_strike_then_caps() {
7770 assert_eq!(escalated_cooldown_ms(0), MODEL_DOWN_COOLDOWN_MS);
7772 assert_eq!(escalated_cooldown_ms(1), MODEL_DOWN_COOLDOWN_MS);
7773 assert_eq!(escalated_cooldown_ms(2), MODEL_DOWN_COOLDOWN_MS * 2);
7775 assert_eq!(escalated_cooldown_ms(3), MODEL_DOWN_COOLDOWN_MS * 4);
7776 assert_eq!(escalated_cooldown_ms(4), 1_800_000);
7778 assert_eq!(escalated_cooldown_ms(100), 1_800_000);
7779 for s in 1..50 {
7781 assert!(escalated_cooldown_ms(s) <= escalated_cooldown_ms(s + 1));
7782 assert!(escalated_cooldown_ms(s) <= 1_800_000);
7783 }
7784 }
7785
7786 #[test]
7787 fn failed_subject_format_pins_exact_wire_shape() {
7788 let s = failed_result_subject("nsed", "sess-abc", 3, "ReviewerAlpha", "evaluate");
7789 assert_eq!(s, "nsed.sess-abc.result.3.ReviewerAlpha.evaluate.failed");
7790 }
7791
7792 #[test]
7793 fn failed_subject_round_zero_renders() {
7794 let s = failed_result_subject("nsed", "x", 0, "A", "propose");
7797 assert_eq!(s, "nsed.x.result.0.A.propose.failed");
7798 }
7799
7800 #[test]
7801 fn failed_subject_custom_prefix_propagates() {
7802 let s = failed_result_subject("tenantX", "sess", 1, "A", "propose");
7806 assert!(s.starts_with("tenantX."));
7807 assert!(s.ends_with(".A.propose.failed"));
7808 }
7809
7810 #[test]
7811 fn failure_marker_emitted_for_propose() {
7812 assert!(should_publish_failure_marker("propose", false));
7813 }
7814
7815 #[test]
7816 fn failure_marker_emitted_for_evaluate() {
7817 assert!(should_publish_failure_marker("evaluate", false));
7818 }
7819
7820 #[test]
7821 fn failure_marker_skipped_for_other_actions() {
7822 for action in ["passthrough", "heartbeat", "unknown", ""] {
7823 assert!(
7824 !should_publish_failure_marker(action, false),
7825 "action {action:?} should not trigger a .failed marker"
7826 );
7827 }
7828 }
7829
7830 #[test]
7831 fn failure_marker_skipped_for_payment_errors() {
7832 assert!(!should_publish_failure_marker("propose", true));
7836 assert!(!should_publish_failure_marker("evaluate", true));
7837 }
7838}
7839pub mod nsed_worker;
7840pub use nsed_worker::{NatsNsedWorkerExt, NatsNsedWorkerStatusExt};