Skip to main content

quorum_rs/workers/
buffer.rs

1//! Response buffer for HITL (Human-in-the-Loop) agent control.
2//!
3//! Sits between LLM completion and NATS publish, allowing operators to
4//! inspect, hold, release, or reject agent responses before they reach
5//! the orchestrator.
6
7use crate::agents::OperatorAnnotation;
8use serde::Serialize;
9use std::collections::VecDeque;
10use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
11use std::time::{Duration, Instant};
12use tokio::sync::RwLock;
13use utoipa::ToSchema;
14
15// ---------------------------------------------------------------------------
16// BufferedResponse
17// ---------------------------------------------------------------------------
18
19/// Callback to ack the original NATS JetStream message.
20///
21/// Wrapping the ack operation in a trait object lets the buffer work with
22/// both real NATS messages and test mocks without exposing the private
23/// `async_nats::jetstream::context::AckContext` type.
24#[async_trait::async_trait]
25pub trait AckHandle: Send + Sync {
26    /// Acknowledge the underlying message.
27    async fn ack(&self) -> anyhow::Result<()>;
28}
29
30/// [`AckHandle`] backed by a real NATS JetStream message.
31pub struct NatsAckHandle(pub async_nats::jetstream::Message);
32
33#[async_trait::async_trait]
34impl AckHandle for NatsAckHandle {
35    async fn ack(&self) -> anyhow::Result<()> {
36        self.0
37            .ack()
38            .await
39            .map_err(|e| anyhow::anyhow!("ack failed: {}", e))
40    }
41}
42
43/// No-op [`AckHandle`] for entries whose NATS message was already acked
44/// at buffer-push time.
45///
46/// When the HITL buffer holds a response for operator review, the original
47/// JetStream message must be acked **immediately** to prevent redelivery.
48/// The response stays in the buffer (not published to the orchestrator) until
49/// the operator approves or the hold timer expires.  This handle replaces
50/// `NatsAckHandle` so that `drain_buffer()` doesn't attempt a redundant ack.
51pub struct PreAckedHandle;
52
53#[async_trait::async_trait]
54impl AckHandle for PreAckedHandle {
55    async fn ack(&self) -> anyhow::Result<()> {
56        Ok(()) // Already acked at push time
57    }
58}
59
60/// A completed agent response held in the buffer before NATS publication.
61pub struct BufferedResponse {
62    /// Unique ID for this buffer entry (UUID).
63    pub id: String,
64    /// Action type: `"propose"` or `"evaluate"`.
65    pub action: String,
66    /// Session / job ID.
67    pub job_id: String,
68    /// Deliberation round number.
69    pub round: u32,
70    /// NATS subject to publish to when released.
71    pub reply_subject: String,
72    /// Serialized payload bytes.
73    pub payload: Vec<u8>,
74    /// When the response was completed by the LLM.
75    pub created_at: Instant,
76    /// When the response should auto-release (created_at + hold_duration).
77    pub release_at: Instant,
78    /// Handle to ack the original NATS message when the entry is released.
79    pub ack_handle: Box<dyn AckHandle>,
80    /// Dedup key for idempotency.
81    pub msg_id: String,
82    /// Operator annotations accumulated during HITL review.
83    pub annotations: Vec<OperatorAnnotation>,
84    /// Whether the payload was edited by an operator.
85    pub edited: bool,
86    /// Whether the operator has stopped (reversibly rejected) this entry.
87    ///
88    /// Stopped entries remain in the buffer but are skipped by
89    /// [`ResponseBuffer::drain_ready`] — they won't be published to NATS
90    /// until un-stopped. This allows the operator to undo a rejection and
91    /// continue editing.
92    pub stopped: bool,
93}
94
95// ---------------------------------------------------------------------------
96// BufferEntrySummary
97// ---------------------------------------------------------------------------
98
99/// Lightweight summary of a buffered entry for the dashboard UI.
100#[derive(Debug, Clone, Serialize, ToSchema)]
101pub struct BufferEntrySummary {
102    /// Buffer entry ID.
103    pub id: String,
104    /// Action type: `"propose"` or `"evaluate"`.
105    pub action: String,
106    /// Full session/job ID (frontend truncates to last 4 chars).
107    pub job_id: String,
108    /// Deliberation round number.
109    pub round: u32,
110    /// Milliseconds since the response was buffered.
111    pub age_ms: u64,
112    /// Milliseconds until auto-release. Negative = overdue (held by pause).
113    pub release_in_ms: i64,
114    /// Whether the operator has stopped (reversibly rejected) this entry.
115    pub stopped: bool,
116}
117
118/// Full detail of a buffered entry, including the deserialized payload content.
119///
120/// Used by the dashboard when the operator clicks a specific buffer entry
121/// to inspect, edit, or annotate the response before release.
122#[derive(Debug, Clone, Serialize, ToSchema)]
123pub struct BufferEntryDetail {
124    #[serde(flatten)]
125    #[schema(inline)]
126    pub summary: BufferEntrySummary,
127    /// Deserialized response payload (the Proposal or Evaluation JSON).
128    /// Can be an object (proposal) or array (evaluation pairs).
129    pub content: serde_json::Value,
130}
131
132// ---------------------------------------------------------------------------
133// ResponseBuffer
134// ---------------------------------------------------------------------------
135
136/// Thread-safe response buffer with pause/resume and manual release/reject.
137///
138/// Hold duration is dynamically adjustable via [`set_hold_duration`] to support
139/// adaptive SLA — low-scoring agents can have their hold increased to give
140/// operators more review time.
141pub struct ResponseBuffer {
142    pending: RwLock<VecDeque<BufferedResponse>>,
143    /// Base hold duration as configured at startup (milliseconds).
144    base_hold_duration_ms: u64,
145    /// Current effective hold duration (milliseconds). Updated atomically
146    /// by the adaptive SLA system.
147    hold_duration_ms: AtomicU64,
148    paused: AtomicBool,
149    /// Response SLA in milliseconds. When set (> 0), [`push_with_deadline`]
150    /// computes `release_at` relative to the task-receive time instead of
151    /// using the fixed hold duration. This keeps responses in the buffer
152    /// for the full SLA duration — card reaching the bottom = auto-release.
153    response_sla_ms: AtomicU64,
154    /// Whether auto-approve mode is enabled for this agent.
155    ///
156    /// When enabled, entries are auto-released immediately if the agent's
157    /// effective divergence score is below [`auto_approve_threshold`].
158    auto_approve: AtomicBool,
159    /// Divergence threshold for auto-approve, stored as `value × 1000`
160    /// (e.g. 1000 = 1.0 = 100%).
161    ///
162    /// The threshold only gates entries that are **already pending** with a
163    /// future `release_at`: [`auto_release_if_eligible`](Self::auto_release_if_eligible)
164    /// promotes them to "ready now" when the reported divergence is at or
165    /// below this value. New entries pushed via
166    /// [`push_with_deadline`](Self::push_with_deadline) while `auto_approve`
167    /// is on **skip the SLA timer entirely** and never consult the
168    /// threshold — they land with `release_at = now` regardless.
169    ///
170    /// Consequently the only way to actually gate new responses by
171    /// divergence is the mid-flight toggle pattern: disable `auto_approve`
172    /// so incoming entries accumulate under the SLA deadline, then later
173    /// re-enable `auto_approve` with a lower threshold to drain only the
174    /// low-divergence entries.
175    ///
176    /// The default of `1000` (100%) combined with `auto_approve: true`
177    /// makes the out-of-the-box behavior a true pass-through that never
178    /// holds responses. Operators who want divergence-gated review must
179    /// disable `auto_approve`; lowering the threshold alone does not gate
180    /// new arrivals.
181    auto_approve_threshold_milli: AtomicU64,
182}
183
184impl ResponseBuffer {
185    /// Create a new buffer with the given hold duration.
186    ///
187    /// The hold duration initializes the response SLA for deadline-based
188    /// release via [`push_with_deadline`]. The dashboard can later override
189    /// the SLA via [`set_response_sla`].
190    ///
191    /// `Duration::ZERO` means responses drain immediately (pass-through mode).
192    /// Any non-zero duration is used as-is — callers control the review window.
193    pub fn new(hold_duration: Duration) -> Self {
194        let ms = hold_duration.as_millis() as u64;
195        Self {
196            pending: RwLock::new(VecDeque::new()),
197            base_hold_duration_ms: ms,
198            hold_duration_ms: AtomicU64::new(ms),
199            paused: AtomicBool::new(false),
200            response_sla_ms: AtomicU64::new(ms),
201            auto_approve: AtomicBool::new(true),
202            auto_approve_threshold_milli: AtomicU64::new(1000), // default: 1.0 (100%) — release everything
203        }
204    }
205
206    /// The current effective hold duration.
207    pub fn hold_duration(&self) -> Duration {
208        Duration::from_millis(self.hold_duration_ms.load(Ordering::Relaxed))
209    }
210
211    /// The base hold duration as configured at startup.
212    pub fn base_hold_duration(&self) -> Duration {
213        Duration::from_millis(self.base_hold_duration_ms)
214    }
215
216    /// Dynamically update the effective hold duration.
217    ///
218    /// Used by the adaptive SLA system to slow down or speed up the buffer
219    /// based on agent scores.
220    pub fn set_hold_duration(&self, duration: Duration) {
221        self.hold_duration_ms
222            .store(duration.as_millis() as u64, Ordering::Relaxed);
223    }
224
225    /// Set the response SLA (used by [`push_with_deadline`] to compute release time).
226    ///
227    /// `Duration::ZERO` disables the deadline (pass-through).
228    /// Any non-zero value is used as-is.
229    pub fn set_response_sla(&self, sla: Duration) {
230        self.response_sla_ms
231            .store(sla.as_millis() as u64, Ordering::Relaxed);
232    }
233
234    /// Current response SLA duration, or `None` if not configured.
235    pub fn response_sla(&self) -> Option<Duration> {
236        let ms = self.response_sla_ms.load(Ordering::Relaxed);
237        if ms == 0 {
238            None
239        } else {
240            Some(Duration::from_millis(ms))
241        }
242    }
243
244    /// Add a completed response to the buffer.
245    pub async fn push(&self, entry: BufferedResponse) {
246        self.pending.write().await.push_back(entry);
247    }
248
249    /// Add a completed response, computing `release_at` from the SLA deadline.
250    ///
251    /// `release_at = task_received + response_sla`
252    ///
253    /// The buffer holds the response for the full SLA duration. The rainfall
254    /// animation on the dashboard runs for exactly this duration — card
255    /// reaching the bottom = SLA expired = auto-release.
256    ///
257    /// The orchestrator already sets round timeouts that respect all agent
258    /// SLAs, so no reserve is needed at the buffer level.
259    ///
260    /// Falls back to the caller-provided `release_at` if no SLA is configured.
261    pub async fn push_with_deadline(&self, mut entry: BufferedResponse, task_received: Instant) {
262        // When auto-approve is ON, bypass the SLA timer entirely —
263        // release immediately on the next drain_ready() call.
264        if self.auto_approve.load(Ordering::Relaxed) {
265            entry.release_at = Instant::now();
266            self.pending.write().await.push_back(entry);
267            return;
268        }
269        let sla_ms = self.response_sla_ms.load(Ordering::Relaxed);
270        if sla_ms > 0 {
271            let sla = Duration::from_millis(sla_ms);
272            let deadline = task_received + sla;
273            let now = Instant::now();
274            entry.release_at = if deadline > now { deadline } else { now };
275        } else {
276            entry.release_at = Instant::now();
277        }
278        self.pending.write().await.push_back(entry);
279    }
280
281    /// Drain entries whose `release_at` has passed, unless paused.
282    ///
283    /// Returns the drained entries (caller is responsible for publishing + ack).
284    /// When paused, always returns an empty vec.
285    pub async fn drain_ready(&self) -> Vec<BufferedResponse> {
286        if self.paused.load(Ordering::Relaxed) {
287            return Vec::new();
288        }
289        let now = Instant::now();
290        let mut pending = self.pending.write().await;
291        let mut ready = Vec::new();
292        let mut remaining = VecDeque::with_capacity(pending.len());
293        for entry in pending.drain(..) {
294            if now >= entry.release_at && !entry.stopped {
295                ready.push(entry);
296            } else {
297                remaining.push_back(entry);
298            }
299        }
300        *pending = remaining;
301        ready
302    }
303
304    /// Return summaries of all buffered entries (for the dashboard UI).
305    pub async fn list(&self) -> Vec<BufferEntrySummary> {
306        let now = Instant::now();
307        let pending = self.pending.read().await;
308        pending
309            .iter()
310            .map(|entry| {
311                let age = now.duration_since(entry.created_at);
312                let release_in = if now >= entry.release_at {
313                    -(now.duration_since(entry.release_at).as_millis() as i64)
314                } else {
315                    entry.release_at.duration_since(now).as_millis() as i64
316                };
317                BufferEntrySummary {
318                    id: entry.id.clone(),
319                    action: entry.action.clone(),
320                    job_id: entry.job_id.clone(),
321                    round: entry.round,
322                    age_ms: age.as_millis() as u64,
323                    release_in_ms: release_in,
324                    stopped: entry.stopped,
325                }
326            })
327            .collect()
328    }
329
330    /// Manually release a specific entry by ID, regardless of hold duration.
331    ///
332    /// Returns the entry if found, or `None` if not in the buffer.
333    pub async fn release(&self, id: &str) -> Option<BufferedResponse> {
334        let mut pending = self.pending.write().await;
335        if let Some(pos) = pending.iter().position(|e| e.id == id) {
336            pending.remove(pos)
337        } else {
338            None
339        }
340    }
341
342    /// Reject (discard) a specific entry by ID.
343    ///
344    /// Returns the entry if found (caller should ack the message without
345    /// publishing), or `None` if not in the buffer.
346    pub async fn reject(&self, id: &str) -> Option<BufferedResponse> {
347        // Same removal logic as release — the caller decides whether to publish.
348        self.release(id).await
349    }
350
351    /// Remove all entries whose `job_id` does NOT match the given current job.
352    ///
353    /// Returns the removed entries (caller is responsible for ack-ing them).
354    /// This prevents stale responses from previous deliberations from
355    /// lingering in the operator review queue.
356    pub async fn drain_stale(&self, current_job_id: &str) -> Vec<BufferedResponse> {
357        let mut pending = self.pending.write().await;
358        let mut stale = Vec::new();
359        let mut remaining = VecDeque::with_capacity(pending.len());
360        for entry in pending.drain(..) {
361            if entry.job_id != current_job_id {
362                stale.push(entry);
363            } else {
364                remaining.push_back(entry);
365            }
366        }
367        *pending = remaining;
368        stale
369    }
370
371    /// Pause the buffer: `drain_ready()` will return empty and the worker
372    /// should also stop pulling new NATS tasks.
373    pub fn pause(&self) {
374        self.paused.store(true, Ordering::Relaxed);
375    }
376
377    /// Resume the buffer: `drain_ready()` resumes normal operation.
378    pub fn resume(&self) {
379        self.paused.store(false, Ordering::Relaxed);
380    }
381
382    /// Whether the buffer is currently paused.
383    pub fn is_paused(&self) -> bool {
384        self.paused.load(Ordering::Relaxed)
385    }
386
387    // -- Auto-approve controls -----------------------------------------------
388
389    /// Enable or disable auto-approve mode.
390    ///
391    /// When enabled, entries whose agent divergence is below the configured
392    /// threshold are auto-released immediately instead of waiting for the
393    /// hold timer or manual operator action.
394    pub fn set_auto_approve(&self, enabled: bool) {
395        self.auto_approve.store(enabled, Ordering::Relaxed);
396    }
397
398    /// Whether auto-approve mode is currently enabled.
399    pub fn is_auto_approve(&self) -> bool {
400        self.auto_approve.load(Ordering::Relaxed)
401    }
402
403    /// Set the divergence threshold for auto-approve (0.0 to 1.0).
404    ///
405    /// Values are clamped to `[0.0, 1.0]`. Stored internally as thousandths.
406    pub fn set_auto_approve_threshold(&self, threshold: f32) {
407        let clamped = threshold.clamp(0.0, 1.0);
408        self.auto_approve_threshold_milli
409            .store((clamped * 1000.0) as u64, Ordering::Relaxed);
410    }
411
412    /// Current auto-approve divergence threshold (0.0 to 1.0).
413    pub fn auto_approve_threshold(&self) -> f32 {
414        self.auto_approve_threshold_milli.load(Ordering::Relaxed) as f32 / 1000.0
415    }
416
417    /// When auto-approve is enabled and the agent's divergence is at or
418    /// below the threshold, mark all non-stopped pending entries for
419    /// immediate release. The gate is strictly `div > threshold` → block,
420    /// so a threshold of `1.0` (the default) releases every entry because
421    /// `compute_divergence` clamps to `[0.0, 1.0]`.
422    ///
423    /// When divergence is `None` (no scores yet), the operator's explicit
424    /// opt-in to auto-approve takes precedence — entries are released.
425    /// The threshold only blocks release when we **have** divergence data
426    /// strictly exceeding the threshold.
427    ///
428    /// Returns the number of entries marked for auto-release.
429    pub async fn auto_release_if_eligible(&self, divergence: Option<f32>) -> usize {
430        if !self.auto_approve.load(Ordering::Relaxed) {
431            return 0;
432        }
433        // When we have divergence data, check against threshold.
434        // When we don't (no scores yet), trust the operator's explicit click.
435        if let Some(div) = divergence {
436            if div > self.auto_approve_threshold() {
437                return 0; // Divergence too high → require manual review
438            }
439        }
440
441        let now = Instant::now();
442        let mut pending = self.pending.write().await;
443        let mut count = 0;
444        for entry in pending.iter_mut() {
445            if !entry.stopped && entry.release_at > now {
446                entry.release_at = now;
447                count += 1;
448            }
449        }
450        count
451    }
452
453    /// Number of entries currently in the buffer.
454    pub async fn len(&self) -> usize {
455        self.pending.read().await.len()
456    }
457
458    /// Whether the buffer is empty.
459    pub async fn is_empty(&self) -> bool {
460        self.pending.read().await.is_empty()
461    }
462
463    /// Return the full detail of a specific buffer entry, including
464    /// the deserialized response payload (for operator inspection/editing).
465    pub async fn get_detail(&self, id: &str) -> Option<BufferEntryDetail> {
466        let now = Instant::now();
467        let pending = self.pending.read().await;
468        pending.iter().find(|e| e.id == id).map(|entry| {
469            let age = now.duration_since(entry.created_at);
470            let release_in = if now >= entry.release_at {
471                -(now.duration_since(entry.release_at).as_millis() as i64)
472            } else {
473                entry.release_at.duration_since(now).as_millis() as i64
474            };
475            let content = serde_json::from_slice(&entry.payload).unwrap_or(serde_json::Value::Null);
476            BufferEntryDetail {
477                summary: BufferEntrySummary {
478                    id: entry.id.clone(),
479                    action: entry.action.clone(),
480                    job_id: entry.job_id.clone(),
481                    round: entry.round,
482                    age_ms: age.as_millis() as u64,
483                    release_in_ms: release_in,
484                    stopped: entry.stopped,
485                },
486                content,
487            }
488        })
489    }
490
491    /// Update the payload of a specific buffer entry (operator edit).
492    ///
493    /// Returns `true` if the entry was found and updated, `false` otherwise.
494    pub async fn update_payload(&self, id: &str, new_payload: Vec<u8>) -> bool {
495        let mut pending = self.pending.write().await;
496        if let Some(entry) = pending.iter_mut().find(|e| e.id == id) {
497            entry.payload = new_payload;
498            true
499        } else {
500            false
501        }
502    }
503
504    /// Add an operator comment to a buffer entry without modifying the payload.
505    ///
506    /// Returns `true` if the entry was found and annotated, `false` otherwise.
507    pub async fn add_comment(&self, id: &str, annotation: OperatorAnnotation) -> bool {
508        let mut pending = self.pending.write().await;
509        if let Some(entry) = pending.iter_mut().find(|e| e.id == id) {
510            entry.annotations.push(annotation);
511            true
512        } else {
513            false
514        }
515    }
516
517    /// Mark a buffer entry for immediate release by setting its `release_at`
518    /// to now.
519    ///
520    /// The entry stays in the buffer — the worker's `drain_buffer()` loop will
521    /// pick it up on the next cycle (≤500ms) and handle the NATS publish.
522    /// This avoids needing a NATS client in the status server.
523    ///
524    /// **Note:** The stopped flag is preserved. Stopped entries must be explicitly
525    /// unstopped (or use [`force_release`]) before they can drain.
526    ///
527    /// Returns `true` if the entry was found and marked, `false` otherwise.
528    pub async fn mark_for_release(&self, id: &str) -> bool {
529        let mut pending = self.pending.write().await;
530        if let Some(entry) = pending.iter_mut().find(|e| e.id == id) {
531            entry.release_at = Instant::now();
532            true
533        } else {
534            false
535        }
536    }
537
538    /// Atomically unstop **and** mark a buffer entry for immediate release.
539    ///
540    /// Combines [`unstop`] + [`mark_for_release`] in a single lock acquisition,
541    /// eliminating the race window where `drain_ready()` could observe the entry
542    /// as unstopped with a stale (already-passed) `release_at`.
543    ///
544    /// Returns `true` if the entry was found, `false` otherwise.
545    pub async fn force_release(&self, id: &str) -> bool {
546        let mut pending = self.pending.write().await;
547        if let Some(entry) = pending.iter_mut().find(|e| e.id == id) {
548            entry.stopped = false;
549            entry.release_at = Instant::now();
550            true
551        } else {
552            false
553        }
554    }
555
556    /// Stop (reversibly reject) a buffer entry.
557    ///
558    /// Stopped entries remain in the buffer but are skipped by
559    /// [`drain_ready`] — they won't auto-release. The operator can later
560    /// call [`unstop`] to make the entry eligible for release again.
561    ///
562    /// Returns `true` if the entry was found and stopped, `false` otherwise.
563    pub async fn stop(&self, id: &str) -> bool {
564        let mut pending = self.pending.write().await;
565        if let Some(entry) = pending.iter_mut().find(|e| e.id == id) {
566            entry.stopped = true;
567            true
568        } else {
569            false
570        }
571    }
572
573    /// Un-stop a previously stopped buffer entry.
574    ///
575    /// The entry becomes eligible for [`drain_ready`] again. If its
576    /// `release_at` has already passed, it will drain on the next cycle.
577    ///
578    /// Returns `true` if the entry was found and un-stopped, `false` otherwise.
579    pub async fn unstop(&self, id: &str) -> bool {
580        let mut pending = self.pending.write().await;
581        if let Some(entry) = pending.iter_mut().find(|e| e.id == id) {
582            entry.stopped = false;
583            true
584        } else {
585            false
586        }
587    }
588
589    /// Update the payload of a buffer entry AND record an edit annotation.
590    ///
591    /// Marks the entry as `edited = true` and appends the annotation.
592    /// Returns `true` if the entry was found and updated, `false` otherwise.
593    pub async fn update_payload_with_annotation(
594        &self,
595        id: &str,
596        new_payload: Vec<u8>,
597        annotation: OperatorAnnotation,
598    ) -> bool {
599        let mut pending = self.pending.write().await;
600        if let Some(entry) = pending.iter_mut().find(|e| e.id == id) {
601            entry.payload = new_payload;
602            entry.edited = true;
603            entry.annotations.push(annotation);
604            true
605        } else {
606            false
607        }
608    }
609}
610
611// ---------------------------------------------------------------------------
612// Adaptive SLA computation
613// ---------------------------------------------------------------------------
614
615/// Compute an adaptive hold duration based on an agent's mean score.
616///
617/// Low-scoring agents get longer hold durations, giving operators more time
618/// to review and potentially intervene before release. Well-converged agents
619/// flow at the base speed.
620///
621/// # Formula
622///
623/// ```text
624/// soft_norm  = score / (1 + |score|)       ∈ (-1, +1)
625/// positive   = (soft_norm + 1) / 2         ∈ (0, 1)
626/// multiplier = 1 + (1 - positive) × amplification
627/// result     = base × multiplier
628/// ```
629///
630/// `mean_score` is the rolling mean of `aggregated_score` values — signed QV
631/// sums that can exceed [-1, +1]. The soft-normalization compresses any
632/// magnitude into (-1, +1) before mapping to the hold multiplier.
633///
634/// With `amplification = 3.0`:
635/// - Score +3.0 → positive ≈ 0.875 → 1.375× base
636/// - Score +1.0 → positive = 0.75  → 1.75× base
637/// - Score  0.0 → positive = 0.50  → 2.50× base
638/// - Score -1.0 → positive = 0.25  → 3.25× base
639/// - Score -3.0 → positive ≈ 0.125 → 3.625× base
640///
641/// Returns `base` unchanged if `mean_score` is `None` (no scores yet).
642pub fn compute_adaptive_hold(
643    base: Duration,
644    mean_score: Option<f32>,
645    amplification: f32,
646) -> Duration {
647    let Some(score) = mean_score else {
648        return base;
649    };
650    let positive = soft_normalize_positive(score);
651    let multiplier = 1.0 + (1.0 - positive) * amplification;
652    Duration::from_secs_f64(base.as_secs_f64() * multiplier as f64)
653}
654
655/// Compute the effective divergence score for an agent (0.0 = converged,
656/// 1.0 = fully divergent).
657///
658/// `aggregated_score` is a signed QV sum (`Σ score_q_s`) that can be any real
659/// number — positive means endorsed, negative means rejected, magnitude grows
660/// with evaluator count. We soft-normalize to (-1, +1) then map to [0, 1]
661/// divergence.
662///
663/// - Score divergence: `(1 − soft_norm(mean_score)) / 2`
664///   where `soft_norm(s) = s / (1 + |s|)` maps ℝ → (-1, +1).
665///   Score +∞ → divergence 0, score −∞ → divergence 1, score 0 → divergence 0.5.
666/// - Std-dev divergence: `clamp(0, 1, score_std_dev / 1.0)`
667///   (signed QV scores per evaluator ∈ [-1, +1]; std_dev ≥ 1.0 = maximal disagreement)
668/// - Effective: `max(score_divergence, std_dev_divergence)`
669///
670/// Returns `None` if no scores are available (divergence unknown).
671/// Soft-normalize a signed score from ℝ → (0, 1).
672///
673/// Maps `s / (1 + |s|)` from ℝ → (-1, +1), then shifts to (0, 1):
674/// - score → +∞  ⟹ 1.0
675/// - score = 0   ⟹ 0.5
676/// - score → -∞  ⟹ 0.0
677fn soft_normalize_positive(score: f32) -> f32 {
678    let soft = score / (1.0 + score.abs());
679    ((soft + 1.0) / 2.0).clamp(0.0, 1.0)
680}
681
682pub fn compute_divergence(mean_score: Option<f32>, score_std_dev: Option<f32>) -> Option<f32> {
683    let score_div = mean_score.map(|s| 1.0 - soft_normalize_positive(s));
684    let std_div = score_std_dev.map(|sd| sd.clamp(0.0, 1.0));
685    match (score_div, std_div) {
686        (Some(a), Some(b)) => Some(a.max(b)),
687        (Some(a), None) => Some(a),
688        (None, Some(b)) => Some(b),
689        (None, None) => None,
690    }
691}
692
693// ---------------------------------------------------------------------------
694// Tests
695// ---------------------------------------------------------------------------
696
697#[cfg(test)]
698mod tests {
699    use super::*;
700
701    /// No-op ack handle for testing — buffer tests never actually ack.
702    struct NoopAckHandle;
703
704    #[async_trait::async_trait]
705    impl AckHandle for NoopAckHandle {
706        async fn ack(&self) -> anyhow::Result<()> {
707            Ok(())
708        }
709    }
710
711    /// Create a minimal BufferedResponse for testing.
712    fn make_entry(id: &str, action: &str, job_id: &str, hold: Duration) -> BufferedResponse {
713        let now = Instant::now();
714        BufferedResponse {
715            id: id.to_string(),
716            action: action.to_string(),
717            job_id: job_id.to_string(),
718            round: 1,
719            reply_subject: format!("nsed.{}.result.1.agent.{}", job_id, action),
720            payload: b"{}".to_vec(),
721            created_at: now,
722            release_at: now + hold,
723            ack_handle: Box::new(NoopAckHandle),
724            msg_id: format!("msg-{}", id),
725            annotations: Vec::new(),
726            edited: false,
727            stopped: false,
728        }
729    }
730
731    #[tokio::test]
732    async fn test_buffer_push_and_len() {
733        let buf = ResponseBuffer::new(Duration::from_secs(10));
734        assert_eq!(buf.len().await, 0);
735        assert!(buf.is_empty().await);
736
737        buf.push(make_entry("a", "propose", "job-1", Duration::from_secs(10)))
738            .await;
739        buf.push(make_entry(
740            "b",
741            "evaluate",
742            "job-2",
743            Duration::from_secs(10),
744        ))
745        .await;
746        assert_eq!(buf.len().await, 2);
747        assert!(!buf.is_empty().await);
748    }
749
750    #[tokio::test]
751    async fn test_buffer_drain_respects_hold_duration() {
752        let buf = ResponseBuffer::new(Duration::from_secs(60));
753        buf.push(make_entry("a", "propose", "job-1", Duration::from_secs(60)))
754            .await;
755
756        // Not enough time has passed — nothing should drain
757        let drained = buf.drain_ready().await;
758        assert!(drained.is_empty());
759        assert_eq!(buf.len().await, 1);
760    }
761
762    #[tokio::test]
763    async fn test_buffer_drain_releases_ready() {
764        let buf = ResponseBuffer::new(Duration::ZERO);
765        // hold=0 means release_at == created_at (immediately ready)
766        buf.push(make_entry("a", "propose", "job-1", Duration::ZERO))
767            .await;
768        buf.push(make_entry("b", "evaluate", "job-2", Duration::ZERO))
769            .await;
770
771        let drained = buf.drain_ready().await;
772        assert_eq!(drained.len(), 2);
773        assert!(buf.is_empty().await);
774    }
775
776    #[tokio::test]
777    async fn test_buffer_pause_stops_drain() {
778        let buf = ResponseBuffer::new(Duration::ZERO);
779        buf.push(make_entry("a", "propose", "job-1", Duration::ZERO))
780            .await;
781
782        buf.pause();
783        assert!(buf.is_paused());
784
785        let drained = buf.drain_ready().await;
786        assert!(drained.is_empty(), "paused buffer should not drain");
787        assert_eq!(buf.len().await, 1, "entry should still be in buffer");
788    }
789
790    #[tokio::test]
791    async fn test_buffer_resume_releases_overdue() {
792        let buf = ResponseBuffer::new(Duration::ZERO);
793        buf.push(make_entry("a", "propose", "job-1", Duration::ZERO))
794            .await;
795
796        buf.pause();
797        let drained = buf.drain_ready().await;
798        assert!(drained.is_empty());
799
800        buf.resume();
801        assert!(!buf.is_paused());
802        let drained = buf.drain_ready().await;
803        assert_eq!(drained.len(), 1);
804    }
805
806    #[tokio::test]
807    async fn test_buffer_release_by_id() {
808        let buf = ResponseBuffer::new(Duration::from_secs(60));
809        buf.push(make_entry("a", "propose", "job-1", Duration::from_secs(60)))
810            .await;
811        buf.push(make_entry(
812            "b",
813            "evaluate",
814            "job-2",
815            Duration::from_secs(60),
816        ))
817        .await;
818
819        let released = buf.release("a").await;
820        assert!(released.is_some());
821        assert_eq!(released.unwrap().id, "a");
822        assert_eq!(buf.len().await, 1);
823    }
824
825    #[tokio::test]
826    async fn test_buffer_reject_by_id() {
827        let buf = ResponseBuffer::new(Duration::from_secs(60));
828        buf.push(make_entry("a", "propose", "job-1", Duration::from_secs(60)))
829            .await;
830
831        let rejected = buf.reject("a").await;
832        assert!(rejected.is_some());
833        assert_eq!(rejected.unwrap().id, "a");
834        assert!(buf.is_empty().await);
835    }
836
837    #[tokio::test]
838    async fn test_buffer_release_unknown_id() {
839        let buf = ResponseBuffer::new(Duration::from_secs(60));
840        buf.push(make_entry("a", "propose", "job-1", Duration::from_secs(60)))
841            .await;
842
843        let released = buf.release("nonexistent").await;
844        assert!(released.is_none());
845        assert_eq!(buf.len().await, 1, "existing entry should remain");
846    }
847
848    #[tokio::test]
849    async fn test_buffer_list_returns_summaries() {
850        let buf = ResponseBuffer::new(Duration::from_secs(30));
851        buf.push(make_entry(
852            "entry-1",
853            "propose",
854            "job-abcd1234",
855            Duration::from_secs(30),
856        ))
857        .await;
858
859        let list = buf.list().await;
860        assert_eq!(list.len(), 1);
861        assert_eq!(list[0].id, "entry-1");
862        assert_eq!(list[0].action, "propose");
863        assert_eq!(list[0].job_id, "job-abcd1234");
864        assert_eq!(list[0].round, 1);
865        assert!(list[0].release_in_ms > 0, "should still be holding");
866    }
867
868    #[tokio::test]
869    async fn test_buffer_zero_hold_drains_immediately() {
870        let buf = ResponseBuffer::new(Duration::ZERO);
871        for i in 0..5 {
872            buf.push(make_entry(
873                &format!("e{}", i),
874                "propose",
875                &format!("job-{}", i),
876                Duration::ZERO,
877            ))
878            .await;
879        }
880        let drained = buf.drain_ready().await;
881        assert_eq!(drained.len(), 5);
882        assert!(buf.is_empty().await);
883    }
884
885    #[tokio::test]
886    async fn test_get_detail_returns_content() {
887        let buf = ResponseBuffer::new(Duration::from_secs(30));
888        let payload = serde_json::json!({"title": "My proposal", "content": "Hello world"});
889        let now = Instant::now();
890        buf.push(BufferedResponse {
891            id: "detail-1".to_string(),
892            action: "propose".to_string(),
893            job_id: "job-xyz".to_string(),
894            round: 3,
895            reply_subject: "nsed.job-xyz.result.3.agent.propose".to_string(),
896            payload: serde_json::to_vec(&payload).unwrap(),
897            created_at: now,
898            release_at: now + Duration::from_secs(30),
899            ack_handle: Box::new(NoopAckHandle),
900            msg_id: "msg-detail-1".to_string(),
901            annotations: Vec::new(),
902            edited: false,
903            stopped: false,
904        })
905        .await;
906
907        let detail = buf.get_detail("detail-1").await;
908        assert!(detail.is_some());
909        let detail = detail.unwrap();
910        assert_eq!(detail.summary.id, "detail-1");
911        assert_eq!(detail.summary.action, "propose");
912        assert_eq!(detail.summary.job_id, "job-xyz");
913        assert_eq!(detail.summary.round, 3);
914        assert!(detail.summary.release_in_ms > 0);
915        assert_eq!(detail.content["title"], "My proposal");
916        assert_eq!(detail.content["content"], "Hello world");
917    }
918
919    #[tokio::test]
920    async fn test_get_detail_returns_none_for_unknown_id() {
921        let buf = ResponseBuffer::new(Duration::from_secs(30));
922        buf.push(make_entry("a", "propose", "job-1", Duration::from_secs(30)))
923            .await;
924        assert!(buf.get_detail("nonexistent").await.is_none());
925    }
926
927    #[tokio::test]
928    async fn test_get_detail_invalid_payload_returns_null_content() {
929        let buf = ResponseBuffer::new(Duration::from_secs(30));
930        let now = Instant::now();
931        buf.push(BufferedResponse {
932            id: "bad-json".to_string(),
933            action: "propose".to_string(),
934            job_id: "job-1".to_string(),
935            round: 1,
936            reply_subject: "nsed.job-1.result.1.agent.propose".to_string(),
937            payload: b"not valid json!".to_vec(),
938            created_at: now,
939            release_at: now + Duration::from_secs(30),
940            ack_handle: Box::new(NoopAckHandle),
941            msg_id: "msg-bad".to_string(),
942            annotations: Vec::new(),
943            edited: false,
944            stopped: false,
945        })
946        .await;
947
948        let detail = buf.get_detail("bad-json").await.unwrap();
949        assert_eq!(detail.content, serde_json::Value::Null);
950    }
951
952    #[tokio::test]
953    async fn test_update_payload_replaces_content() {
954        let buf = ResponseBuffer::new(Duration::from_secs(30));
955        buf.push(make_entry(
956            "upd-1",
957            "evaluate",
958            "job-1",
959            Duration::from_secs(30),
960        ))
961        .await;
962
963        let new_payload = serde_json::json!({"scores": [8, 9, 7]});
964        let updated = buf
965            .update_payload("upd-1", serde_json::to_vec(&new_payload).unwrap())
966            .await;
967        assert!(updated);
968
969        // Verify via get_detail
970        let detail = buf.get_detail("upd-1").await.unwrap();
971        assert_eq!(detail.content["scores"], serde_json::json!([8, 9, 7]));
972    }
973
974    #[tokio::test]
975    async fn test_update_payload_unknown_id_returns_false() {
976        let buf = ResponseBuffer::new(Duration::from_secs(30));
977        buf.push(make_entry("a", "propose", "job-1", Duration::from_secs(30)))
978            .await;
979
980        let result = buf.update_payload("nonexistent", b"{}".to_vec()).await;
981        assert!(!result);
982        // Original entry unaffected
983        assert_eq!(buf.len().await, 1);
984    }
985
986    #[tokio::test]
987    async fn test_add_comment_records_annotation() {
988        use crate::agents::{AnnotationType, OperatorAnnotation};
989
990        let buf = ResponseBuffer::new(Duration::from_secs(30));
991        buf.push(make_entry(
992            "ann-1",
993            "propose",
994            "job-1",
995            Duration::from_secs(30),
996        ))
997        .await;
998
999        let annotation = OperatorAnnotation {
1000            annotation_type: AnnotationType::Comment,
1001            comment: "Looks good".to_string(),
1002            timestamp: "2026-03-02T12:00:00Z".to_string(),
1003            original_content_hash: None,
1004        };
1005
1006        assert!(buf.add_comment("ann-1", annotation).await);
1007
1008        // Verify via drain_ready won't drain (still held), but we can
1009        // check the entry is still there
1010        assert_eq!(buf.len().await, 1);
1011    }
1012
1013    #[tokio::test]
1014    async fn test_add_comment_unknown_id_returns_false() {
1015        use crate::agents::{AnnotationType, OperatorAnnotation};
1016
1017        let buf = ResponseBuffer::new(Duration::from_secs(30));
1018        buf.push(make_entry("a", "propose", "job-1", Duration::from_secs(30)))
1019            .await;
1020
1021        let annotation = OperatorAnnotation {
1022            annotation_type: AnnotationType::Comment,
1023            comment: "test".to_string(),
1024            timestamp: "2026-03-02T12:00:00Z".to_string(),
1025            original_content_hash: None,
1026        };
1027
1028        assert!(!buf.add_comment("nonexistent", annotation).await);
1029    }
1030
1031    #[tokio::test]
1032    async fn test_update_payload_with_annotation_marks_edited() {
1033        use crate::agents::{AnnotationType, OperatorAnnotation};
1034
1035        let buf = ResponseBuffer::new(Duration::ZERO);
1036        buf.push(make_entry("edit-1", "propose", "job-1", Duration::ZERO))
1037            .await;
1038
1039        let annotation = OperatorAnnotation {
1040            annotation_type: AnnotationType::Edit,
1041            comment: "Fixed wording".to_string(),
1042            timestamp: "2026-03-02T12:00:00Z".to_string(),
1043            original_content_hash: Some("abc123".to_string()),
1044        };
1045
1046        let new_payload = serde_json::json!({"content": "edited"});
1047        assert!(
1048            buf.update_payload_with_annotation(
1049                "edit-1",
1050                serde_json::to_vec(&new_payload).unwrap(),
1051                annotation
1052            )
1053            .await
1054        );
1055
1056        // Drain the entry and verify it has annotations and edited flag
1057        let drained = buf.drain_ready().await;
1058        assert_eq!(drained.len(), 1);
1059        let entry = &drained[0];
1060        assert!(entry.edited);
1061        assert_eq!(entry.annotations.len(), 1);
1062        assert_eq!(entry.annotations[0].annotation_type, AnnotationType::Edit);
1063        assert_eq!(entry.annotations[0].comment, "Fixed wording");
1064    }
1065
1066    #[tokio::test]
1067    async fn test_buffer_concurrent_push_drain() {
1068        use std::sync::Arc;
1069
1070        let buf = Arc::new(ResponseBuffer::new(Duration::ZERO));
1071        let mut handles = Vec::new();
1072
1073        // Spawn 10 pushers
1074        for i in 0..10 {
1075            let buf = buf.clone();
1076            handles.push(tokio::spawn(async move {
1077                buf.push(make_entry(
1078                    &format!("c{}", i),
1079                    "propose",
1080                    &format!("job-{}", i),
1081                    Duration::ZERO,
1082                ))
1083                .await;
1084            }));
1085        }
1086
1087        for h in handles {
1088            h.await.unwrap();
1089        }
1090
1091        // Drain all
1092        let drained = buf.drain_ready().await;
1093        assert_eq!(drained.len(), 10);
1094        assert!(buf.is_empty().await);
1095    }
1096
1097    // -------------------------------------------------------------------
1098    // Adaptive SLA tests
1099    // -------------------------------------------------------------------
1100
1101    #[test]
1102    fn test_compute_adaptive_hold_high_score() {
1103        let base = Duration::from_secs(10);
1104        // score = 0.8 → soft = 0.8/1.8 ≈ 0.444 → positive ≈ 0.722
1105        // multiplier = 1 + 0.278 * 3 = 1.833
1106        let hold = super::compute_adaptive_hold(base, Some(0.8), 3.0);
1107        let expected_secs = 18.33;
1108        assert!(
1109            (hold.as_secs_f64() - expected_secs).abs() < 0.5,
1110            "hold={:?}",
1111            hold
1112        );
1113    }
1114
1115    #[test]
1116    fn test_compute_adaptive_hold_low_score() {
1117        let base = Duration::from_secs(10);
1118        // score = -0.8 → soft = -0.8/1.8 ≈ -0.444 → positive ≈ 0.278
1119        // multiplier = 1 + 0.722 * 3 = 3.167
1120        let hold = super::compute_adaptive_hold(base, Some(-0.8), 3.0);
1121        let expected_secs = 31.67;
1122        assert!(
1123            (hold.as_secs_f64() - expected_secs).abs() < 0.5,
1124            "hold={:?}",
1125            hold
1126        );
1127    }
1128
1129    #[test]
1130    fn test_compute_adaptive_hold_no_score() {
1131        let base = Duration::from_secs(10);
1132        let hold = super::compute_adaptive_hold(base, None, 3.0);
1133        assert_eq!(hold, base);
1134    }
1135
1136    #[test]
1137    fn test_compute_adaptive_hold_perfect_score() {
1138        let base = Duration::from_secs(10);
1139        // score = 1.0 → soft = 0.5 → positive = 0.75
1140        // multiplier = 1 + 0.25 * 3 = 1.75
1141        let hold = super::compute_adaptive_hold(base, Some(1.0), 3.0);
1142        let expected_secs = 17.5;
1143        assert!(
1144            (hold.as_secs_f64() - expected_secs).abs() < 0.5,
1145            "hold={:?}",
1146            hold
1147        );
1148    }
1149
1150    #[test]
1151    fn test_compute_adaptive_hold_zero_score() {
1152        let base = Duration::from_secs(10);
1153        // score = 0.0 → soft = 0 → positive = 0.5
1154        // multiplier = 1 + 0.5 * 3 = 2.5
1155        let hold = super::compute_adaptive_hold(base, Some(0.0), 3.0);
1156        let expected_secs = 25.0;
1157        assert!(
1158            (hold.as_secs_f64() - expected_secs).abs() < 0.5,
1159            "hold={:?}",
1160            hold
1161        );
1162    }
1163
1164    #[test]
1165    fn test_set_hold_duration_atomic() {
1166        let buf = ResponseBuffer::new(Duration::from_secs(10));
1167        assert_eq!(buf.hold_duration(), Duration::from_secs(10));
1168        assert_eq!(buf.base_hold_duration(), Duration::from_secs(10));
1169
1170        buf.set_hold_duration(Duration::from_secs(25));
1171        assert_eq!(buf.hold_duration(), Duration::from_secs(25));
1172        // Base should remain unchanged
1173        assert_eq!(buf.base_hold_duration(), Duration::from_secs(10));
1174    }
1175
1176    // -------------------------------------------------------------------
1177    // SLA-based release tests
1178    // -------------------------------------------------------------------
1179
1180    #[test]
1181    fn test_response_sla_default_matches_hold_duration() {
1182        // SLA initializes to the hold_duration value — no floor applied.
1183        let buf = ResponseBuffer::new(Duration::from_secs(10));
1184        assert_eq!(buf.response_sla(), Some(Duration::from_secs(10)));
1185
1186        let buf_long = ResponseBuffer::new(Duration::from_secs(600));
1187        assert_eq!(buf_long.response_sla(), Some(Duration::from_secs(600)));
1188
1189        // Sub-second durations work too
1190        let buf_fast = ResponseBuffer::new(Duration::from_millis(500));
1191        assert_eq!(buf_fast.response_sla(), Some(Duration::from_millis(500)));
1192    }
1193
1194    #[test]
1195    fn test_response_sla_zero_hold_is_none() {
1196        let buf = ResponseBuffer::new(Duration::ZERO);
1197        assert!(buf.response_sla().is_none());
1198    }
1199
1200    #[test]
1201    fn test_set_response_sla() {
1202        let buf = ResponseBuffer::new(Duration::from_secs(10));
1203        buf.set_response_sla(Duration::from_secs(600));
1204        assert_eq!(buf.response_sla(), Some(Duration::from_secs(600)));
1205    }
1206
1207    #[test]
1208    fn test_set_response_sla_uses_exact_value() {
1209        let buf = ResponseBuffer::new(Duration::from_secs(10));
1210        // Any non-zero value is accepted as-is — no floor.
1211        buf.set_response_sla(Duration::from_secs(30));
1212        assert_eq!(buf.response_sla(), Some(Duration::from_secs(30)));
1213
1214        buf.set_response_sla(Duration::from_millis(1));
1215        assert_eq!(buf.response_sla(), Some(Duration::from_millis(1)));
1216
1217        // Setting to 0 means passthrough
1218        buf.set_response_sla(Duration::ZERO);
1219        assert_eq!(buf.response_sla(), None, "zero means passthrough");
1220    }
1221
1222    #[tokio::test]
1223    async fn test_push_with_deadline_sla() {
1224        // set_response_sla(60s) — used as-is, no floor.
1225        // release_at = task_received + 60s.
1226        let buf = ResponseBuffer::new(Duration::from_secs(10));
1227        buf.set_auto_approve(false);
1228        buf.set_response_sla(Duration::from_secs(60));
1229
1230        let task_received = Instant::now();
1231        let entry = make_entry("sla-1", "propose", "job-1", Duration::from_secs(10));
1232        buf.push_with_deadline(entry, task_received).await;
1233
1234        // Entry should not drain immediately (~60s hold)
1235        let drained = buf.drain_ready().await;
1236        assert!(
1237            drained.is_empty(),
1238            "should hold for full SLA (~60s), not drain immediately"
1239        );
1240        assert_eq!(buf.len().await, 1);
1241    }
1242
1243    #[tokio::test]
1244    async fn test_push_with_deadline_no_sla_fallback() {
1245        // SLA = 0 (pass-through mode) — should use caller's release_at
1246        let buf = ResponseBuffer::new(Duration::ZERO);
1247        // response_sla_ms is also 0 because hold_duration is ZERO
1248
1249        let task_received = Instant::now();
1250        let entry = make_entry("nosla-1", "propose", "job-1", Duration::ZERO);
1251        buf.push_with_deadline(entry, task_received).await;
1252
1253        // Should drain immediately since no SLA and caller set release_at = now + 0
1254        let drained = buf.drain_ready().await;
1255        assert_eq!(drained.len(), 1, "should drain immediately when no SLA set");
1256    }
1257
1258    #[tokio::test]
1259    async fn test_push_with_deadline_past_deadline_clamps() {
1260        // SLA = 600s (above floor), task received 620s ago — past deadline.
1261        // No reserve — deadline = received + 600s.
1262        // 620s ago means we're 20s past deadline → should drain immediately.
1263        let buf = ResponseBuffer::new(Duration::from_secs(60));
1264        buf.set_response_sla(Duration::from_secs(600));
1265
1266        let task_received = Instant::now() - Duration::from_secs(620);
1267        let entry = make_entry("late-1", "evaluate", "job-1", Duration::from_secs(60));
1268        buf.push_with_deadline(entry, task_received).await;
1269
1270        // Should drain immediately since we're past the SLA deadline
1271        let drained = buf.drain_ready().await;
1272        assert_eq!(
1273            drained.len(),
1274            1,
1275            "past-deadline entry should drain immediately"
1276        );
1277    }
1278
1279    /// Regression test: a buffer created with short hold_duration (e.g. 10s from
1280    /// agent config) must NOT auto-release entries before the operator has had
1281    /// time to review.  The minimum operator review window is 5 minutes (300s).
1282    ///
1283    /// Previously, `response_sla_ms` was initialized from `hold_duration_ms`,
1284    /// so a 10s hold → 10s SLA → release_at = task_received + 7s.  By the time
1285    /// the control plane called `set_response_sla(300s)` the first task was
1286    /// already in the buffer with a 7s deadline.
1287    #[tokio::test]
1288    async fn test_short_hold_duration_uses_exact_sla() {
1289        // Agent configured with 10s hold_duration — SLA is 10s, no floor.
1290        let buf = ResponseBuffer::new(Duration::from_secs(10));
1291        buf.set_auto_approve(false);
1292
1293        let task_received = Instant::now();
1294        let entry = make_entry("p1", "propose", "job-1", Duration::from_secs(10));
1295        buf.push_with_deadline(entry, task_received).await;
1296
1297        // Entry should be held for ~10s (the configured SLA).
1298        let list = buf.list().await;
1299        assert_eq!(list.len(), 1);
1300        assert!(
1301            list[0].release_in_ms > 0 && list[0].release_in_ms <= 10_000,
1302            "release_in_ms should be in (0, 10_000], got {}",
1303            list[0].release_in_ms
1304        );
1305    }
1306
1307    #[tokio::test]
1308    async fn test_response_sla_matches_hold_on_construction() {
1309        let buf = ResponseBuffer::new(Duration::from_secs(10));
1310        let sla = buf.response_sla();
1311        assert_eq!(sla, Some(Duration::from_secs(10)));
1312    }
1313
1314    /// Duration::ZERO is pass-through mode — no holding at all.
1315    /// The SLA floor should NOT apply in pass-through mode.
1316    #[tokio::test]
1317    async fn test_zero_hold_is_passthrough_no_sla_floor() {
1318        let buf = ResponseBuffer::new(Duration::ZERO);
1319        // Zero hold means "no buffering" — SLA should remain 0
1320        let sla = buf.response_sla();
1321        assert_eq!(sla, None, "pass-through mode should have no SLA");
1322    }
1323
1324    #[tokio::test]
1325    async fn test_drain_stale_removes_entries_from_other_jobs() {
1326        let buf = ResponseBuffer::new(Duration::from_secs(300));
1327        buf.push(make_entry(
1328            "a",
1329            "propose",
1330            "old-job",
1331            Duration::from_secs(300),
1332        ))
1333        .await;
1334        buf.push(make_entry(
1335            "b",
1336            "evaluate",
1337            "old-job",
1338            Duration::from_secs(300),
1339        ))
1340        .await;
1341        buf.push(make_entry(
1342            "c",
1343            "propose",
1344            "current-job",
1345            Duration::from_secs(300),
1346        ))
1347        .await;
1348        assert_eq!(buf.len().await, 3);
1349
1350        let stale = buf.drain_stale("current-job").await;
1351        assert_eq!(stale.len(), 2, "should drain 2 old-job entries");
1352        assert_eq!(buf.len().await, 1, "should keep 1 current-job entry");
1353
1354        // Verify the remaining entry is the current job
1355        let list = buf.list().await;
1356        assert_eq!(list[0].id, "c");
1357        assert_eq!(list[0].job_id, "current-job");
1358    }
1359
1360    #[tokio::test]
1361    async fn test_drain_stale_no_op_when_all_current() {
1362        let buf = ResponseBuffer::new(Duration::from_secs(300));
1363        buf.push(make_entry(
1364            "a",
1365            "propose",
1366            "job-1",
1367            Duration::from_secs(300),
1368        ))
1369        .await;
1370        buf.push(make_entry(
1371            "b",
1372            "evaluate",
1373            "job-1",
1374            Duration::from_secs(300),
1375        ))
1376        .await;
1377
1378        let stale = buf.drain_stale("job-1").await;
1379        assert!(stale.is_empty());
1380        assert_eq!(buf.len().await, 2);
1381    }
1382
1383    #[tokio::test]
1384    async fn test_drain_stale_empty_buffer() {
1385        let buf = ResponseBuffer::new(Duration::from_secs(300));
1386        let stale = buf.drain_stale("any-job").await;
1387        assert!(stale.is_empty());
1388    }
1389
1390    /// Verify PreAckedHandle is a no-op — ack always succeeds.
1391    ///
1392    /// This is the architectural invariant: when HITL buffers a response,
1393    /// the NATS message is acked IMMEDIATELY at push time to prevent
1394    /// JetStream redelivery.  The PreAckedHandle replaces NatsAckHandle
1395    /// so drain_buffer()'s ack() call is harmless.
1396    #[tokio::test]
1397    async fn test_pre_acked_handle_is_noop() {
1398        let handle = PreAckedHandle;
1399        assert!(
1400            handle.ack().await.is_ok(),
1401            "PreAckedHandle.ack() should always succeed"
1402        );
1403        // Can call multiple times safely (idempotent)
1404        assert!(handle.ack().await.is_ok());
1405    }
1406
1407    /// Verify that a buffer entry with PreAckedHandle drains and releases
1408    /// correctly — the full HITL flow.
1409    #[tokio::test]
1410    async fn test_buffer_entry_with_pre_acked_handle_drains_correctly() {
1411        let buf = ResponseBuffer::new(Duration::ZERO);
1412        let now = Instant::now();
1413        buf.push(BufferedResponse {
1414            id: "pre-acked-1".to_string(),
1415            action: "propose".to_string(),
1416            job_id: "job-1".to_string(),
1417            round: 1,
1418            reply_subject: "nsed.job-1.result.1.agent.propose".to_string(),
1419            payload: b"{\"content\":\"test\"}".to_vec(),
1420            created_at: now,
1421            release_at: now,                      // immediate drain for test
1422            ack_handle: Box::new(PreAckedHandle), // ← pre-acked
1423            msg_id: "msg-pre-acked-1".to_string(),
1424            annotations: Vec::new(),
1425            edited: false,
1426            stopped: false,
1427        })
1428        .await;
1429
1430        assert_eq!(buf.len().await, 1);
1431
1432        // Drain should succeed and entry should have PreAckedHandle
1433        let drained = buf.drain_ready().await;
1434        assert_eq!(drained.len(), 1);
1435        // Calling ack on the drained entry should be a no-op (not error)
1436        assert!(drained[0].ack_handle.ack().await.is_ok());
1437        assert!(buf.is_empty().await);
1438    }
1439
1440    // -----------------------------------------------------------------------
1441    // mark_for_release tests
1442    // -----------------------------------------------------------------------
1443
1444    #[tokio::test]
1445    async fn test_mark_for_release_sets_release_at_to_now() {
1446        // Buffer entry with a far-future release_at should NOT drain normally
1447        let buf = ResponseBuffer::new(Duration::from_secs(600));
1448        let now = Instant::now();
1449        let far_future = now + Duration::from_secs(3600);
1450        buf.push(BufferedResponse {
1451            id: "mark-1".to_string(),
1452            action: "propose".to_string(),
1453            job_id: "job-1".to_string(),
1454            round: 1,
1455            reply_subject: "nsed.job-1.result.1.agent.propose".to_string(),
1456            payload: b"{}".to_vec(),
1457            created_at: now,
1458            release_at: far_future,
1459            ack_handle: Box::new(NoopAckHandle),
1460            msg_id: "msg-mark-1".to_string(),
1461            annotations: Vec::new(),
1462            edited: false,
1463            stopped: false,
1464        })
1465        .await;
1466
1467        // Before mark: drain_ready should return nothing (entry is far future)
1468        assert!(buf.drain_ready().await.is_empty());
1469        assert_eq!(buf.len().await, 1);
1470
1471        // Mark for release
1472        let found = buf.mark_for_release("mark-1").await;
1473        assert!(found, "mark_for_release should find the entry");
1474
1475        // After mark: drain_ready should pick it up immediately
1476        let drained = buf.drain_ready().await;
1477        assert_eq!(drained.len(), 1);
1478        assert_eq!(drained[0].id, "mark-1");
1479        assert!(buf.is_empty().await);
1480    }
1481
1482    #[tokio::test]
1483    async fn test_mark_for_release_unknown_id_returns_false() {
1484        let buf = ResponseBuffer::new(Duration::from_secs(60));
1485        let found = buf.mark_for_release("nonexistent").await;
1486        assert!(
1487            !found,
1488            "mark_for_release should return false for unknown ID"
1489        );
1490    }
1491
1492    #[tokio::test]
1493    async fn test_mark_for_release_only_affects_target_entry() {
1494        let buf = ResponseBuffer::new(Duration::from_secs(600));
1495        let now = Instant::now();
1496        let far_future = now + Duration::from_secs(3600);
1497
1498        // Push two entries with far-future release times
1499        for i in 0..2 {
1500            buf.push(BufferedResponse {
1501                id: format!("entry-{}", i),
1502                action: "propose".to_string(),
1503                job_id: "job-1".to_string(),
1504                round: 1,
1505                reply_subject: format!("nsed.job-1.result.1.agent{}.propose", i),
1506                payload: b"{}".to_vec(),
1507                created_at: now,
1508                release_at: far_future,
1509                ack_handle: Box::new(NoopAckHandle),
1510                msg_id: format!("msg-{}", i),
1511                annotations: Vec::new(),
1512                edited: false,
1513                stopped: false,
1514            })
1515            .await;
1516        }
1517        assert_eq!(buf.len().await, 2);
1518
1519        // Mark only entry-0 for release
1520        buf.mark_for_release("entry-0").await;
1521
1522        // Only entry-0 should drain; entry-1 stays
1523        let drained = buf.drain_ready().await;
1524        assert_eq!(drained.len(), 1);
1525        assert_eq!(drained[0].id, "entry-0");
1526        assert_eq!(buf.len().await, 1); // entry-1 remains
1527    }
1528
1529    #[tokio::test]
1530    async fn test_mark_for_release_while_paused_still_marks() {
1531        let buf = ResponseBuffer::new(Duration::from_secs(600));
1532        let now = Instant::now();
1533        buf.push(BufferedResponse {
1534            id: "paused-mark-1".to_string(),
1535            action: "evaluate".to_string(),
1536            job_id: "job-2".to_string(),
1537            round: 1,
1538            reply_subject: "nsed.job-2.result.1.agent.evaluate".to_string(),
1539            payload: b"{}".to_vec(),
1540            created_at: now,
1541            release_at: now + Duration::from_secs(3600),
1542            ack_handle: Box::new(NoopAckHandle),
1543            msg_id: "msg-paused-1".to_string(),
1544            annotations: Vec::new(),
1545            edited: false,
1546            stopped: false,
1547        })
1548        .await;
1549
1550        buf.pause();
1551
1552        // Mark succeeds even when paused
1553        assert!(buf.mark_for_release("paused-mark-1").await);
1554
1555        // But drain_ready respects pause — returns nothing
1556        assert!(buf.drain_ready().await.is_empty());
1557        assert_eq!(buf.len().await, 1);
1558
1559        // Resume and drain
1560        buf.resume();
1561        let drained = buf.drain_ready().await;
1562        assert_eq!(drained.len(), 1);
1563        assert_eq!(drained[0].id, "paused-mark-1");
1564    }
1565
1566    #[tokio::test]
1567    async fn test_mark_for_release_preserves_stopped_flag() {
1568        let buf = ResponseBuffer::new(Duration::from_secs(600));
1569        buf.push(make_entry(
1570            "stopped-release",
1571            "propose",
1572            "job-1",
1573            Duration::from_secs(3600),
1574        ))
1575        .await;
1576
1577        // Stop the entry first
1578        assert!(buf.stop("stopped-release").await);
1579
1580        // Even with hold=0, stopped entries don't drain
1581        let drained = buf.drain_ready().await;
1582        assert!(drained.is_empty(), "stopped entry should not drain");
1583
1584        // mark_for_release sets release_at to now but preserves stopped
1585        assert!(buf.mark_for_release("stopped-release").await);
1586
1587        // Still stopped — must explicitly unstop before drain
1588        let drained = buf.drain_ready().await;
1589        assert!(
1590            drained.is_empty(),
1591            "stopped entry should not drain even after mark_for_release"
1592        );
1593
1594        // Unstop → now it should drain immediately
1595        assert!(buf.unstop("stopped-release").await);
1596        let drained = buf.drain_ready().await;
1597        assert_eq!(drained.len(), 1);
1598        assert_eq!(drained[0].id, "stopped-release");
1599    }
1600
1601    #[tokio::test]
1602    async fn test_force_release_atomically_unstops_and_releases() {
1603        let buf = ResponseBuffer::new(Duration::from_secs(600));
1604        buf.push(make_entry(
1605            "atomic-rel",
1606            "propose",
1607            "job-1",
1608            Duration::from_secs(3600),
1609        ))
1610        .await;
1611
1612        // Stop the entry
1613        assert!(buf.stop("atomic-rel").await);
1614        let drained = buf.drain_ready().await;
1615        assert!(drained.is_empty(), "stopped entry should not drain");
1616
1617        // force_release atomically clears stopped + sets release_at = now
1618        assert!(buf.force_release("atomic-rel").await);
1619
1620        // Should drain immediately in one step
1621        let drained = buf.drain_ready().await;
1622        assert_eq!(drained.len(), 1);
1623        assert_eq!(drained[0].id, "atomic-rel");
1624    }
1625
1626    #[tokio::test]
1627    async fn test_force_release_nonexistent_returns_false() {
1628        let buf = ResponseBuffer::new(Duration::from_secs(600));
1629        assert!(!buf.force_release("no-such-entry").await);
1630    }
1631
1632    // -----------------------------------------------------------------------
1633    // stop / unstop tests
1634    // -----------------------------------------------------------------------
1635
1636    #[tokio::test]
1637    async fn test_stopped_entry_not_drained() {
1638        let buf = ResponseBuffer::new(Duration::ZERO);
1639        // hold=0 → release_at == now (immediately ready)
1640        buf.push(make_entry("stop-1", "propose", "job-1", Duration::ZERO))
1641            .await;
1642
1643        // Stop the entry
1644        assert!(buf.stop("stop-1").await);
1645
1646        // Even though release_at has passed, stopped entries must not drain
1647        let drained = buf.drain_ready().await;
1648        assert!(drained.is_empty(), "stopped entry should not drain");
1649        assert_eq!(buf.len().await, 1, "entry should still be in buffer");
1650    }
1651
1652    #[tokio::test]
1653    async fn test_unstop_makes_entry_drainable() {
1654        let buf = ResponseBuffer::new(Duration::ZERO);
1655        buf.push(make_entry("unstop-1", "evaluate", "job-1", Duration::ZERO))
1656            .await;
1657
1658        // Stop then unstop
1659        assert!(buf.stop("unstop-1").await);
1660        assert!(buf.unstop("unstop-1").await);
1661
1662        // Entry should now drain normally
1663        let drained = buf.drain_ready().await;
1664        assert_eq!(drained.len(), 1);
1665        assert_eq!(drained[0].id, "unstop-1");
1666        assert!(buf.is_empty().await);
1667    }
1668
1669    #[tokio::test]
1670    async fn test_stop_unknown_id_returns_false() {
1671        let buf = ResponseBuffer::new(Duration::from_secs(60));
1672        assert!(!buf.stop("nonexistent").await);
1673    }
1674
1675    #[tokio::test]
1676    async fn test_unstop_unknown_id_returns_false() {
1677        let buf = ResponseBuffer::new(Duration::from_secs(60));
1678        assert!(!buf.unstop("nonexistent").await);
1679    }
1680
1681    #[tokio::test]
1682    async fn test_stop_only_affects_target_entry() {
1683        let buf = ResponseBuffer::new(Duration::ZERO);
1684        buf.push(make_entry("s-1", "propose", "job-1", Duration::ZERO))
1685            .await;
1686        buf.push(make_entry("s-2", "evaluate", "job-1", Duration::ZERO))
1687            .await;
1688
1689        // Stop only s-1
1690        assert!(buf.stop("s-1").await);
1691
1692        // Only s-2 should drain
1693        let drained = buf.drain_ready().await;
1694        assert_eq!(drained.len(), 1);
1695        assert_eq!(drained[0].id, "s-2");
1696        // s-1 still in buffer
1697        assert_eq!(buf.len().await, 1);
1698    }
1699
1700    #[tokio::test]
1701    async fn test_stopped_entry_visible_in_list() {
1702        let buf = ResponseBuffer::new(Duration::from_secs(60));
1703        buf.push(make_entry(
1704            "vis-1",
1705            "propose",
1706            "job-1",
1707            Duration::from_secs(60),
1708        ))
1709        .await;
1710
1711        buf.stop("vis-1").await;
1712
1713        let entries = buf.list().await;
1714        assert_eq!(entries.len(), 1);
1715        assert!(entries[0].stopped, "stopped flag should be true in list");
1716    }
1717
1718    #[tokio::test]
1719    async fn test_stopped_entry_visible_in_detail() {
1720        let buf = ResponseBuffer::new(Duration::from_secs(60));
1721        buf.push(make_entry(
1722            "vis-d-1",
1723            "propose",
1724            "job-1",
1725            Duration::from_secs(60),
1726        ))
1727        .await;
1728
1729        buf.stop("vis-d-1").await;
1730
1731        let detail = buf.get_detail("vis-d-1").await;
1732        assert!(detail.is_some());
1733        assert!(
1734            detail.unwrap().summary.stopped,
1735            "stopped flag should be true in detail"
1736        );
1737    }
1738
1739    #[tokio::test]
1740    async fn test_stop_while_paused_still_stops() {
1741        let buf = ResponseBuffer::new(Duration::ZERO);
1742        buf.push(make_entry("sp-1", "propose", "job-1", Duration::ZERO))
1743            .await;
1744
1745        buf.pause();
1746        assert!(buf.stop("sp-1").await);
1747        buf.resume();
1748
1749        // Resumed but stopped — should NOT drain
1750        let drained = buf.drain_ready().await;
1751        assert!(
1752            drained.is_empty(),
1753            "stopped entry should not drain even after resume"
1754        );
1755        assert_eq!(buf.len().await, 1);
1756
1757        // Unstop → should drain
1758        buf.unstop("sp-1").await;
1759        let drained = buf.drain_ready().await;
1760        assert_eq!(drained.len(), 1);
1761    }
1762
1763    // -------------------------------------------------------------------
1764    // Reply subject preservation tests
1765    // -------------------------------------------------------------------
1766
1767    #[tokio::test]
1768    async fn test_reply_subject_preserved_after_edit() {
1769        use crate::agents::{AnnotationType, OperatorAnnotation};
1770
1771        let buf = ResponseBuffer::new(Duration::ZERO);
1772        let entry = make_entry("rs-1", "propose", "job-A", Duration::ZERO);
1773        let original_subject = entry.reply_subject.clone();
1774        buf.push(entry).await;
1775
1776        // Edit the payload
1777        let new_payload = br#"{"content":"edited by operator"}"#.to_vec();
1778        let annotation = OperatorAnnotation {
1779            annotation_type: AnnotationType::Edit,
1780            comment: "Improved wording".into(),
1781            timestamp: "2026-01-01T00:00:00Z".into(),
1782            original_content_hash: None,
1783        };
1784        assert!(
1785            buf.update_payload_with_annotation("rs-1", new_payload.clone(), annotation)
1786                .await
1787        );
1788
1789        // Drain and verify reply_subject is unchanged
1790        let drained = buf.drain_ready().await;
1791        assert_eq!(drained.len(), 1);
1792        assert_eq!(
1793            drained[0].reply_subject, original_subject,
1794            "reply_subject must survive edits"
1795        );
1796        assert_eq!(drained[0].payload, new_payload, "payload should be updated");
1797        assert!(drained[0].edited, "edited flag should be set");
1798        assert_eq!(drained[0].annotations.len(), 1);
1799    }
1800
1801    #[tokio::test]
1802    async fn test_reply_subject_preserved_after_multiple_edits() {
1803        use crate::agents::{AnnotationType, OperatorAnnotation};
1804
1805        let buf = ResponseBuffer::new(Duration::ZERO);
1806        let entry = make_entry("rs-2", "evaluate", "job-B", Duration::ZERO);
1807        let original_subject = entry.reply_subject.clone();
1808        buf.push(entry).await;
1809
1810        // First edit
1811        buf.update_payload_with_annotation(
1812            "rs-2",
1813            b"v2".to_vec(),
1814            OperatorAnnotation {
1815                annotation_type: AnnotationType::Edit,
1816                comment: "First edit".into(),
1817                timestamp: "t1".into(),
1818                original_content_hash: None,
1819            },
1820        )
1821        .await;
1822
1823        // Second edit
1824        buf.update_payload_with_annotation(
1825            "rs-2",
1826            b"v3".to_vec(),
1827            OperatorAnnotation {
1828                annotation_type: AnnotationType::Edit,
1829                comment: "Second edit".into(),
1830                timestamp: "t2".into(),
1831                original_content_hash: None,
1832            },
1833        )
1834        .await;
1835
1836        // Add comment
1837        buf.add_comment(
1838            "rs-2",
1839            OperatorAnnotation {
1840                annotation_type: AnnotationType::Comment,
1841                comment: "LGTM".into(),
1842                timestamp: "t3".into(),
1843                original_content_hash: None,
1844            },
1845        )
1846        .await;
1847
1848        let drained = buf.drain_ready().await;
1849        assert_eq!(drained.len(), 1);
1850        assert_eq!(
1851            drained[0].reply_subject, original_subject,
1852            "reply_subject must survive multiple edits"
1853        );
1854        assert_eq!(
1855            drained[0].payload, b"v3",
1856            "payload should reflect last edit"
1857        );
1858        assert_eq!(drained[0].annotations.len(), 3, "all annotations preserved");
1859    }
1860
1861    #[tokio::test]
1862    async fn test_reply_subject_preserved_after_stop_edit_unstop() {
1863        use crate::agents::{AnnotationType, OperatorAnnotation};
1864
1865        let buf = ResponseBuffer::new(Duration::ZERO);
1866        let entry = make_entry("rs-3", "propose", "job-C", Duration::ZERO);
1867        let original_subject = entry.reply_subject.clone();
1868        buf.push(entry).await;
1869
1870        // Simulate regen flow: stop → edit → unstop → drain
1871        assert!(buf.stop("rs-3").await);
1872
1873        // Edit while stopped (regen replaces content)
1874        buf.update_payload_with_annotation(
1875            "rs-3",
1876            br#"{"content":"regenerated proposal"}"#.to_vec(),
1877            OperatorAnnotation {
1878                annotation_type: AnnotationType::Edit,
1879                comment: "Regenerated by operator".into(),
1880                timestamp: "t1".into(),
1881                original_content_hash: None,
1882            },
1883        )
1884        .await;
1885
1886        // Still stopped — should not drain
1887        let drained = buf.drain_ready().await;
1888        assert!(
1889            drained.is_empty(),
1890            "stopped entry should not drain even after edit"
1891        );
1892
1893        // Unstop
1894        assert!(buf.unstop("rs-3").await);
1895
1896        // Now should drain with original reply_subject
1897        let drained = buf.drain_ready().await;
1898        assert_eq!(drained.len(), 1);
1899        assert_eq!(
1900            drained[0].reply_subject, original_subject,
1901            "reply_subject must survive stop→edit→unstop cycle"
1902        );
1903        assert_eq!(
1904            std::str::from_utf8(&drained[0].payload).unwrap(),
1905            r#"{"content":"regenerated proposal"}"#
1906        );
1907    }
1908
1909    #[tokio::test]
1910    async fn test_double_stop_is_idempotent() {
1911        let buf = ResponseBuffer::new(Duration::ZERO);
1912        buf.push(make_entry("ds-1", "propose", "j", Duration::ZERO))
1913            .await;
1914
1915        assert!(buf.stop("ds-1").await);
1916        assert!(buf.stop("ds-1").await); // second stop is fine
1917        assert!(buf.drain_ready().await.is_empty());
1918
1919        assert!(buf.unstop("ds-1").await);
1920        assert_eq!(buf.drain_ready().await.len(), 1);
1921    }
1922
1923    #[tokio::test]
1924    async fn test_double_unstop_is_idempotent() {
1925        let buf = ResponseBuffer::new(Duration::ZERO);
1926        buf.push(make_entry("du-1", "propose", "j", Duration::ZERO))
1927            .await;
1928        buf.stop("du-1").await;
1929
1930        assert!(buf.unstop("du-1").await);
1931        assert!(buf.unstop("du-1").await); // second unstop is fine
1932        assert_eq!(buf.drain_ready().await.len(), 1);
1933    }
1934
1935    // -------------------------------------------------------------------
1936    // Edit on non-existent/already-drained entries
1937    // -------------------------------------------------------------------
1938
1939    #[tokio::test]
1940    async fn test_edit_nonexistent_returns_false() {
1941        use crate::agents::{AnnotationType, OperatorAnnotation};
1942
1943        let buf = ResponseBuffer::new(Duration::ZERO);
1944        let result = buf
1945            .update_payload_with_annotation(
1946                "ghost",
1947                b"new".to_vec(),
1948                OperatorAnnotation {
1949                    annotation_type: AnnotationType::Edit,
1950                    comment: "".into(),
1951                    timestamp: "t".into(),
1952                    original_content_hash: None,
1953                },
1954            )
1955            .await;
1956        assert!(!result);
1957    }
1958
1959    #[tokio::test]
1960    async fn test_edit_after_drain_returns_false() {
1961        use crate::agents::{AnnotationType, OperatorAnnotation};
1962
1963        let buf = ResponseBuffer::new(Duration::ZERO);
1964        buf.push(make_entry("ed-1", "propose", "j", Duration::ZERO))
1965            .await;
1966        buf.drain_ready().await; // drains it
1967
1968        let result = buf
1969            .update_payload_with_annotation(
1970                "ed-1",
1971                b"too late".to_vec(),
1972                OperatorAnnotation {
1973                    annotation_type: AnnotationType::Edit,
1974                    comment: "".into(),
1975                    timestamp: "t".into(),
1976                    original_content_hash: None,
1977                },
1978            )
1979            .await;
1980        assert!(!result, "cannot edit an already-drained entry");
1981    }
1982
1983    // -------------------------------------------------------------------
1984    // Mixed: multiple entries, selective stop/edit/drain
1985    // -------------------------------------------------------------------
1986
1987    #[tokio::test]
1988    async fn test_selective_stop_only_blocks_target() {
1989        let buf = ResponseBuffer::new(Duration::ZERO);
1990        buf.push(make_entry("m-1", "propose", "j", Duration::ZERO))
1991            .await;
1992        buf.push(make_entry("m-2", "evaluate", "j", Duration::ZERO))
1993            .await;
1994        buf.push(make_entry("m-3", "propose", "j", Duration::ZERO))
1995            .await;
1996
1997        buf.stop("m-2").await;
1998
1999        let drained = buf.drain_ready().await;
2000        assert_eq!(drained.len(), 2, "only non-stopped entries should drain");
2001        let ids: Vec<&str> = drained.iter().map(|e| e.id.as_str()).collect();
2002        assert!(ids.contains(&"m-1"));
2003        assert!(ids.contains(&"m-3"));
2004        assert!(!ids.contains(&"m-2"));
2005
2006        // m-2 still in buffer
2007        assert_eq!(buf.len().await, 1);
2008        assert!(buf.get_detail("m-2").await.is_some());
2009    }
2010
2011    #[tokio::test]
2012    async fn test_job_id_and_action_preserved_through_full_lifecycle() {
2013        use crate::agents::{AnnotationType, OperatorAnnotation};
2014
2015        let buf = ResponseBuffer::new(Duration::ZERO);
2016        let mut entry = make_entry("lc-1", "evaluate", "job-XYZ", Duration::ZERO);
2017        entry.round = 3;
2018        entry.reply_subject = "nsed.job-XYZ.result.3.agent.evaluate".into();
2019        buf.push(entry).await;
2020
2021        // Stop
2022        buf.stop("lc-1").await;
2023
2024        // Edit
2025        buf.update_payload_with_annotation(
2026            "lc-1",
2027            b"edited".to_vec(),
2028            OperatorAnnotation {
2029                annotation_type: AnnotationType::Edit,
2030                comment: "regen".into(),
2031                timestamp: "t".into(),
2032                original_content_hash: None,
2033            },
2034        )
2035        .await;
2036
2037        // Unstop
2038        buf.unstop("lc-1").await;
2039
2040        // Drain
2041        let drained = buf.drain_ready().await;
2042        assert_eq!(drained.len(), 1);
2043        let e = &drained[0];
2044        assert_eq!(e.job_id, "job-XYZ");
2045        assert_eq!(e.action, "evaluate");
2046        assert_eq!(e.round, 3);
2047        assert_eq!(e.reply_subject, "nsed.job-XYZ.result.3.agent.evaluate");
2048        assert!(e.edited);
2049    }
2050
2051    // -------------------------------------------------------------------
2052    // compute_divergence tests
2053    // -------------------------------------------------------------------
2054
2055    #[test]
2056    fn test_compute_divergence_both_signals() {
2057        // score = 0.6 → soft = 0.6/1.6 = 0.375 → (1-0.375)/2 = 0.3125
2058        // std_div = 0.25 / 1.0 = 0.25
2059        // effective = max(0.3125, 0.25) = 0.3125
2060        let div = super::compute_divergence(Some(0.6), Some(0.25));
2061        assert!((div.unwrap() - 0.3125).abs() < 0.01);
2062    }
2063
2064    #[test]
2065    fn test_compute_divergence_score_only() {
2066        // score = 1.0 → soft = 0.5 → (1-0.5)/2 = 0.25
2067        let div = super::compute_divergence(Some(1.0), None);
2068        assert!((div.unwrap() - 0.25).abs() < 0.01);
2069    }
2070
2071    #[test]
2072    fn test_compute_divergence_score_low() {
2073        // score = -0.8 → soft = -0.8/1.8 = -0.444 → (1+0.444)/2 = 0.722
2074        let div = super::compute_divergence(Some(-0.8), None);
2075        assert!((div.unwrap() - 0.722).abs() < 0.02);
2076    }
2077
2078    #[test]
2079    fn test_compute_divergence_std_dev_only() {
2080        // std_div = 0.25 / 1.0 = 0.25
2081        let div = super::compute_divergence(None, Some(0.25));
2082        assert!((div.unwrap() - 0.25).abs() < 0.01);
2083    }
2084
2085    #[test]
2086    fn test_compute_divergence_none() {
2087        let div = super::compute_divergence(None, None);
2088        assert!(div.is_none());
2089    }
2090
2091    #[test]
2092    fn test_compute_divergence_perfect_score() {
2093        // score = 3.0 (strong endorsement) → soft = 0.75 → (1-0.75)/2 = 0.125
2094        // std_dev = 0 → std_div = 0
2095        // effective = max(0.125, 0) = 0.125
2096        let div = super::compute_divergence(Some(3.0), Some(0.0));
2097        assert!((div.unwrap() - 0.125).abs() < 0.01);
2098    }
2099
2100    #[test]
2101    fn test_compute_divergence_worst_score() {
2102        // score = -3.0 → soft = -0.75 → (1+0.75)/2 = 0.875
2103        // std_dev = 1.2 → std_div = 1.2/1.0 → clamped 1.0
2104        // effective = max(0.875, 1.0) = 1.0
2105        let div = super::compute_divergence(Some(-3.0), Some(1.2));
2106        assert!((div.unwrap() - 1.0).abs() < 0.01);
2107    }
2108
2109    #[test]
2110    fn test_compute_divergence_large_positive_score() {
2111        // Large positive → very low divergence (asymptotically → 0)
2112        let div = super::compute_divergence(Some(10.0), None);
2113        // soft = 10/11 ≈ 0.909 → (1-0.909)/2 ≈ 0.045
2114        assert!(
2115            div.unwrap() < 0.1,
2116            "large positive should give low divergence"
2117        );
2118    }
2119
2120    #[test]
2121    fn test_compute_divergence_large_negative_score() {
2122        // Large negative → very high divergence (asymptotically → 1)
2123        let div = super::compute_divergence(Some(-10.0), None);
2124        // soft = -10/11 ≈ -0.909 → (1+0.909)/2 ≈ 0.955
2125        assert!(
2126            div.unwrap() > 0.9,
2127            "large negative should give high divergence"
2128        );
2129    }
2130
2131    // -------------------------------------------------------------------
2132    // Auto-approve tests
2133    // -------------------------------------------------------------------
2134
2135    #[test]
2136    fn test_auto_approve_default_on() {
2137        let buf = ResponseBuffer::new(Duration::from_secs(10));
2138        assert!(
2139            buf.is_auto_approve(),
2140            "auto-approve should be ON by default"
2141        );
2142    }
2143
2144    #[test]
2145    fn test_auto_approve_toggle() {
2146        let buf = ResponseBuffer::new(Duration::from_secs(10));
2147        assert!(buf.is_auto_approve());
2148        buf.set_auto_approve(false);
2149        assert!(!buf.is_auto_approve());
2150        buf.set_auto_approve(true);
2151        assert!(buf.is_auto_approve());
2152    }
2153
2154    #[test]
2155    fn test_auto_approve_threshold_default() {
2156        // Default is 1.0 (100%) — combined with auto_approve=true, this
2157        // makes the buffer a true pass-through that releases every entry
2158        // regardless of divergence. Operators who want the old 50% gate
2159        // must set it explicitly.
2160        let buf = ResponseBuffer::new(Duration::from_secs(10));
2161        assert!(
2162            (buf.auto_approve_threshold() - 1.0).abs() < 0.01,
2163            "auto_approve_threshold default should be 1.0 (release everything)"
2164        );
2165    }
2166
2167    #[tokio::test]
2168    async fn test_default_config_releases_every_entry_regardless_of_divergence() {
2169        // End-to-end check of the new pass-through default:
2170        // `auto_approve = true` + `threshold = 1.0` should release
2171        // every pending entry on the next `auto_release_if_eligible`
2172        // call, no matter what divergence value is reported.
2173        let buf = ResponseBuffer::new(Duration::from_secs(60));
2174        // Do NOT call set_auto_approve / set_auto_approve_threshold —
2175        // we explicitly want to exercise the fresh defaults.
2176        buf.push(make_entry("a", "propose", "job-1", Duration::from_secs(60)))
2177            .await;
2178        buf.push(make_entry("b", "propose", "job-2", Duration::from_secs(60)))
2179            .await;
2180        buf.push(make_entry("c", "propose", "job-3", Duration::from_secs(60)))
2181            .await;
2182
2183        // Maximum possible divergence (the compute_divergence formula
2184        // clamps to [0, 1], so 1.0 is the worst case). The default
2185        // threshold of 1.0 still permits release because the gate is
2186        // `div > threshold`, not `div >= threshold`.
2187        let count = buf.auto_release_if_eligible(Some(1.0)).await;
2188        assert_eq!(
2189            count, 3,
2190            "all three entries should auto-release under the default 100% threshold"
2191        );
2192    }
2193
2194    #[test]
2195    fn test_auto_approve_threshold_set_and_get() {
2196        let buf = ResponseBuffer::new(Duration::from_secs(10));
2197        buf.set_auto_approve_threshold(0.75);
2198        assert!((buf.auto_approve_threshold() - 0.75).abs() < 0.01);
2199        buf.set_auto_approve_threshold(0.1);
2200        assert!((buf.auto_approve_threshold() - 0.1).abs() < 0.01);
2201    }
2202
2203    #[test]
2204    fn test_auto_approve_threshold_clamped() {
2205        let buf = ResponseBuffer::new(Duration::from_secs(10));
2206        buf.set_auto_approve_threshold(-0.5);
2207        assert!((buf.auto_approve_threshold() - 0.0).abs() < 0.01);
2208        buf.set_auto_approve_threshold(2.0);
2209        assert!((buf.auto_approve_threshold() - 1.0).abs() < 0.01);
2210    }
2211
2212    #[tokio::test]
2213    async fn test_auto_release_when_eligible() {
2214        let buf = ResponseBuffer::new(Duration::from_secs(60));
2215        buf.set_auto_approve(true);
2216        buf.set_auto_approve_threshold(0.5);
2217        buf.push(make_entry("a", "propose", "job-1", Duration::from_secs(60)))
2218            .await;
2219
2220        // Divergence 0.2 < threshold 0.5 → should auto-release
2221        let count = buf.auto_release_if_eligible(Some(0.2)).await;
2222        assert_eq!(count, 1);
2223        // Now drain_ready() should pick it up
2224        let drained = buf.drain_ready().await;
2225        assert_eq!(drained.len(), 1);
2226    }
2227
2228    #[tokio::test]
2229    async fn test_auto_release_skipped_when_disabled() {
2230        let buf = ResponseBuffer::new(Duration::from_secs(60));
2231        buf.set_auto_approve(false);
2232        buf.set_auto_approve_threshold(0.5);
2233        buf.push(make_entry("a", "propose", "job-1", Duration::from_secs(60)))
2234            .await;
2235
2236        let count = buf.auto_release_if_eligible(Some(0.2)).await;
2237        assert_eq!(count, 0);
2238        let drained = buf.drain_ready().await;
2239        assert!(drained.is_empty());
2240    }
2241
2242    #[tokio::test]
2243    async fn test_auto_release_skipped_when_divergence_above_threshold() {
2244        let buf = ResponseBuffer::new(Duration::from_secs(60));
2245        buf.set_auto_approve(true);
2246        buf.set_auto_approve_threshold(0.3);
2247        buf.push(make_entry("a", "propose", "job-1", Duration::from_secs(60)))
2248            .await;
2249
2250        // Divergence 0.5 >= threshold 0.3 → no auto-release
2251        let count = buf.auto_release_if_eligible(Some(0.5)).await;
2252        assert_eq!(count, 0);
2253        let drained = buf.drain_ready().await;
2254        assert!(drained.is_empty());
2255    }
2256
2257    #[tokio::test]
2258    async fn test_auto_release_with_no_divergence_data_trusts_operator() {
2259        let buf = ResponseBuffer::new(Duration::from_secs(60));
2260        buf.set_auto_approve(true);
2261        buf.set_auto_approve_threshold(0.5);
2262        buf.push(make_entry("a", "propose", "job-1", Duration::from_secs(60)))
2263            .await;
2264
2265        // None divergence → operator explicitly enabled auto, trust the click
2266        let count = buf.auto_release_if_eligible(None).await;
2267        assert_eq!(count, 1);
2268        let drained = buf.drain_ready().await;
2269        assert_eq!(drained.len(), 1);
2270    }
2271
2272    #[tokio::test]
2273    async fn test_auto_release_respects_stopped_flag() {
2274        let buf = ResponseBuffer::new(Duration::from_secs(60));
2275        buf.set_auto_approve(true);
2276        buf.set_auto_approve_threshold(0.5);
2277        buf.push(make_entry("a", "propose", "job-1", Duration::from_secs(60)))
2278            .await;
2279        // Stop the entry
2280        buf.stop("a").await;
2281
2282        // Low divergence but entry is stopped → should NOT auto-release
2283        let count = buf.auto_release_if_eligible(Some(0.1)).await;
2284        assert_eq!(count, 0);
2285        let drained = buf.drain_ready().await;
2286        assert!(drained.is_empty());
2287    }
2288
2289    #[tokio::test]
2290    async fn test_auto_release_at_exact_threshold_releases() {
2291        let buf = ResponseBuffer::new(Duration::from_secs(60));
2292        buf.set_auto_approve(true);
2293        buf.set_auto_approve_threshold(0.5);
2294        buf.push(make_entry("a", "propose", "job-1", Duration::from_secs(60)))
2295            .await;
2296
2297        // Divergence exactly at threshold → eligible (inclusive boundary).
2298        // Operator sets threshold to X% meaning "approve up to X%".
2299        let count = buf.auto_release_if_eligible(Some(0.5)).await;
2300        assert_eq!(count, 1);
2301    }
2302
2303    #[tokio::test]
2304    async fn test_auto_release_above_threshold_does_not_release() {
2305        let buf = ResponseBuffer::new(Duration::from_secs(60));
2306        buf.set_auto_approve(true);
2307        buf.set_auto_approve_threshold(0.5);
2308        buf.push(make_entry("a", "propose", "job-1", Duration::from_secs(60)))
2309            .await;
2310
2311        // Divergence strictly above threshold → not eligible
2312        let count = buf.auto_release_if_eligible(Some(0.51)).await;
2313        assert_eq!(count, 0);
2314    }
2315
2316    #[tokio::test]
2317    async fn test_auto_release_multiple_entries() {
2318        let buf = ResponseBuffer::new(Duration::from_secs(60));
2319        buf.set_auto_approve(true);
2320        buf.set_auto_approve_threshold(0.5);
2321        buf.push(make_entry("a", "propose", "job-1", Duration::from_secs(60)))
2322            .await;
2323        buf.push(make_entry(
2324            "b",
2325            "evaluate",
2326            "job-1",
2327            Duration::from_secs(60),
2328        ))
2329        .await;
2330        buf.push(make_entry("c", "propose", "job-2", Duration::from_secs(60)))
2331            .await;
2332
2333        let count = buf.auto_release_if_eligible(Some(0.2)).await;
2334        assert_eq!(count, 3);
2335        let drained = buf.drain_ready().await;
2336        assert_eq!(drained.len(), 3);
2337    }
2338
2339    // -------------------------------------------------------------------
2340    // OVERDUE invariant: buffered items always have release_in_ms >= 0
2341    // -------------------------------------------------------------------
2342    //
2343    // User-visible invariant: "if a card is in the rainfall, it should
2344    // NOT be possible to be overdue, because the SLA math is pre-
2345    // calculated."  The backend guarantees this in two ways:
2346    //
2347    //   1. push_with_deadline() clamps release_at to max(deadline, now),
2348    //      so release_in_ms >= 0 immediately after push.
2349    //
2350    //   2. drain_ready() removes entries once now >= release_at, so any
2351    //      entry still in the buffer has either:
2352    //        a) release_at in the future (release_in_ms > 0), or
2353    //        b) release_at just passed but drain hasn't fired yet
2354    //           (acceptable race; < 500ms drain cycle).
2355    //
2356    // The tests below verify both properties.
2357
2358    /// After push_with_deadline (SLA configured), release_in_ms must be
2359    /// non-negative — the entry is guaranteed to stay in the buffer for
2360    /// at least the SLA duration minus reserve.
2361    #[tokio::test]
2362    async fn test_invariant_release_in_ms_non_negative_after_push() {
2363        let buf = ResponseBuffer::new(Duration::from_secs(60));
2364        buf.set_response_sla(Duration::from_secs(600));
2365
2366        let task_received = Instant::now();
2367        let entry = make_entry("inv-1", "propose", "job-1", Duration::from_secs(60));
2368        buf.push_with_deadline(entry, task_received).await;
2369
2370        let list = buf.list().await;
2371        assert_eq!(list.len(), 1);
2372        assert!(
2373            list[0].release_in_ms >= 0,
2374            "invariant: buffered item must have release_in_ms >= 0, got {}",
2375            list[0].release_in_ms,
2376        );
2377    }
2378
2379    /// When the agent takes a long time generating (tool calls etc.),
2380    /// task_received is well in the past. push_with_deadline clamps
2381    /// release_at to max(deadline, now), so release_in_ms >= 0 even
2382    /// though the SLA has technically expired relative to task_received.
2383    #[tokio::test]
2384    async fn test_invariant_slow_agent_still_non_negative() {
2385        let buf = ResponseBuffer::new(Duration::from_secs(60));
2386        buf.set_response_sla(Duration::from_secs(600));
2387
2388        // Agent took 700s to generate — 100s past SLA.
2389        let task_received = Instant::now() - Duration::from_secs(700);
2390        let entry = make_entry("inv-2", "propose", "job-1", Duration::from_secs(60));
2391        buf.push_with_deadline(entry, task_received).await;
2392
2393        let list = buf.list().await;
2394        assert_eq!(list.len(), 1);
2395        assert!(
2396            list[0].release_in_ms >= 0,
2397            "invariant: even for slow agents, release_in_ms must be >= 0, got {}",
2398            list[0].release_in_ms,
2399        );
2400
2401        // The entry should be immediately drainable since release_at was
2402        // clamped to `now` (past deadline).
2403        let drained = buf.drain_ready().await;
2404        assert_eq!(
2405            drained.len(),
2406            1,
2407            "past-deadline entry should drain immediately"
2408        );
2409    }
2410
2411    /// Paused + past-deadline: release_in_ms can go negative (held by
2412    /// operator pause), but this is fine because the dashboard shows
2413    /// "Releasing…" instead of "OVERDUE". The key check: drain_ready
2414    /// returns nothing while paused, keeping the entry in the buffer.
2415    #[tokio::test]
2416    async fn test_invariant_paused_entry_stays_in_buffer() {
2417        let buf = ResponseBuffer::new(Duration::ZERO);
2418        buf.push(make_entry("inv-3", "propose", "job-1", Duration::ZERO))
2419            .await;
2420
2421        // Pause BEFORE drain → entry should stay in buffer
2422        buf.pause();
2423
2424        let drained = buf.drain_ready().await;
2425        assert!(
2426            drained.is_empty(),
2427            "paused buffer must not drain — entry stays visible in rainfall"
2428        );
2429        assert_eq!(buf.len().await, 1, "entry must remain in buffer");
2430
2431        // Even with release_at passed, entry is still listed
2432        let list = buf.list().await;
2433        assert_eq!(list.len(), 1);
2434        // release_in_ms may be 0 or slightly negative here — that's OK,
2435        // the frontend shows "Releasing…" not "OVERDUE"
2436    }
2437
2438    /// Stopped entry: release_at can pass but the entry stays in the
2439    /// buffer (operator can un-stop and release). Verify it doesn't drain.
2440    #[tokio::test]
2441    async fn test_invariant_stopped_entry_stays_in_buffer() {
2442        let buf = ResponseBuffer::new(Duration::ZERO);
2443        buf.push(make_entry("inv-4", "propose", "job-1", Duration::ZERO))
2444            .await;
2445
2446        buf.stop("inv-4").await;
2447
2448        // release_at == now (hold=0), but stopped → should NOT drain
2449        let drained = buf.drain_ready().await;
2450        assert!(
2451            drained.is_empty(),
2452            "stopped entry must not drain even though release_at passed"
2453        );
2454        assert_eq!(buf.len().await, 1);
2455    }
2456
2457    /// The full invariant cycle: push → list (positive) → wait → drain
2458    /// → verify the entry never existed in a "buffered + overdue" state
2459    /// without the operator's knowledge.
2460    #[tokio::test]
2461    async fn test_invariant_full_lifecycle_no_surprise_overdue() {
2462        let buf = ResponseBuffer::new(Duration::from_secs(60));
2463        buf.set_auto_approve(false);
2464        buf.set_response_sla(Duration::from_secs(600));
2465
2466        let task_received = Instant::now();
2467        let entry = make_entry("inv-5", "propose", "job-1", Duration::from_secs(60));
2468        buf.push_with_deadline(entry, task_received).await;
2469
2470        // Immediately after push: release_in_ms > 0
2471        let snap1 = buf.list().await;
2472        assert!(snap1[0].release_in_ms > 0, "snap1: should be positive");
2473
2474        // Simulate time passing (we can't fast-forward Instant, but we
2475        // can verify get_detail gives the same guarantee)
2476        let detail = buf.get_detail("inv-5").await.unwrap();
2477        assert!(
2478            detail.summary.release_in_ms > 0,
2479            "get_detail: should be positive immediately after push"
2480        );
2481
2482        // Entry has not been drained — still in buffer
2483        assert_eq!(buf.len().await, 1, "entry still buffered");
2484    }
2485
2486    // -------------------------------------------------------------------
2487    // Additional coverage: stop/unstop, mark_for_release, divergence,
2488    // auto_release_if_eligible
2489    // -------------------------------------------------------------------
2490
2491    /// Test that stop("job_1") prevents entries from appearing in
2492    /// drain_ready(), and unstop("job_1") makes them drainable again.
2493    #[tokio::test]
2494    async fn test_buffer_stop_and_unstop() {
2495        let buf = ResponseBuffer::new(Duration::ZERO);
2496        // Push two entries for different jobs; hold=0 means immediately ready
2497        buf.push(make_entry("e1", "propose", "job_1", Duration::ZERO))
2498            .await;
2499        buf.push(make_entry("e2", "evaluate", "job_2", Duration::ZERO))
2500            .await;
2501
2502        // Stop e1 — it should no longer appear in drain_ready
2503        assert!(buf.stop("e1").await);
2504
2505        let drained = buf.drain_ready().await;
2506        assert_eq!(drained.len(), 1, "only e2 should drain");
2507        assert_eq!(drained[0].id, "e2");
2508        assert_eq!(buf.len().await, 1, "e1 should still be in buffer");
2509
2510        // Unstop e1 — it should now be drainable
2511        assert!(buf.unstop("e1").await);
2512        let drained = buf.drain_ready().await;
2513        assert_eq!(drained.len(), 1, "e1 should drain after unstop");
2514        assert_eq!(drained[0].id, "e1");
2515        assert!(buf.is_empty().await);
2516    }
2517
2518    /// Test that mark_for_release("id") sets the entry's release_at to
2519    /// now, making it immediately drainable even with a long hold.
2520    #[tokio::test]
2521    async fn test_buffer_mark_for_release() {
2522        let buf = ResponseBuffer::new(Duration::from_secs(600));
2523        buf.push(make_entry(
2524            "mr-1",
2525            "propose",
2526            "job-1",
2527            Duration::from_secs(600),
2528        ))
2529        .await;
2530        buf.push(make_entry(
2531            "mr-2",
2532            "evaluate",
2533            "job-1",
2534            Duration::from_secs(600),
2535        ))
2536        .await;
2537
2538        // Neither should drain yet (600s hold)
2539        assert!(buf.drain_ready().await.is_empty());
2540
2541        // Mark only mr-1 for release
2542        assert!(buf.mark_for_release("mr-1").await);
2543
2544        // mr-1 should drain immediately; mr-2 stays
2545        let drained = buf.drain_ready().await;
2546        assert_eq!(drained.len(), 1);
2547        assert_eq!(drained[0].id, "mr-1");
2548        assert_eq!(buf.len().await, 1, "mr-2 should still be held");
2549
2550        // Unknown ID returns false
2551        assert!(!buf.mark_for_release("nonexistent").await);
2552    }
2553
2554    /// Test compute_divergence with specific scenarios:
2555    /// - Strong endorsement (low divergence)
2556    /// - Rejected with high stddev (saturates at 1.0)
2557    /// - Empty recent scores (should return None)
2558    #[test]
2559    fn test_buffer_compute_divergence() {
2560        // High positive score with zero stddev → low divergence
2561        // score = 3.0 → soft = 0.75 → (1-0.75)/2 = 0.125
2562        let div = super::compute_divergence(Some(3.0), Some(0.0));
2563        assert!(
2564            div.unwrap() < 0.2,
2565            "strong endorsement should have low divergence, got {}",
2566            div.unwrap()
2567        );
2568
2569        // Negative score + high stddev → divergence saturates at 1.0
2570        // score = -2.0 → soft = -0.667 → (1+0.667)/2 = 0.833
2571        // std_dev = 1.5 → std_div = 1.5 → clamped 1.0
2572        // effective = max(0.833, 1.0) = 1.0
2573        let div_high = super::compute_divergence(Some(-2.0), Some(1.5));
2574        assert!(
2575            (div_high.unwrap() - 1.0).abs() < 0.01,
2576            "rejected + high stddev should saturate divergence, got {}",
2577            div_high.unwrap()
2578        );
2579
2580        // No scores at all → None (divergence unknown)
2581        let div_empty = super::compute_divergence(None, None);
2582        assert!(
2583            div_empty.is_none(),
2584            "empty recent scores should return None"
2585        );
2586    }
2587
2588    /// Test that entries with divergence below the auto-approve threshold
2589    /// are auto-released, while high-divergence entries are held.
2590    #[tokio::test]
2591    async fn test_buffer_auto_release_if_eligible() {
2592        let buf = ResponseBuffer::new(Duration::from_secs(600));
2593        buf.set_auto_approve(true);
2594        buf.set_auto_approve_threshold(0.4);
2595
2596        // Push three entries with long hold
2597        buf.push(make_entry(
2598            "ar-1",
2599            "propose",
2600            "job-1",
2601            Duration::from_secs(600),
2602        ))
2603        .await;
2604        buf.push(make_entry(
2605            "ar-2",
2606            "evaluate",
2607            "job-1",
2608            Duration::from_secs(600),
2609        ))
2610        .await;
2611        // Stop ar-2 to test that stopped entries are excluded
2612        buf.stop("ar-2").await;
2613        buf.push(make_entry(
2614            "ar-3",
2615            "propose",
2616            "job-2",
2617            Duration::from_secs(600),
2618        ))
2619        .await;
2620
2621        // Low divergence (0.1 < threshold 0.4) → non-stopped entries should
2622        // be marked for immediate release
2623        let count = buf.auto_release_if_eligible(Some(0.1)).await;
2624        assert_eq!(count, 2, "only non-stopped entries should be auto-released");
2625
2626        // Drain: ar-1 and ar-3 should drain; ar-2 stays (stopped)
2627        let drained = buf.drain_ready().await;
2628        assert_eq!(drained.len(), 2);
2629        let ids: Vec<&str> = drained.iter().map(|e| e.id.as_str()).collect();
2630        assert!(ids.contains(&"ar-1"));
2631        assert!(ids.contains(&"ar-3"));
2632        assert!(!ids.contains(&"ar-2"));
2633        assert_eq!(buf.len().await, 1, "ar-2 should remain (stopped)");
2634
2635        // Now test high divergence: push a new entry and try with div above threshold
2636        buf.push(make_entry(
2637            "ar-4",
2638            "propose",
2639            "job-3",
2640            Duration::from_secs(600),
2641        ))
2642        .await;
2643        let count_high = buf.auto_release_if_eligible(Some(0.6)).await;
2644        assert_eq!(
2645            count_high, 0,
2646            "high divergence should not auto-release any entries"
2647        );
2648        assert!(
2649            buf.drain_ready().await.is_empty(),
2650            "no entries should drain when divergence is above threshold"
2651        );
2652    }
2653}