Skip to main content

quorum_rs/workers/
mod.rs

1//! NATS JetStream worker that bridges the orchestrator's task subjects to the
2//! agent's propose/evaluate methods.
3//!
4//! This module provides [`NatsNsedWorker`] — the main runtime loop for
5//! independent agent processes — along with configuration types and the
6//! [`NatsScratchpadStore`] persistence implementation.
7//!
8//! # Extension points
9//!
10//! - [`WorkerHook`] — intercept NATS publishes (e.g. for crypto wrapping)
11//! - [`UserToolHandlerFactory`] — inject per-task user tool handlers
12
13pub 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
39// ---------------------------------------------------------------------------
40// Passthrough protocol types
41// ---------------------------------------------------------------------------
42
43/// NATS subject pattern for passthrough requests:
44/// `{subject_prefix}.agent.{agent_id}.passthrough`
45///
46/// The orchestrator sends a core NATS request to this subject when a policy
47/// has `mode: passthrough`.  The agent replies synchronously (no JetStream).
48pub const PASSTHROUGH_SUBJECT_SUFFIX: &str = "passthrough";
49
50/// Request payload for passthrough mode.
51///
52/// The orchestrator serializes this as JSON and publishes it via NATS
53/// request-reply.  The agent deserializes, calls `ChatCapable::chat()`,
54/// and replies with [`PassthroughResponse`].
55#[derive(Debug, Clone, Serialize, Deserialize)]
56pub struct PassthroughRequest {
57    /// Session / room identifier for history tracking.
58    pub session_id: String,
59    /// OpenAI-compatible conversation messages.
60    pub messages: Vec<PassthroughMessage>,
61    /// Principal who submitted the request (for cost attribution).
62    pub operator_principal: String,
63}
64
65/// A single message in a passthrough conversation — role + content.
66#[derive(Debug, Clone, Serialize, Deserialize)]
67pub struct PassthroughMessage {
68    /// `"user"`, `"assistant"`, or `"system"`.
69    pub role: String,
70    pub content: String,
71}
72
73/// Response payload returned by the agent for a passthrough request.
74#[derive(Debug, Clone, Serialize, Deserialize)]
75pub struct PassthroughResponse {
76    /// The agent's text reply.
77    pub content: String,
78    /// Input token count reported by the LLM (if available).
79    pub input_tokens: Option<u32>,
80    /// Output token count reported by the LLM (if available).
81    pub output_tokens: Option<u32>,
82}
83
84/// Error envelope returned when the agent cannot handle a passthrough request.
85#[derive(Debug, Clone, Serialize, Deserialize)]
86pub struct PassthroughError {
87    pub error: String,
88}
89
90// ---------------------------------------------------------------------------
91// WorkerHook trait
92// ---------------------------------------------------------------------------
93
94/// Trait for intercepting worker lifecycle events.
95///
96/// Implementations can wrap, encrypt, or transform payloads before they are
97/// published to NATS. The default implementation is a no-op passthrough;
98/// crypto-wrapping implementations can be plugged in by callers.
99#[async_trait]
100pub trait WorkerHook: Send + Sync + Debug {
101    /// Called before publishing a proposal/evaluation result.
102    async fn before_publish(&self, _subject: &str, _payload: &mut Vec<u8>) -> Result<()> {
103        Ok(())
104    }
105}
106
107// ---------------------------------------------------------------------------
108// UserToolHandlerFactory trait
109// ---------------------------------------------------------------------------
110
111/// Factory for creating per-task user tool handlers.
112///
113/// The reference implementation [`NatsUserToolHandlerFactory`](crate::agents::NatsUserToolHandlerFactory)
114/// wraps [`UserToolHandler`](crate::agents::UserToolHandler), which depends on NATS internals.
115#[async_trait]
116pub trait UserToolHandlerFactory: Send + Sync + Debug {
117    /// Create a handler scoped to a specific task execution.
118    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// ---------------------------------------------------------------------------
130// WorkerConfig
131// ---------------------------------------------------------------------------
132
133/// Configuration for the NATS connection and behavior.
134///
135/// NATS subjects are split across two namespaces:
136/// - **`subject_prefix`** (`"nsed"`) — scientific deliberation protocol:
137///   tasks (`nsed.{session}.task.{agent}.{action}`), results, events.
138/// - **`api_prefix`** (`"sphera"`) — product API layer:
139///   job manifests (`sphera.jobs.manifest.>`), ACKs, heartbeats, pings.
140#[derive(Clone, Debug)]
141pub struct WorkerConfig {
142    /// NATS server URL (e.g. `nats://localhost:4222`).
143    pub nats_url: String,
144    /// JetStream stream name to consume from.
145    pub stream_name: String,
146    /// Durable consumer name for task messages.
147    pub consumer_name: String,
148    /// Subject prefix for the deliberation protocol (default: `"nsed"`).
149    pub subject_prefix: String,
150    /// Subject prefix for the product API layer (default: `"sphera"`).
151    pub api_prefix: String,
152    /// TTL for scratchpad data in seconds. Defaults to 7 days.
153    pub scratchpad_retention_secs: u64,
154    /// Optional NATS authentication credentials.
155    pub nats_auth: Option<NatsAuth>,
156    /// Max jobs this agent processes concurrently. Enforced as the pull
157    /// consumer's `max_ack_pending` — the server won't deliver the next task
158    /// until an in-flight one is acked. `None` leaves it unbounded (server
159    /// default). Set to `1` for agents whose jobs share mutable state (e.g. a
160    /// git repo a middleware resets per job) so concurrent jobs can't race.
161    pub max_concurrent_jobs: Option<usize>,
162}
163
164impl WorkerConfig {
165    /// Creates a new `WorkerConfig` with default retention settings.
166    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    /// Sets the subject prefix for deliberation protocol subjects.
180    pub fn with_subject_prefix(mut self, prefix: String) -> Self {
181        self.subject_prefix = prefix;
182        self
183    }
184
185    /// Sets the subject prefix for product API subjects.
186    pub fn with_api_prefix(mut self, prefix: String) -> Self {
187        self.api_prefix = prefix;
188        self
189    }
190
191    /// Sets the scratchpad retention TTL.
192    pub fn with_scratchpad_retention(mut self, secs: u64) -> Self {
193        self.scratchpad_retention_secs = secs;
194        self
195    }
196
197    /// Sets NATS authentication credentials.
198    pub fn with_nats_auth(mut self, auth: NatsAuth) -> Self {
199        self.nats_auth = Some(auth);
200        self
201    }
202
203    /// Caps how many jobs this agent processes concurrently (see
204    /// [`WorkerConfig::max_concurrent_jobs`]).
205    pub fn with_max_concurrent_jobs(mut self, n: usize) -> Self {
206        self.max_concurrent_jobs = Some(n);
207        self
208    }
209
210    /// The pull-consumer `max_ack_pending` implied by `max_concurrent_jobs`:
211    /// the cap when set, else `0` (server default = unbounded). Extracted so the
212    /// mapping is unit-testable without a live NATS.
213    fn max_ack_pending(&self) -> i64 {
214        self.max_concurrent_jobs.map(|n| n as i64).unwrap_or(0)
215    }
216}
217
218// ---------------------------------------------------------------------------
219// JobManifest
220// ---------------------------------------------------------------------------
221
222/// Job manifest broadcast by the orchestrator when a new deliberation is created.
223#[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// ---------------------------------------------------------------------------
233// NatsScratchpadStore
234// ---------------------------------------------------------------------------
235
236/// A wrapper around NATS KV that implements [`PersistenceStore`].
237///
238/// Provides durable, scoped key–value storage for agent scratchpad data.
239#[derive(Clone, Debug)]
240pub struct NatsScratchpadStore {
241    store: kv::Store,
242    js: jetstream::Context,
243    scope_prefix: String,
244}
245
246impl NatsScratchpadStore {
247    /// Creates a new scratchpad store scoped to `scope_prefix`.
248    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
401// ---------------------------------------------------------------------------
402// NatsNsedWorker
403// ---------------------------------------------------------------------------
404
405/// The main NATS worker that connects any [`NsedAgent`] implementation to the
406/// orchestrator's JetStream task/result subjects.
407///
408/// # Lifecycle
409///
410/// 1. **Connect** — Establishes NATS connection and creates per-agent KV buckets.
411/// 2. **Subscribe** — Binds durable pull consumers for tasks and manifests.
412/// 3. **Loop** — Processes messages: manifests → ACK, tasks → propose/evaluate.
413/// 4. **Dedup** — Checks idempotency KV before processing.
414pub struct NatsNsedWorker {
415    agent: Arc<dyn NsedAgent>,
416    /// Agent configuration for heartbeat metadata.
417    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    /// Optional shared status snapshot for the embedded status dashboard.
427    status: Option<SharedAgentStatus>,
428    /// Optional hook for intercepting NATS publishes (e.g. crypto wrapping).
429    hook: Option<Arc<dyn WorkerHook>>,
430    /// Optional factory for creating per-task user tool handlers.
431    user_tool_factory: Option<Arc<dyn UserToolHandlerFactory>>,
432    /// Optional chat-capable agent for the status server chat endpoint.
433    chat_agent: Option<Arc<dyn ChatCapable>>,
434    /// Optional response buffer for HITL control (holds responses before NATS publish).
435    response_buffer: Option<Arc<buffer::ResponseBuffer>>,
436    /// Pause flag — when true, the worker stops consuming new NATS tasks.
437    ///
438    /// This is independent of the buffer's pause flag: the buffer controls
439    /// whether completed responses are released, while this flag controls
440    /// whether new tasks are pulled from NATS.
441    paused: Arc<AtomicBool>,
442    /// Epoch-ms deadline until which this agent's remote model is considered
443    /// down (`0` = up). Armed when a task fails with a model-unavailable error
444    /// (e.g. 404); the heartbeat reports `model_down` while the deadline is in
445    /// the future so the scheduler stops assigning doomed tasks, and it clears
446    /// automatically on expiry so a transient outage doesn't bench forever.
447    model_down_until_ms: Arc<AtomicU64>,
448    /// Count of consecutive model-down task failures. Drives escalating bench
449    /// backoff via [`escalated_cooldown_ms`] so a chronically-dead model is
450    /// quiesced (progressively longer benches, capped) instead of re-entering
451    /// scheduling every fixed cooldown. Reset to `0` on any successful task.
452    model_down_strikes: Arc<AtomicU64>,
453    /// Strategy that decides whether a task-failure error means the remote model
454    /// is gone (→ bench). Defaults to [`HeuristicModelDownDetector`]; override
455    /// via [`NatsNsedWorker::with_model_down_detector`].
456    model_down_detector: Arc<dyn ModelDownDetector>,
457    /// Optional proactive availability probe over the provider's model catalog.
458    /// When set, the agent periodically checks whether its own model is still
459    /// listed and benches itself (arms `model_down`) BEFORE a task fails — the
460    /// proactive complement to the reactive [`model_down_detector`]. `None` =
461    /// reactive-only (default). Wire via [`NatsNsedWorker::with_model_availability`].
462    model_availability: Option<Arc<ModelAvailability>>,
463    /// Epoch-ms of the last proactive availability probe, throttling it to the
464    /// probe interval regardless of the (faster) heartbeat cadence.
465    last_availability_probe_ms: Arc<AtomicU64>,
466    /// Optional telemetry emitter for recording LLM call metrics.
467    telemetry: Option<TelemetryEmitterMux>,
468    /// Agent middleware pipelines, built from `agent_config.middleware`.
469    /// `None` when the hook point is unconfigured → zero overhead + no behavior
470    /// change for existing agents.
471    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-final hook — fires once on the orchestrator's `job_complete` event.
475    job_complete_mw: Option<Arc<crate::middleware::pipeline::MiddlewarePipeline>>,
476}
477
478impl NatsNsedWorker {
479    /// Creates a new worker, connecting to NATS and initializing KV buckets.
480    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    /// Like [`NatsNsedWorker::new`] but takes an already-erased
490    /// `Arc<dyn NsedAgent>`. The [`ProviderRegistry`] dispatch path
491    /// produces trait objects (the concrete agent type is chosen at
492    /// runtime by a [`ProviderFactory`]), so it can't satisfy the
493    /// `impl NsedAgent` bound on `new`.
494    ///
495    /// [`ProviderRegistry`]: crate::providers::ProviderRegistry
496    /// [`ProviderFactory`]: crate::providers::ProviderFactory
497    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        // Build the middleware pipelines once. `None` when a hook point has no
552        // entries, so the hot path stays untouched for agents without middleware.
553        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        // `?` — a middleware that fails to build (broken/missing dylib, builtin
561        // error) fails the agent's startup (fail-closed) rather than silently
562        // running without the guard.
563        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    /// Returns a reference to the telemetry mux, if one was provided.
619    pub fn telemetry(&self) -> Option<&TelemetryEmitterMux> {
620        self.telemetry.as_ref()
621    }
622
623    /// Sets a [`WorkerHook`] for intercepting NATS publishes.
624    pub fn with_hook(mut self, hook: Arc<dyn WorkerHook>) -> Self {
625        self.hook = Some(hook);
626        self
627    }
628
629    /// Override the model-down detection strategy (default:
630    /// [`HeuristicModelDownDetector`]). A deployment can supply a stricter or
631    /// provider-tuned [`ModelDownDetector`] that decides when a task-failure
632    /// error means the remote model is gone and the agent should bench itself.
633    pub fn with_model_down_detector(mut self, detector: Arc<dyn ModelDownDetector>) -> Self {
634        self.model_down_detector = detector;
635        self
636    }
637
638    /// Enable proactive model-availability self-benching: the agent periodically
639    /// checks its own model against the provider catalog via `probe` and arms
640    /// `model_down` if the model is no longer listed — before a task fails.
641    /// Without this, model-down is detected only reactively on a failed task.
642    pub fn with_model_availability(mut self, probe: Arc<ModelAvailability>) -> Self {
643        self.model_availability = Some(probe);
644        self
645    }
646
647    /// Interval between proactive availability probes (throttles the check that
648    /// rides the faster heartbeat tick).
649    const AVAILABILITY_PROBE_INTERVAL_MS: u64 = 300_000; // 5 min
650
651    /// Proactively check whether this agent's own model is still in the provider
652    /// catalog and self-bench (arm `model_down`) if not. Throttled to
653    /// [`Self::AVAILABILITY_PROBE_INTERVAL_MS`]; a no-op when no probe is wired.
654    /// Fail-open: an `Unknown` verdict (fetch failed / provider not covered)
655    /// never benches. Returns `true` if it armed model_down this call.
656    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            // Fail-open: keep serving on a catalog fetch error.
672            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    /// Install a [`SigningHook`] with an explicit [`AgentKeyPair`](crate::crypto::AgentKeyPair).
696    ///
697    /// Wraps outbound payloads in signed [`AuditEnvelope`]s. The keypair must
698    /// be provided — use [`auto_sign()`](Self::auto_sign) for a random keypair.
699    ///
700    /// Not installed by default to maintain backward compatibility.
701    ///
702    /// Requires the `audit` feature (enabled by default).
703    #[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    /// Install automatic signing with an auto-generated keypair.
713    ///
714    /// Equivalent to `with_signing(AgentKeyPair::generate())`. The keypair is
715    /// unique per worker instance — use `with_signing(AgentKeyPair::from_seed(...))`
716    /// for deterministic/reproducible signing.
717    ///
718    /// Requires the `audit` feature (enabled by default).
719    #[cfg(feature = "audit")]
720    pub fn auto_sign(self) -> Self {
721        self.with_signing(crate::crypto::AgentKeyPair::generate())
722    }
723
724    /// Sets a [`UserToolHandlerFactory`] for creating per-task user tool handlers.
725    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    /// The user-tool handler factory this worker was built with, if any. A worker WITHOUT
731    /// one silently drops every job-carried user tool (e.g. `ask_user`): the tool arrives in
732    /// the context but no handler is built, so it is never advertised over MCP. Exposed so a
733    /// test can assert the serve/build path actually wired it, and drive it end to end.
734    pub fn user_tool_factory(&self) -> Option<Arc<dyn UserToolHandlerFactory>> {
735        self.user_tool_factory.clone()
736    }
737
738    /// Sets a [`ChatCapable`] agent for the status server chat endpoint.
739    pub fn with_chat(mut self, chat: Arc<dyn ChatCapable>) -> Self {
740        self.chat_agent = Some(chat);
741        self
742    }
743
744    /// Enables the embedded status dashboard on the given port.
745    ///
746    /// Note: The actual HTTP server requires the `status-server` cargo
747    /// feature. Without that feature, this method only creates the shared
748    /// status snapshot (heartbeats and events are still recorded).
749    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    /// Returns the shared status snapshot handle, if status is enabled.
760    pub fn status(&self) -> Option<&SharedAgentStatus> {
761        self.status.as_ref()
762    }
763
764    /// Returns the agent config.
765    pub fn agent_config(&self) -> &AgentConfig {
766        &self.agent_config
767    }
768
769    /// Returns the agent ID.
770    pub fn agent_id(&self) -> &str {
771        &self.agent_id
772    }
773
774    /// Returns the chat-capable agent, if set.
775    pub fn chat_agent(&self) -> Option<&Arc<dyn ChatCapable>> {
776        self.chat_agent.as_ref()
777    }
778
779    /// Sets a [`ResponseBuffer`](buffer::ResponseBuffer) for HITL control.
780    ///
781    /// When set, completed responses are held in the buffer for `hold_duration`
782    /// before being published to NATS. The buffer can be paused, and individual
783    /// entries can be manually released or rejected via the control plane API.
784    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    /// Returns the response buffer, if set.
790    pub fn response_buffer(&self) -> Option<&Arc<buffer::ResponseBuffer>> {
791        self.response_buffer.as_ref()
792    }
793
794    /// Returns a handle to the worker's pause flag.
795    ///
796    /// This can be shared with external systems (e.g. the dashboard control
797    /// plane) to pause/resume the worker without needing a response buffer.
798    ///
799    /// **Note:** Mutating the returned `AtomicBool` directly (via
800    /// `handle.store()`) only toggles the task-consumption flag. It does
801    /// **not** pause or resume the response buffer. Production code should
802    /// prefer [`Self::pause()`] / [`Self::resume()`] which synchronise both
803    /// the flag and the buffer in a single call.
804    pub fn pause_handle(&self) -> Arc<AtomicBool> {
805        self.paused.clone()
806    }
807
808    /// Pause the worker — stops consuming new NATS tasks.
809    pub fn pause(&self) {
810        self.paused.store(true, Ordering::Relaxed);
811        // Also pause the buffer if present, so held entries aren't released
812        if let Some(ref buf) = self.response_buffer {
813            buf.pause();
814        }
815    }
816
817    /// Resume the worker — resumes consuming NATS tasks.
818    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    /// Returns whether the worker is currently paused.
826    pub fn is_paused(&self) -> bool {
827        self.paused.load(Ordering::Relaxed)
828    }
829
830    /// A handle to this agent's own event log (24h JetStream history). Public so
831    /// the multi-agent runner can hand the dashboard a per-agent read handle.
832    pub fn event_store(&self) -> AgentEventStore {
833        AgentEventStore::new(self.js.clone(), self.agent_id.clone())
834    }
835
836    /// Persist one event to the agent's log, best-effort. A publish failure is
837    /// logged and swallowed — the event log is operator telemetry, never on the
838    /// task's critical path.
839    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    /// Runs the worker loop, consuming messages from the task and manifest
850    /// consumers until the streams close or an unrecoverable error occurs.
851    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        // Wait for the stream to be created by the orchestrator
856        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        // Ensure the agent-scoped event log exists (24h retention). Best-effort:
886        // a failure here must not stop the worker from serving tasks.
887        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        // Task consumer
895        // Short ack_wait (30s) enables fast task recovery after agent crash.
896        // During long-running LLM calls (propose/evaluate), a background task
897        // sends `AckKind::Progress` heartbeats every 15s to extend the
898        // deadline. Without heartbeats, the message is redelivered within 30s
899        // — much faster than the previous 600s, allowing agents to resume
900        // in-flight tasks almost immediately after restart.
901        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), // short — heartbeats extend
908                    // Bound concurrent in-flight jobs for this agent (0 = unbounded).
909                    // The server withholds the next task until an in-flight one acks.
910                    max_ack_pending: self.config.max_ack_pending(),
911                    ..Default::default()
912                },
913            )
914            .await?;
915
916        // Manifest consumer (broadcast to all agents)
917        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        // Score event subscription — listens to round_summary events so the
931        // dashboard can show evaluation scores starting from round 1.
932        //
933        // Uses a Core NATS subscription (not JetStream) because round_summary
934        // events are published to per-session `nsed_results_{session_id}`
935        // streams, not the global `sphera_jobs` stream.  Core NATS sees all
936        // publishes regardless of which JetStream stream captures them.
937        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        // Job-complete subscription — the orchestrator's terminal event. Only
957        // subscribe when an `on_job_complete` hook is configured (zero overhead
958        // otherwise). Core NATS, same per-session publish pattern as scores.
959        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        // Passthrough subscription — Core NATS request-reply for PolicyMode::Passthrough.
979        // When the orchestrator routes a request directly to this agent (no deliberation
980        // cycle), it publishes to this subject and waits for a synchronous reply.
981        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        // Push initial "connected" event to dashboard
1004        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            // When paused, skip task consumption but keep draining buffer,
1023            // processing manifests, and sending heartbeats.
1024            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                        // Worker is paused — reject immediately instead of calling the LLM.
1093                        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 = (&current_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                            // pending_publish_depth/buffer_bytes:
1129                            // see docs/agent-sdk/reference/telemetry.md
1130                            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            // Hook interception point
1195            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            // Push to dashboard event log
1215            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    /// Process a `round_summary` event to update the dashboard score badge.
1230    ///
1231    /// The orchestrator publishes this after each evaluation phase with per-proposal
1232    /// scores.  We look up this agent's entry and call `push_score()` so the HITL
1233    /// dashboard can show colour-coded divergence immediately (even for round 1).
1234    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        // Extract session_id from subject: {prefix}.{session_id}.result.event.round_summary
1242        let session_id = session_id_from_subject(msg.subject.as_str(), &self.config.subject_prefix);
1243
1244        // Do not gate on active_jobs — that set tracks task-in-flight and is
1245        // cleared after each task completion, but round_summary events arrive
1246        // asynchronously after evaluation finishes (when the task is already
1247        // removed from active_jobs).  The agent_id check below provides the
1248        // real correctness filter, and already_has prevents duplicate scores.
1249
1250        // Find this agent's proposal score in the summary.
1251        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        // on_completion hook — fires per round-summary with the round's winner
1274        // (top aggregated_score) in `metadata.winner`. NOTE: this is per-round,
1275        // not a single job-final signal (the orchestrator owns halting), so a
1276        // completion middleware with once-only side effects must guard itself.
1277        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    /// Handle the orchestrator's terminal `job_complete` event — fire the
1304    /// `on_job_complete` hook **once** with the final winner. Distinct from the
1305    /// per-round `on_completion` (which stays for per-round agent reactions).
1306    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            // A clean winner consensus surfaces `project_advanced {project_id, head}`
1328            // in the verdict content — republish it as the "epic advanced, pull now"
1329            // notification so clients holding the project sync.
1330            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    /// Handle a passthrough request received on the core NATS subject.
1354    ///
1355    /// The orchestrator sends a [`PassthroughRequest`] and expects a synchronous
1356    /// reply with either a [`PassthroughResponse`] (success) or a
1357    /// [`PassthroughError`] (failure, wrapped in a JSON `error` field).
1358    ///
1359    /// This method never returns an error — failures are replied back to the
1360    /// orchestrator so it can surface them to the caller.
1361    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        // Convert PassthroughMessage → async_openai ChatCompletionRequestMessage
1408        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        // Passthrough timeout: derived from `response_sla_secs` but
1467        // capped at 600 s because passthrough is a single-shot request
1468        // (no propose/evaluate rounds) and a hung Claude CLI would
1469        // otherwise block the handler forever with no error surfaced to
1470        // the caller. The cap is intentionally below the full deliberation
1471        // budget — operators who need longer should use deliberation mode.
1472        //
1473        // `response_sla_secs == 0` is the "no explicit budget" sentinel
1474        // (per the branch-1 `PolicySla::job_timeout` invariant). A naive
1475        // `.min(600)` would yield 0 here and cause an immediate
1476        // `Elapsed` failure on every passthrough — fall back to the
1477        // 600 s cap instead.
1478        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    /// Run one middleware pipeline for a hook point. Returns the (possibly
1538    /// transformed) `content` on pass, `None` when the pipeline is unconfigured,
1539    /// or an `Err` when a middleware blocks (fails the task).
1540    #[allow(clippy::too_many_arguments)] // cohesive hook-invocation signature
1541    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        // Wrap in Arc so the heartbeat background task can send Progress acks
1570        // while the main task continues using the message for ack/payload access.
1571        let msg = std::sync::Arc::new(msg);
1572
1573        // Build a dedup key unique even across stream recreations
1574        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        // Record when the task was received for SLA-deadline buffer release.
1598        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        // Own the action string so `msg` can be moved into the buffer later.
1627        let action: String = subject_parts.last().unwrap_or(&"unknown").to_string();
1628        let action = action.as_str();
1629
1630        // Publish "agent_working" event
1631        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        // Push to dashboard event log
1653        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        // Attach scratchpad store to context
1663        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        // Construct UserToolHandler if user tools are registered and factory is available
1673        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        // Track this job as active (for heartbeat reporting)
1696        {
1697            let mut jobs = self.active_jobs.lock().unwrap();
1698            jobs.insert(session_id.clone());
1699        }
1700
1701        // Update status: task started
1702        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        // Record previous round's aggregated score for dashboard display.
1718        // The orchestrator populates `previous_own_score` on round 2+ propose tasks.
1719        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        // NTP-skew negatives clamp to zero — `job_age_at_accept_ms`
1743        // is `>= 0` by contract.
1744        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        // Execute the action with progress heartbeats.
1761        // The heartbeat task sends AckKind::Progress every 15s to extend the
1762        // JetStream ack deadline (30s), preventing premature redelivery during
1763        // long LLM calls while keeping ack_wait short for fast crash recovery.
1764        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; // skip immediate first tick
1769            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        // before_prompt hook — transform the task before the agent builds its
1785        // prompt. Mutates `task_description` (which feeds the PromptSet). No-op
1786        // when unconfigured; a block fails the task.
1787        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            // Thread the conversation id (top-level metadata) so patch-deliberation can
1793            // key the agent worktree on the stable thread — not the per-job id — and
1794            // keep the provider's cwd-scoped session resuming across turns. Top-level
1795            // (not under `patch_deliberation`): the dylib config-merge is a shallow
1796            // overlay that would clobber a nested key.
1797            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                // A middleware may declare a JSON schema to constrain (and force)
1822                // the proposal submission — thread it to the agent.
1823                if let Some(schema) = new.get("proposal_schema") {
1824                    if schema.is_object() {
1825                        context.forced_proposal_schema = Some(schema.clone());
1826                    }
1827                }
1828                // A middleware may declare a per-job worktree (e.g. patch-deliberation
1829                // `agent_working_dir`) — run the agent subprocess with cwd = that dir
1830                // instead of the stale launch dir, so bare git + relative reads hit
1831                // the frozen job-scoped tree.
1832                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                // Advertise this agent under its epic — project_id (+ epic_head) come
1836                // from the verdict (patch-deliberation surfaces them). Lets the fleet
1837                // route reads/discovery to a live holder of the project regardless of
1838                // each node's local path. No-op when there's no project_id.
1839                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        // Inject the reviewer as the react loop's submission validator so a
1862        // provider_response block (e.g. patch-deliberation "applied ZERO changes")
1863        // re-prompts the agent in-loop, reusing the loop's own retry budget instead
1864        // of a second one. Proposals only — evaluations have no provider_response.
1865        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        // Retry loop for transient transport errors (broken pipe, connection reset, etc.).
1875        // Wraps the full propose/evaluate call because the SDK cannot pinpoint where inside
1876        // the agent's implementation the transport failure occurred. Only errors matching
1877        // `is_transient_error()` are retried; LLM-level or logic errors break immediately.
1878        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                            // The agent's react loop already validated the submission via
1888                            // the injected `submission_validator` (a reviewer block re-prompts
1889                            // in-loop, reusing its retry budget). This provider_response call
1890                            // is the accepted proposal's commit + content transform; it is
1891                            // idempotent (self-cleaning) with the in-loop validation run.
1892                            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                            // Filter out evaluations targeting unknown candidates (LLM hallucinations).
1917                            // Only keep entries whose target_id matches a known candidate.
1918                            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; // suppress unused warning
1966                        break Err(e);
1967                    }
1968                }
1969            }
1970        };
1971        let task_duration_ms = task_start.elapsed().as_millis() as u64;
1972
1973        // Stop heartbeat — task complete (or failed), we'll ack/nack below
1974        heartbeat_handle.abort();
1975
1976        match execution_result {
1977            Ok(mut response_payload) => {
1978                // Extract a content preview before the payload is moved/consumed.
1979                // Pass candidates so evaluation previews include anonymized proposal text.
1980                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                // HITL buffer: hold response for operator review instead of
1993                // publishing immediately. The background drain task in run()
1994                // handles publication when the hold expires.
1995                //
1996                // The crypto-wrap hook (`before_publish`) runs at the actual
1997                // publish point — for the immediate path, just before
1998                // `nats.publish`; for the buffered path, in `drain_buffer`
1999                // after annotation injection and `restamp_published_at`.
2000                // This keeps signatures over the final on-the-wire bytes
2001                // and lets the restamp update the inner publish_at_ms
2002                // without invalidating any envelope.
2003                //
2004                // CRITICAL: We ack the JetStream message and mark it processed
2005                // IMMEDIATELY. This prevents JetStream from redelivering the
2006                // task (default ack_wait ~30s) which would bypass the buffer
2007                // and cause the orchestrator to receive duplicate responses.
2008                // The response payload stays in the buffer — only the NATS ack
2009                // is immediate, NOT the publish.
2010                let was_buffered = if let Some(ref buf) = self.response_buffer {
2011                    // Ack + dedup BEFORE buffering to prevent JetStream redelivery
2012                    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, // fallback; overridden by push_with_deadline if SLA set
2028                        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                    // Update status: buffered (not yet published)
2041                    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                    // No buffer — publish immediately (original behavior)
2061                    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                // Remove finished job from active set
2075                {
2076                    let mut jobs = self.active_jobs.lock().unwrap();
2077                    jobs.remove(&session_id);
2078                }
2079
2080                // Update status: always log the task to recent_tasks (for content
2081                // lookup), but only fire task_complete event for non-buffered tasks.
2082                // Buffered tasks already fired response_buffered above — the
2083                // dashboard uses that event to spawn the rain card.
2084                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                // Task succeeded → the remote model is reachable. Clear the
2117                // model-down strike count so a recovered model waits only the
2118                // base cooldown on any future failure, not an escalated bench.
2119                self.model_down_strikes.store(0, Ordering::Relaxed);
2120
2121                // queue_wait_ms / llm_attempts / tool_call_count /
2122                // pending_publish_depth: see docs/agent-sdk/reference/telemetry.md
2123                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                // Detect 402 Payment Required — auto-pause agent instead of
2143                // continuing to hammer a provider with no credits left.
2144                let is_payment_error = err_str.contains("402 Payment Required")
2145                    || err_str.contains("insufficient_quota")
2146                    || err_str.contains("billing");
2147
2148                // Remove failed job from active set
2149                {
2150                    let mut jobs = self.active_jobs.lock().unwrap();
2151                    jobs.remove(&session_id);
2152                }
2153
2154                // When propagate_payment_error is false, suppress the error
2155                // event and pause the worker so it stops pulling new tasks
2156                // (avoids wasting API calls against a provider with no credits).
2157                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                // Bench the agent when its remote model is unavailable (e.g. a
2171                // 404 from a removed model / dead endpoint): the next heartbeat
2172                // reports model_down so the scheduler stops assigning it doomed
2173                // tasks until the cooldown expires and it self-heals.
2174                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                    // `reason` is a short machine-readable classifier so
2189                    // the orchestrator (and telemetry consumers) can group
2190                    // bails as parse_error / timeout / tool_error / etc.
2191                    // without re-parsing `error`. Treated identically to a
2192                    // missing vote — the orchestrator advances the round on
2193                    // this event the same way it would on a phase-timeout
2194                    // for this agent.
2195                    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                    // TODO(removal): legacy session-wide event. Kept so
2207                    // existing dashboard SSE + audit consumers keep
2208                    // working through the migration. Remove once every
2209                    // consumer (dashboard.html, audit, telemetry
2210                    // forwarder) has switched to subscribing on the
2211                    // round-scoped `.failed` subject published below. At
2212                    // that point, drop this block and the corresponding
2213                    // `.legacy_subject_format` test.
2214                    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                    // Round-scoped failure marker for the orchestrator's
2227                    // per-phase consumer. Mirrors the verdict subject
2228                    // hierarchy (`result.{round}.{agent}.{action}`) with a
2229                    // `.failed` suffix so a single
2230                    // `filter_subjects=[verdict, verdict.failed]` consumer
2231                    // can wait on success-or-failure without payload-
2232                    // snooping for round / action / session.
2233                    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                // Update status: task failed
2252                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                // Payment errors get a distinct class so the orchestrator
2292                // can auto-resume when credits return.
2293                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    /// Publishes an agent heartbeat via core NATS pub/sub.
2317    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        // Collect reliability stats + last error from status snapshot
2329        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        // Update status snapshot for the embedded dashboard
2396        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    /// Drain ready entries from the response buffer and publish them to NATS.
2414    ///
2415    /// If the operator annotated or edited an entry, the annotations are
2416    /// injected into the payload JSON before publication. Annotations are
2417    /// also published to a dedicated audit subject for traceability.
2418    async fn drain_buffer(&self) {
2419        let Some(ref buf) = self.response_buffer else {
2420            return;
2421        };
2422
2423        // Adaptive SLA + auto-approve: when the status server is active,
2424        // use score-based divergence for the threshold check. Without a
2425        // status server, auto-approve with no divergence data (releases
2426        // immediately since the divergence gate passes on None).
2427        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            // Render `None` divergence explicitly as "n/a" rather than
2439            // substituting a sentinel like -1.0 — the latter is
2440            // indistinguishable from a real (if impossible) negative
2441            // score in aggregated logs and confuses operators reading
2442            // the pass-through path for agents without a status server.
2443            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            // Inject operator annotations into payload before publishing
2457            let publish_payload = if !entry.annotations.is_empty() || entry.edited {
2458                Self::inject_annotations(&entry)
2459            } else {
2460                entry.payload.clone()
2461            };
2462
2463            // Re-stamp publish-instant at the actual publish point so
2464            // buffer dwell + HITL review time don't inflate the
2465            // orchestrator-side `propagation_ms` reading.
2466            let mut publish_payload =
2467                Self::restamp_published_at(&publish_payload, chrono::Utc::now().timestamp_millis());
2468
2469            // Crypto-wrap hook runs AFTER restamp so any signature is
2470            // over the final on-the-wire bytes (including the fresh
2471            // publish_ts). Pre-buffer wrapping would invalidate the
2472            // signature when restamp mutated the payload.
2473            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                // Re-enqueue the entry so it's retried on the next drain cycle.
2497                // This is critical because the entry was already pre-acked from
2498                // JetStream — dropping it here means the response is permanently lost.
2499                // Add backoff to prevent hot-loop retries on persistent failures.
2500                entry.release_at = Instant::now() + std::time::Duration::from_secs(5);
2501                buf.push(entry).await;
2502                continue;
2503            }
2504
2505            // Publish annotations to a dedicated audit subject for traceability
2506            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            // Update status snapshot
2551            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                // Fire task_complete so the full lifecycle is visible:
2573                // agent_working → response_buffered → buffer_released → task_complete
2574                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    /// Inject operator annotations into a buffered response's payload JSON.
2584    ///
2585    /// For **proposals** (JSON objects): adds `operator_annotations` array and
2586    /// `edited_by: "operator"` (if edited) at the top level.
2587    ///
2588    /// Re-stamp `published_at_ms` on a serialized payload immediately
2589    /// before NATS publish. Callers should invoke this on the buffer
2590    /// drain path so that buffer dwell time + operator review delay
2591    /// don't leak into the orchestrator's `submission_received.propagation_ms`.
2592    /// The non-buffered immediate-publish path stamps at serde time
2593    /// and publishes microseconds later, so it does not need this.
2594    ///
2595    /// Handles both shapes:
2596    /// - **Proposal**: top-level JSON object with `published_at_ms`.
2597    /// - **Evaluation batch**: JSON array of `[target_id, {eval_obj}]`
2598    ///   tuples; each eval object's `published_at_ms` is updated.
2599    ///
2600    /// On any parse failure the original bytes are returned unchanged
2601    /// — losing a re-stamp is preferable to corrupting the payload.
2602    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    /// For **evaluations** (JSON arrays of `[target_id, {eval_obj}]` tuples):
2623    /// injects the same fields into each evaluation object within the array.
2624    ///
2625    /// If the payload is not valid JSON, returns the original bytes unchanged.
2626    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            // Proposal payload — inject at top level
2642            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            // Evaluation payload — array of [target_id, {eval_obj}] tuples.
2657            // Inject annotations into each evaluation object.
2658            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/// A middleware `Blocked` verdict surfaced as a typed error so the propose loop
2739/// can tell a re-promptable reviewer rejection (e.g. patch-deliberation
2740/// "applied ZERO changes") apart from a transport or logic failure. Its `Display`
2741/// is kept identical to the pre-typed message so log output is unchanged.
2742#[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/// Run a middleware pipeline for a hook point and return the (possibly
2757/// transformed) `content`, or an `Err` when a middleware blocks. Free fn so it's
2758/// unit-testable with a mock pipeline — no worker / NATS needed.
2759#[allow(clippy::too_many_arguments)] // cohesive hook-invocation signature
2760async 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/// Adapts the `provider_response` middleware into a [`SubmissionValidator`] the
2789/// agent's react loop runs on each `submit_proposal`. It runs the pipeline on the
2790/// proposal content and maps a [`MiddlewareBlocked`] verdict to the reason string
2791/// the loop feeds back to the model; a pass (or non-block error) returns `None`
2792/// (accept). The pipeline run is idempotent (patch-deliberation self-cleans the
2793/// worktree), so running it here per attempt AND once more for the accepted
2794/// proposal's commit/transform composes correctly.
2795#[derive(Debug)]
2796struct MiddlewareSubmissionValidator {
2797    pipeline: Arc<crate::middleware::pipeline::MiddlewarePipeline>,
2798    agent_id: String,
2799    session_id: String,
2800    round: u32,
2801}
2802
2803/// Build the react loop's submission validator for a task: a reviewer wrapping the
2804/// `provider_response` pipeline, but only for `propose` (evaluations have no
2805/// provider_response) and only when a pipeline is configured — else `None`.
2806fn 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
2848/// Extract the session id from a result-event subject shaped like
2849/// `{prefix}.{session_id}.result.event.{kind}`. `prefix` may be empty.
2850fn 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
2863/// The (content, metadata) a `job_complete` event contributes to the
2864/// `on_job_complete` hook context. `metadata.winner` = the final winner.
2865fn 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
2883/// The winning agent of a round summary = the highest aggregated score.
2884/// `None` for an empty score list. Among equal maxima, `max_by` returns the LAST,
2885/// so ties resolve to the last-listed proposal (deterministic — `proposal_scores`
2886/// is an ordered `Vec`). A NaN score (should be unreachable — `normalize_score`
2887/// guards against it — but defended here too) sorts LOWEST so it can never win over
2888/// a real score.
2889fn 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
2896/// Compare two aggregated scores with NaN sorting LOWEST (so a poisoned score never
2897/// wins). Finite scores compare naturally.
2898pub(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
2908/// Extract a human-readable content preview from a serialized response payload.
2909///
2910/// For proposals: extracts the `content` field (truncated to ~500 chars).
2911/// For evaluations: extracts scores and justification summaries.
2912fn extract_content_preview(
2913    payload: &[u8],
2914    action: &str,
2915    candidates: &[crate::agents::CandidateProposal],
2916) -> Option<String> {
2917    // Parse as serde_json::Value — cheap, no struct coupling.
2918    // Returns a JSON string with structured data for rich dashboard rendering.
2919    let val: serde_json::Value = serde_json::from_slice(payload).ok()?;
2920    let structured = match action {
2921        "propose" => {
2922            // Proposal: { "content": "...", "thought_process": "..." }
2923            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            // Truncate content at 2000 chars for preview
2934            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            // Evaluations: [ ["target_id", { "score": 7.5, "justification": "...", "stance": "...", ... }], ... ]
2962            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            // Include anonymized proposal content for candidates that appear
3028            // in the displayed evals (capped to the same 10-entry budget).
3029            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
3059/// Check whether an error is a transient transport/network failure suitable for retry.
3060///
3061/// Uses case-insensitive substring matching against known OS and HTTP transport
3062/// error messages so that variations in casing or wrapping don't cause misses.
3063fn 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
3081/// Categorise a task-execution error into a short machine-readable
3082/// abstention reason. The reason flows into the
3083/// [`Proposal::abstained`] / [`Evaluation::abstained`] sentinel
3084/// payload the worker publishes on bail so the orchestrator (and
3085/// telemetry consumers) can distinguish parse failures from iteration
3086/// caps without parsing the full error string.
3087/// How long (ms) to bench an agent after its remote model reports unavailable.
3088/// Long enough to skip a flapping/removed model across a job, short enough that
3089/// a recovered model rejoins on its own without operator intervention.
3090const MODEL_DOWN_COOLDOWN_MS: u64 = 300_000; // 5 minutes
3091
3092/// Bench duration (ms) for the Nth consecutive model-down strike: the base
3093/// cooldown doubled per strike, capped at 30 minutes. A single transient 404
3094/// waits [`MODEL_DOWN_COOLDOWN_MS`]; a chronically-dead model (e.g. a removed
3095/// endpoint that 404s every job) backs off toward the cap so it stops
3096/// re-entering scheduling every fixed cooldown. The strike count resets on any
3097/// successful task, so a recovered model waits only the base again.
3098fn escalated_cooldown_ms(strikes: u64) -> u64 {
3099    const CAP_MS: u64 = 1_800_000; // 30 minutes
3100    let shift = strikes.saturating_sub(1).min(20); // keep `1 << shift` in range
3101    MODEL_DOWN_COOLDOWN_MS
3102        .saturating_mul(1u64 << shift)
3103        .min(CAP_MS)
3104}
3105
3106/// True when a task error indicates the remote MODEL/endpoint is unavailable
3107/// (removed model / dead endpoint) rather than a transient, auth, rate, or
3108/// billing error. Only these bench the agent — over-matching (e.g. on a bare
3109/// "404" that could appear in a job id) is avoided by anchoring on the HTTP
3110/// status phrase and known model-not-found messages.
3111/// Decides, from a failed task's error text, whether the agent's remote model is
3112/// unavailable and the agent should bench itself. Pluggable so a deployment can
3113/// tighten or provider-tune the rule; [`HeuristicModelDownDetector`] is the
3114/// default. Must be conservative: a false positive benches a healthy agent, so
3115/// only genuine "this model does not exist" signals should return `true` — never
3116/// transient/auth/rate/billing errors.
3117pub trait ModelDownDetector: Send + Sync + std::fmt::Debug {
3118    /// `true` iff `error` indicates the remote model is unavailable.
3119    fn is_model_down(&self, error: &str) -> bool;
3120}
3121
3122/// Default detector: substring-matches the common provider phrasings for a
3123/// missing/removed model. Deliberately narrow — a bare "404" inside unrelated
3124/// text (a job id) must not trip it.
3125///
3126/// `410 Gone` belongs here as much as `404`: a provider that retires a model
3127/// answers it for a while, and it says more definitely than a 404 that the
3128/// resource is deliberately no longer served. Leaving it out cost two agents an
3129/// unbroken run of failures — they struck forever, never reported `model_down`,
3130/// and the scheduler kept assigning them work that could not succeed.
3131///
3132/// `400` is deliberately absent. A bad request is usually ours — a malformed
3133/// body, an unsupported parameter — and benching an agent for it would take a
3134/// healthy model out of service over our own bug.
3135#[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
3153/// Whether the model-down cooldown is still active. `until_ms == 0` means the
3154/// model was never marked down; otherwise it is down until `now_ms` reaches the
3155/// deadline, after which the agent is eligible again.
3156fn model_down_active(until_ms: u64, now_ms: u64) -> bool {
3157    until_ms != 0 && now_ms < until_ms
3158}
3159
3160/// Whether a proactive availability probe is due: either never run
3161/// (`last_ms == 0`) or `interval_ms` has elapsed since the last one. Throttles
3162/// the check so it runs on its own cadence, not on every (faster) heartbeat.
3163fn 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
3185/// Build the round-scoped failure marker subject. Mirrors the verdict
3186/// subject hierarchy (`{prefix}.{session}.result.{round}.{agent}.{action}`)
3187/// with a `.failed` tail. Pure-format so the exact wire shape is
3188/// pinned by unit test and the orchestrator can derive matching
3189/// `filter_subjects` from the same constants.
3190fn 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
3200/// Should the worker emit a `.failed` marker on bail for this action?
3201///
3202/// - `propose` / `evaluate`: yes — those phases have orchestrator-side
3203///   collectors that need the round-scoped signal.
3204/// - other actions (passthrough, heartbeat handlers, etc.): no — no
3205///   round-scoped collector waits on them.
3206/// - payment errors: no — orchestrator may retry once credits return;
3207///   a `.failed` marker would mislead the collector into permanently
3208///   counting the agent as a no-show for the round.
3209fn 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    // --- mock middleware for hook-invocation tests (no NATS needed) ---
3225
3226    // The default `stages()` excludes BeforePrompt/Completion/JobComplete; these
3227    // mocks are built directly (not via the per-entry build path that overrides
3228    // stages), so opt into every stage.
3229    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        // TODO(slop): placeholder identifier — pick a name that says what this is
3246        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        // TODO(slop): placeholder identifier — pick a name that says what this is
3262        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    /// Echoes the context it received (so a test can assert how the worker built it).
3274    #[derive(Debug)]
3275    struct ContextEchoMock;
3276    #[async_trait::async_trait]
3277    impl AgentMiddleware for ContextEchoMock {
3278        // TODO(slop): placeholder identifier — pick a name that says what this is
3279        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    // --- ReAct reviewer-block retry (propose_with_react_retry) ---
3358
3359    #[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        // A NaN-scored proposal at the END must not win over a strictly-larger real
3501        // score (max_by returns the last of Equal, and NaN compared naively is Equal
3502        // to all). NaN sorts lowest.
3503        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        // Unbounded by default → max_ack_pending 0 (server default).
3542        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    /// Tests that the default implementation of `WorkerHook::before_publish`
3758    /// is a no-op passthrough that returns `Ok(())`.
3759    #[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        // Payload should be unmodified by the default no-op implementation
3772        assert_eq!(payload, vec![1, 2, 3]);
3773    }
3774
3775    // ---- inject_annotations tests ----
3776
3777    /// A test-only no-op ack handle (buffer.rs's NoopAckHandle is private).
3778    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        // No edited_by or operator_annotations when not edited
3880        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        // Evaluation payload: array of [target_id, {eval_obj}] tuples
3889        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        // First eval tuple
3908        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        // Second eval tuple
3920        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        // Has annotation but not edited — annotations inject, edited_by does not
3934        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        // Annotation present
3948        assert!(eval_obj["operator_annotations"].is_array());
3949        assert_eq!(eval_obj["operator_annotations"][0]["comment"], "Reviewed");
3950        // Not edited — no edited_by field
3951        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        // Non-JSON payload should be returned unchanged
3961        assert_eq!(result, payload.to_vec());
3962    }
3963
3964    // ---- Score delivery tests ----
3965
3966    #[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        // First score entry
4025        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        // Simulate dedup guard — same job + round should not be pushed again
4035        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        // Different round should pass
4042        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        // Verify that the score extraction logic in handle_message is correct:
4052        // previous_own_score is from the PREVIOUS round, so we subtract 1.
4053        use crate::status::{AgentStatusSnapshot, ScoreEntry};
4054
4055        let mut snap = AgentStatusSnapshot::new("agent-1".into(), "model".into(), "p".into());
4056
4057        // Simulate: round_number=3, previous_own_score=Some(4.5)
4058        // → score is for round 2
4059        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); // round_number - 1
4083        assert!((entry.score - 4.5).abs() < f32::EPSILON);
4084        assert_eq!(entry.job_id, "job-X");
4085
4086        // mean_score should reflect the pushed score
4087        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        // Same logic as handle_message: only push when Some
4100        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        // No score should be pushed when previous_own_score is None
4118        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        // Edge case: round_number=1 → saturating_sub(1) = 0
4129        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); // saturating_sub(1) of 1 = 0
4146    }
4147
4148    #[test]
4149    fn test_score_extraction_skips_evaluate_action() {
4150        // Verify that score extraction only happens for "propose", not "evaluate".
4151        // This mirrors the `if action == "propose"` guard in handle_message.
4152        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    // ---- handle_round_summary edge case tests ----
4169
4170    #[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        // Simulate handler logic: search for "my-agent" in proposal_scores
4205        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        // No score pushed — agent not found in proposal_scores
4222        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![], // empty — all agents timed out
4244            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        // Verify session_id extraction from NATS subject
4275        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        // Verifies that round_summary handler finds and records this agent's score
4333        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        // Simulate the handler logic (without the active_jobs gate)
4379        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        // Calling handler twice for same round should not duplicate scores
4406        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        // Process twice
4443        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        // All scores are real (implicit max-score injection guarantees no sentinels).
4474        // A zero score is a valid QV score and should be pushed.
4475        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        // When round_number == 1, saturating_sub(1) yields 0 which is not a real
4496        // round — the guard `round_number > 1` should prevent pushing.
4497        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        // Simulate the guarded logic from handle_task
4505        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        // When round_number >= 2, previous_own_score should be recorded
4527        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    // ---- inject_annotations evaluate array edge cases ----
4553
4554    #[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        // Tuple with only target_id, no eval object — should be skipped gracefully
4567        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        // No crash — tuple[1] doesn't exist, so annotations not injected
4575        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        // Tuple where index 1 is a string, not an object — should be skipped
4583        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"); // unchanged
4590    }
4591
4592    #[test]
4593    fn test_inject_annotations_mixed_valid_and_invalid_tuples() {
4594        use crate::agents::{AnnotationType, OperatorAnnotation};
4595
4596        // Mix: valid tuple, malformed single-element, valid tuple
4597        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        // First tuple: valid → annotations injected
4615        let a = &arr[0].as_array().unwrap()[1];
4616        assert_eq!(a["edited_by"], "operator");
4617        assert!(a["operator_annotations"].is_array());
4618
4619        // Second tuple: malformed → no crash, no annotations
4620        let b = arr[1].as_array().unwrap();
4621        assert_eq!(b.len(), 1); // just ["agent-B"]
4622
4623        // Third tuple: valid → annotations injected
4624        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        // Array items that aren't arrays themselves — should be skipped
4631        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        // Should survive without panic — items that aren't arrays are skipped
4637        let arr = val.as_array().unwrap();
4638        assert_eq!(arr.len(), 3);
4639    }
4640
4641    // ---- NATS integration tests: ack_wait & redelivery ----
4642
4643    /// Helper: connect to a local NATS server, returning None if unavailable.
4644    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    /// Regression test: agent task messages must be redelivered promptly after
4655    /// a crash. With a short `ack_wait` (3s in this test, 30s in production),
4656    /// an unacked task message becomes available for another consumer within
4657    /// seconds instead of the previous 600s (10 minutes).
4658    ///
4659    /// This validates the mechanism that enables fast agent recovery after
4660    /// restart — agents pick up their unfinished tasks almost immediately.
4661    #[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        // Create a test stream that captures task subjects
4678        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        // Publish a task (simulates orchestrator dispatching propose)
4688        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        // Create consumer with SHORT ack_wait (3s for test speed)
4702        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        // Consume the message but DON'T ack (simulating crash)
4719        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        // Verify we got the right message
4727        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 message stream (simulating crash — DO NOT ack)
4731        drop(messages);
4732
4733        // Wait for ack_wait to expire (3s + buffer)
4734        tokio::time::sleep(std::time::Duration::from_secs(4)).await;
4735
4736        // Reconnect to the SAME durable consumer — message should be redelivered
4737        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        // Verify it was actually redelivered (num_delivered > 1)
4766        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        // Ack and cleanup
4775        let _ = redelivered.ack().await;
4776        let _ = js.delete_stream(&stream_name).await;
4777    }
4778
4779    /// Verify that `AckKind::Progress` heartbeats extend the ack deadline,
4780    /// preventing premature redelivery during long-running LLM calls.
4781    ///
4782    /// Flow: consume message → send Progress heartbeat → wait longer than
4783    /// original ack_wait → verify message is NOT redelivered (heartbeat
4784    /// extended the deadline).
4785    #[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), // 3s ack_wait
4833                    ..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        // Send Progress heartbeat at t=2s (before 3s ack_wait expires)
4847        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        // Wait another 2s (total 4s from first delivery, but heartbeat reset the clock)
4853        tokio::time::sleep(std::time::Duration::from_secs(2)).await;
4854
4855        // Now ack the message
4856        msg.ack().await.expect("final ack");
4857
4858        // Drop the message stream
4859        drop(messages);
4860
4861        // Try to consume again — there should be NO redelivered messages
4862        // because we successfully acked.
4863        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        // Should timeout — no messages to deliver since we acked
4882        assert!(
4883            result.is_err(),
4884            "No message should be redelivered after successful Progress+Ack"
4885        );
4886
4887        // Cleanup
4888        let _ = js.delete_stream(&stream_name).await;
4889    }
4890
4891    // -------------------------------------------------------------------
4892    // Mock agent for worker builder/control tests
4893    // -------------------------------------------------------------------
4894
4895    #[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    // -------------------------------------------------------------------
4918    // Worker pause/resume tests (require NATS)
4919    // -------------------------------------------------------------------
4920
4921    #[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); // We only need NATS to be available for worker construction
4931
4932        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        // Initially not paused
4949        assert!(!worker.is_paused(), "worker should start unpaused");
4950
4951        // Pause the worker
4952        worker.pause();
4953        assert!(worker.is_paused(), "worker should be paused after pause()");
4954
4955        // Resume the worker
4956        worker.resume();
4957        assert!(
4958            !worker.is_paused(),
4959            "worker should be unpaused after resume()"
4960        );
4961
4962        // Double pause is idempotent
4963        worker.pause();
4964        worker.pause();
4965        assert!(worker.is_paused(), "double pause should still be paused");
4966
4967        // Resume once is enough
4968        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        // Without buffer
4996        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        // With buffer
5002        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        // Verify pause_handle returns a shared Arc<AtomicBool>
5018        let handle = worker.pause_handle();
5019        assert!(
5020            !handle.load(Ordering::Relaxed),
5021            "handle should start as false"
5022        );
5023
5024        // Pause via worker, verify handle reflects it
5025        worker.pause();
5026        assert!(
5027            handle.load(Ordering::Relaxed),
5028            "handle should reflect paused state"
5029        );
5030
5031        // Also verify the buffer is paused when worker is paused
5032        assert!(
5033            worker.response_buffer().unwrap().is_paused(),
5034            "buffer should be paused when worker is paused"
5035        );
5036
5037        // Resume via worker
5038        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    /// Tests that externally mutating the `Arc<AtomicBool>` returned by
5050    /// `pause_handle()` is reflected by `is_paused()`.
5051    ///
5052    /// **Important:** This test exercises raw atomic mutation which bypasses
5053    /// the response-buffer pause logic. Production callers should use
5054    /// [`NatsNsedWorker::pause()`] / [`NatsNsedWorker::resume()`] instead,
5055    /// which synchronise both the atomic flag and the buffer state.
5056    #[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        // Externally set the handle (simulating dashboard control plane)
5086        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    // ===================================================================
5100    // inject_annotations: additional edge cases
5101    // ===================================================================
5102
5103    #[test]
5104    fn test_inject_annotations_primitive_json_value_passthrough() {
5105        // A JSON primitive (string, number, bool, null) is neither object nor
5106        // array, so inject_annotations should return the original bytes.
5107        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            // Should be round-trippable JSON but unchanged semantically
5116            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        // edited=true but annotations list is empty:
5128        // should add edited_by but NOT operator_annotations key
5129        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        // edited=false, has annotations:
5145        // should add operator_annotations but NOT edited_by
5146        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        // Evaluation array: edited=true, empty annotations.
5209        // Should add edited_by to each eval object but no operator_annotations.
5210        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        // Array where some items are not arrays (e.g. numbers or nulls).
5230        // Should survive without panic; non-array items left unchanged.
5231        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        // Only the valid tuple should have annotations injected
5247        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        // Non-array items remain unchanged
5251        assert_eq!(arr[0], 42);
5252        assert!(arr[2].is_null());
5253        assert_eq!(arr[3], "string");
5254    }
5255
5256    // ===================================================================
5257    // Payment error detection logic (pure string matching)
5258    // ===================================================================
5259
5260    /// Extracts the payment error detection logic into a testable form.
5261    /// This mirrors the inline logic at lines ~1157-1170 of handle_message.
5262    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        // propagate_payment_error = false → should suppress
5302        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        // propagate_payment_error = true → should NOT suppress
5310        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        // Non-payment errors → never suppress regardless of propagate flag
5317        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    // ===================================================================
5325    // Heartbeat status determination (pure logic)
5326    // ===================================================================
5327
5328    #[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        // With multiple jobs, active_job is some (though order is non-deterministic)
5362        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    // ===================================================================
5372    // Error string formatting in heartbeat (pure logic from lines 1234-1247)
5373    // ===================================================================
5374
5375    #[test]
5376    fn test_heartbeat_error_extraction_no_status() {
5377        // No status snapshot → defaults to (0, 0, None)
5378        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        // Push a successful task
5397        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        // Push an error task
5408        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        // Extract error using the same logic as publish_heartbeat
5419        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        // Create a task with a very long job_id to test truncation
5439        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        // Only successful tasks
5476        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    // ===================================================================
5499    // extract_content_preview (standalone function, lines 1549-1667)
5500    // ===================================================================
5501
5502    #[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        // No thought_process → no "tp" key
5533        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        // Content should be truncated to ~2000 chars + ellipsis
5563        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        // thought_process should be truncated to ~500 chars + ellipsis
5589        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        // Create 15 eval tuples; preview should only include first 10
5746        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        // content is missing → as_str() returns None → unwrap_or("") → empty → return None
5787        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        // Array items that are not arrays should produce no eval entries
5796        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        // Tuple with only target_id, no eval object
5809        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    // ===================================================================
5820    // WorkerConfig additional builder coverage
5821    // ===================================================================
5822
5823    #[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        // Other fields unchanged
5834        assert_eq!(config.subject_prefix, "nsed");
5835        assert_eq!(config.scratchpad_retention_secs, 86400 * 7);
5836    }
5837
5838    // ===================================================================
5839    // round_summary subject parsing: edge case — missing segment fallback
5840    // ===================================================================
5841
5842    #[test]
5843    fn test_round_summary_subject_parsing_missing_segment_fallback() {
5844        // Subject has fewer segments than expected → fallback to "?"
5845        let prefix = "nsed.v2.extra"; // 3 segments
5846        let subject = "nsed.v2.extra"; // only the prefix itself, no session_id segment
5847
5848        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) // index 3, but only 3 segments (0..2)
5856            .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        // Subject with just one segment after prefix
5865        let prefix = "nsed";
5866        let subject = "nsed.my-session";
5867
5868        let prefix_count = prefix.split('.').count(); // 1
5869        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    // ===================================================================
5879    // Buffer auto-approve controls (from buffer.rs)
5880    // ===================================================================
5881
5882    #[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        // Default is 1.0 (100%) so the default-on auto-approve mode is
5904        // a true pass-through: every divergence value in the clamped
5905        // [0, 1] range passes the `div > threshold` gate.
5906        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        // Explicitly disable auto_approve (on by default)
5941        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        // Push an entry with a future release_at
5953        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        // Divergence 0.5 > threshold 0.3 → no auto-release
5972        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        // Divergence 0.2 < threshold 0.5 → should auto-release all 3
6003        let count = buf.auto_release_if_eligible(Some(0.2)).await;
6004        assert_eq!(count, 3);
6005
6006        // Now they should drain
6007        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        // When divergence is None (no scores yet), trust the operator's
6014        // explicit opt-in to auto-approve
6015        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        // Stopped entries should not be auto-released
6065        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        // Entries whose release_at is already in the past should not be counted
6072        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, // already ready
6086            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        // Entry is already ready (release_at <= now), so auto_release shouldn't touch it
6095        // The function checks `entry.release_at > now` before modifying
6096        let count = buf.auto_release_if_eligible(Some(0.1)).await;
6097        assert_eq!(count, 0);
6098    }
6099
6100    // ===================================================================
6101    // Compute adaptive hold & divergence: additional edge cases
6102    // ===================================================================
6103
6104    #[test]
6105    fn test_compute_adaptive_hold_negative_score() {
6106        let base = std::time::Duration::from_secs(10);
6107        // score = -0.5 → soft = -0.5/1.5 = -0.333 → positive = 0.333
6108        // multiplier = 1 + (1 - 0.333) * 3 = 1 + 2.0 = 3.0
6109        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        // score = 3.0 → soft = 3/4 = 0.75 → positive = 0.875
6122        // multiplier = 1 + 0.125 * 3 = 1.375
6123        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        // amplification = 0 → multiplier = 1.0 always (no effect)
6136        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        // Zero base → result is always zero regardless of score
6144        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        // std_dev >= 1.0 → capped at 1.0
6151        let div = buffer::compute_divergence(Some(0.9), Some(1.2));
6152        // score_div: soft(0.9) = 0.9/1.9 = 0.474 → (1-0.474)/2 = 0.263
6153        // std_div = 1.2 / 1.0 = 1.2 → clamped to 1.0
6154        // effective = max(0.263, 1.0) = 1.0
6155        assert!((div.unwrap() - 1.0).abs() < 0.01);
6156    }
6157
6158    #[test]
6159    fn test_compute_divergence_zero_score() {
6160        // score = 0 → soft = 0 → (1-0)/2 = 0.5 (ambiguous)
6161        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        // score = 3.0 → soft = 0.75 → (1-0.75)/2 = 0.125 (mostly converged)
6168        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        // score = -1.0 → soft = -0.5 → (1-(-0.5))/2 = 0.75 (high divergence)
6175        let div = buffer::compute_divergence(Some(-1.0), None);
6176        assert!((div.unwrap() - 0.75).abs() < 0.01);
6177    }
6178
6179    // ===================================================================
6180    // AgentHeartbeat serialization
6181    // ===================================================================
6182
6183    #[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        // Roundtrip
6218        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    // ===================================================================
6257    // JobManifest edge cases
6258    // ===================================================================
6259
6260    #[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        // This mirrors the agent selection logic in handle_manifest
6286        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    // ===================================================================
6292    // NatsScratchpadStore scoped_key
6293    // ===================================================================
6294
6295    // We can't construct NatsScratchpadStore without NATS, but we can test
6296    // the scoped_key format by replicating the logic.
6297    #[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    // ===================================================================
6314    // extract_content_preview: Unicode truncation edge cases
6315    // ===================================================================
6316
6317    #[test]
6318    fn test_extract_preview_proposal_unicode_content_truncation() {
6319        // Multi-byte Unicode characters: content with CJK chars should truncate
6320        // at char boundaries, not byte boundaries.
6321        let long_content: String = "\u{4e16}\u{754c}".repeat(1200); // "世界" × 1200 = 2400 chars
6322        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        // Should be truncated to 2000 chars + ellipsis
6334        assert!(c.chars().count() <= 2002);
6335        assert!(c.ends_with('\u{2026}'));
6336        // Verify no panic from splitting inside a multi-byte boundary
6337    }
6338
6339    #[test]
6340    fn test_extract_preview_proposal_unicode_thought_truncation() {
6341        // thought_process with emoji characters truncated at char boundary
6342        let long_tp: String = "\u{1f600}".repeat(600); // grinning face emoji × 600
6343        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        // Exactly 2000 chars should NOT be truncated
6372        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        // 2001 chars → truncated to 2000 + ellipsis = 2001
6390        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        // Empty string thought_process should NOT produce a "tp" key
6415        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        // "content" is present but not a string (e.g. number) → as_str() returns None → ""
6434        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        // content is 42 → as_str() returns None → unwrap_or("") → empty → return None
6442        assert!(preview.is_none());
6443    }
6444
6445    // ===================================================================
6446    // extract_content_preview: evaluation edge cases
6447    // ===================================================================
6448
6449    #[test]
6450    fn test_extract_preview_eval_unicode_justification_truncation() {
6451        let long_j: String = "\u{4e16}".repeat(400); // CJK "世" × 400
6452        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); // thumbs up × 300
6469        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        // Eval object without "score" — should still produce an eval entry (without "s")
6520        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        // Tuple where first element is not a string — target defaults to "?"
6541        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        // Full evaluation with all optional fields: stance, textual_feedback,
6556        // category_scores, claim_assessments, disagreements
6557        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        // Eval object with no score, no justification, no stance, no feedback
6592        // Only the target ID should appear
6593        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        // No other fields should be present (they are all optional)
6603        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    // ===================================================================
6613    // extract_content_preview: props (anonymized proposal content)
6614    // ===================================================================
6615
6616    #[test]
6617    fn test_extract_preview_eval_props_truncated() {
6618        // Candidate content over 1000 chars → truncated with "…"
6619        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        // 1000 chars + "…"
6637        assert_eq!(val.chars().count(), 1001);
6638    }
6639
6640    #[test]
6641    fn test_extract_preview_eval_props_short_content() {
6642        // Content under 1000 chars → full content, no ellipsis
6643        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        // Two candidates, but only one appears in the eval array
6664        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        // Only Candidate_A is in the eval payload
6681        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    // ===================================================================
6698    // compute_divergence: additional edge cases
6699    // ===================================================================
6700
6701    #[test]
6702    fn test_compute_divergence_only_std_dev_no_score() {
6703        // Only std_dev, no score — should use std_div only
6704        // std_div = 0.3 / 1.0 = 0.3
6705        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        // score = 0.7 → soft = 0.7/1.7 = 0.412 → (1-0.412)/2 = 0.294
6713        // std_div = 0.35 / 1.0 = 0.35
6714        // effective = max(0.294, 0.35) = 0.35
6715        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        // score = -2.0 → soft = -2/3 = -0.667 → (1+0.667)/2 = 0.833
6722        // std_div = 0.05 / 1.0 = 0.05
6723        // effective = max(0.833, 0.05) = 0.833
6724        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        // score = 5.0 → soft = 5/6 = 0.833 → (1-0.833)/2 = 0.083
6731        // std_dev = 0.02 → std_div = 0.02
6732        // effective = max(0.083, 0.02) = 0.083
6733        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        // score = 3.0 → soft = 0.75 → (1-0.75)/2 = 0.125
6746        // std_dev = 0 → effective = 0.125
6747        let div = buffer::compute_divergence(Some(3.0), Some(0.0));
6748        assert!((div.unwrap() - 0.125).abs() < 0.01);
6749    }
6750
6751    // ===================================================================
6752    // status.rs: check_flags edge cases
6753    // ===================================================================
6754
6755    #[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        // Push 3 consistently negative scores → recent-3 avg < -0.3
6762        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        // Push scores with high variance → std_dev > 1.5
6783        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        // mean = 0.0, variance = ((-2)^2 + 2^2) / 2 = 4.0
6799        // std_dev = 2.0 → > 1.5
6800        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        // First: trigger low score flag with negative scores
6815        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        // Now push positive scores to clear the flag
6827        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        // Recent 3 avg = 0.8 → above -0.3 threshold
6838        // std_dev of [-0.5,-0.5,-0.5,0.8,0.8,0.8] ≈ 0.65 → below 1.5
6839        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        // Positive scores = endorsed proposals
6849        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        // Only 2 scores, both low — should NOT trigger low-score flag (needs >=3)
6870        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        // std_dev = 0.0 (both same score), so no divergence flag either
6886        // n < 3 → no low score check
6887        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        // Push 3 negative scores: consistently rejected
6900        // [-0.5, -0.4, -0.6] → avg = -0.5 < -0.3 → low score flag (checked first)
6901        // std_dev ≈ 0.08 → below 1.5 → no divergence flag
6902        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    // ===================================================================
6933    // push_score: std_dev computation edge cases
6934    // ===================================================================
6935
6936    #[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        // Push 55 scores (MAX_RECENT_SCORES is 50)
6987        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        // Oldest 5 should have been removed: first remaining is round 6
7003        assert_eq!(snap.recent_scores.front().unwrap().round, 6);
7004    }
7005
7006    // ===================================================================
7007    // WorkerConfig: Debug trait coverage
7008    // ===================================================================
7009
7010    #[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    // ===================================================================
7027    // JobManifest: Debug trait coverage
7028    // ===================================================================
7029
7030    #[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    // ===================================================================
7046    // ConfigPatch: additional apply coverage
7047    // ===================================================================
7048
7049    #[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    // ===================================================================
7104    // ConfigPatch: deny_unknown_fields
7105    // ===================================================================
7106
7107    #[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    // ===================================================================
7120    // inject_annotations: JSON number payload is primitive passthrough
7121    // ===================================================================
7122
7123    #[test]
7124    fn test_inject_annotations_deeply_nested_eval() {
7125        use crate::agents::{AnnotationType, OperatorAnnotation};
7126
7127        // Three-element tuple (unusual but valid JSON array item)
7128        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        // Should inject into tuple[1] which is the eval object
7141        assert_eq!(inner[1]["edited_by"], "operator");
7142        assert!(inner[1]["operator_annotations"].is_array());
7143        // Extra element should be preserved
7144        assert_eq!(inner.len(), 3);
7145        assert_eq!(inner[2], "extra-data");
7146    }
7147
7148    // ===================================================================
7149    // Buffer: compute_adaptive_hold with medium score
7150    // ===================================================================
7151
7152    #[test]
7153    fn test_compute_adaptive_hold_medium_score() {
7154        let base = std::time::Duration::from_secs(10);
7155        // score = 0.5 → soft = 0.5/1.5 = 0.333 → positive = 0.667
7156        // multiplier = 1 + 0.333 * 3 = 2.0
7157        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    // ===================================================================
7167    // Buffer: response SLA floor edge cases
7168    // ===================================================================
7169
7170    #[tokio::test]
7171    async fn test_buffer_response_sla_matches_hold() {
7172        // SLA initializes to the exact hold_duration — no floor.
7173        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    // ===================================================================
7187    // AgentLiveStatus: serialization and equality
7188    // ===================================================================
7189
7190    #[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        // Roundtrip
7199        let parsed: AgentLiveStatus = serde_json::from_str(&busy_json).unwrap();
7200        assert_eq!(parsed, AgentLiveStatus::Busy);
7201    }
7202
7203    // ===================================================================
7204    // status.rs: error_rate edge cases
7205    // ===================================================================
7206
7207    #[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    // ===================================================================
7252    // Buffer: stop/unstop with drain_stale interaction
7253    // ===================================================================
7254
7255    #[tokio::test]
7256    async fn test_drain_stale_drains_stopped_entries_from_other_jobs() {
7257        // Stopped entries from a different job should still be drained by drain_stale
7258        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    // ===================================================================
7284    // Buffer: list with overdue entries shows negative release_in_ms
7285    // ===================================================================
7286
7287    #[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        // Entry with release_at in the past (overdue)
7293        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, // already past or at now
7302            ack_handle: Box::new(TestAckHandle),
7303            msg_id: "m".into(),
7304            annotations: vec![],
7305            edited: false,
7306            stopped: true, // stopped so it stays in buffer for list()
7307        })
7308        .await;
7309
7310        // Small sleep to ensure now is past release_at
7311        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    // ===================================================================
7324    // Buffer: get_detail serialization of BufferEntryDetail
7325    // ===================================================================
7326
7327    #[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        // Verify that BufferEntryDetail serializes with flattened summary fields
7352        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    // ===================================================================
7364    // Buffer: update_payload_with_annotation for unknown ID
7365    // ===================================================================
7366
7367    #[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    // ===================================================================
7404    // NatsAuth: edge case for inline_creds only
7405    // ===================================================================
7406
7407    #[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    // ===================================================================
7428    // Proposal / Evaluation type serde roundtrips (covers type lines)
7429    // ===================================================================
7430
7431    #[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    // ===================================================================
7462    // extract_content_preview: action as arbitrary string
7463    // ===================================================================
7464
7465    #[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    // ===================================================================
7475    // BufferEntrySummary serialization
7476    // ===================================================================
7477
7478    // ===================================================================
7479    // Evaluation hallucination filter (mirrors logic in handle_task "evaluate")
7480    // ===================================================================
7481
7482    /// Helper: replicate the exact filter logic from the evaluate branch.
7483    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), // hallucinated
7522            make_eval("Candidate_B", 0.6),
7523            make_eval("UNKNOWN_X", 0.9), // hallucinated
7524            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        // Verify serialization round-trips correctly
7534        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    // ---- is_transient_error ----
7594
7595    #[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        // Transport errors often appear as part of a longer message
7647        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        // Observed: two agents striking forever on models the provider has
7696        // withdrawn. 410 Gone says so more definitely than 404 — the resource
7697        // existed and is deliberately no longer served — but only 404 was matched,
7698        // so the agents never reported model_down and the scheduler kept handing
7699        // them work that could not succeed.
7700        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        // A retirement announced in prose rather than by status.
7704        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        // Still not model-down: 410 inside unrelated text, and a 400 — which is
7708        // usually a malformed request of ours, not a missing model, so treating it
7709        // as down would bench a healthy agent over our own bug.
7710        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        // The observed incident: a 404 from a removed model / dead endpoint.
7718        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        // Not model-down: transient / auth / rate / billing / other statuses.
7723        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        // A bare "404" inside unrelated text (e.g. a job id) must not trip it.
7728        assert!(!d.is_model_down("job sphera_jobs-404 completed"));
7729    }
7730
7731    #[test]
7732    fn model_down_detector_is_pluggable() {
7733        // A deployment can swap in a stricter/looser rule via the trait.
7734        #[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        // Never probed → due immediately.
7748        assert!(availability_probe_due(0, 1_000, 300_000));
7749        // Within the interval → not due.
7750        assert!(!availability_probe_due(1_000, 1_500, 300_000));
7751        // Interval elapsed → due again.
7752        assert!(availability_probe_due(1_000, 301_000, 300_000));
7753        // Exactly at the boundary → due.
7754        assert!(availability_probe_due(1_000, 301_000, 300_000));
7755    }
7756
7757    #[test]
7758    fn model_down_active_respects_the_cooldown_deadline() {
7759        // 0 = never marked down.
7760        assert!(!model_down_active(0, 1_000));
7761        // Down while now is before the deadline.
7762        assert!(model_down_active(5_000, 4_999));
7763        // Recovered once now reaches/passes the deadline.
7764        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        // First strike (and the defensive 0 case) = base cooldown.
7771        assert_eq!(escalated_cooldown_ms(0), MODEL_DOWN_COOLDOWN_MS);
7772        assert_eq!(escalated_cooldown_ms(1), MODEL_DOWN_COOLDOWN_MS);
7773        // Each further consecutive strike doubles the bench.
7774        assert_eq!(escalated_cooldown_ms(2), MODEL_DOWN_COOLDOWN_MS * 2);
7775        assert_eq!(escalated_cooldown_ms(3), MODEL_DOWN_COOLDOWN_MS * 4);
7776        // *8 would be 2.4M ms, so it saturates at the 30-minute cap.
7777        assert_eq!(escalated_cooldown_ms(4), 1_800_000);
7778        assert_eq!(escalated_cooldown_ms(100), 1_800_000);
7779        // Monotonic non-decreasing up to the cap, never above it.
7780        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        // Round 0 is uncommon in production but legal; the worker must
7795        // not panic on it.
7796        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        // SDK consumers can override `subject_prefix` for multi-tenant
7803        // isolation. The failure-marker fn must respect that prefix
7804        // instead of hard-coding "nsed".
7805        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        // Payment errors get auto-retry once credits return; a
7833        // permanent missing-vote signal would mislead the round
7834        // collector into counting the agent as a no-show.
7835        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};