Skip to main content

agent_engine/engine/
reactor.rs

1//! Central event-drain and wake-action policy — mode-agnostic engine layer.
2//!
3//! `drain_event_queue` pops every pending event from the queue in priority
4//! order, formats it, and classifies it as Steered / Buffered / Injected.
5//! `wake_action` decides what the caller should do next.
6//!
7//! `event_payload_from_drained` builds the canonical wire-format payload
8//! (shared by RPC and server) from a `DrainedEvent`.
9
10use tokio::sync::mpsc::UnboundedSender;
11
12use crate::events::{format_event_for_agent, EventQueue};
13use crate::SharedMessage;
14
15// Re-export the shared wire payload so callers can use `engine::reactor::EventPayload`.
16pub use agent_core::core::rpc_protocol::EventPayload;
17
18/// Default cap: how many consecutive auto-triggered model turns can fire before
19/// the engine parks and waits for real user input.
20///
21/// Configurable via `events.auto_turn_cap` (see `EventsConfig::auto_turn_cap`).
22/// A cap of `0` means **unlimited** — the engine never parks.
23pub const AUTO_TURN_CAP: u32 = 5;
24
25/// Sentinel: `events.auto_turn_cap = 0` disables the cap entirely.
26pub const AUTO_TURN_UNLIMITED: u32 = 0;
27
28/// Single source of truth for "has the consecutive auto-turn cap been hit?"
29///
30/// * `cap == 0` (`AUTO_TURN_UNLIMITED`) → never reached.
31/// * otherwise → `consecutive >= cap`.
32#[inline]
33pub fn auto_turn_cap_reached(consecutive: u32, cap: u32) -> bool {
34    cap != AUTO_TURN_UNLIMITED && consecutive >= cap
35}
36
37/// Human-readable rendering of a cap value for user-facing messages.
38pub fn describe_auto_turn_cap(cap: u32) -> String {
39    if cap == AUTO_TURN_UNLIMITED {
40        "unlimited".to_string()
41    } else {
42        cap.to_string()
43    }
44}
45
46// ── Types ────────────────────────────────────────────────────────────────────
47
48/// What happened to a single event that was drained from the queue.
49#[derive(Debug, Clone, PartialEq, Eq)]
50pub enum EventDisposition {
51    /// Sent via the steering channel into an active stream.
52    Steered,
53    /// Buffered in `pending_events` (busy but no live steer channel).
54    Buffered,
55    /// Injected as a `role=user` message into `messages` (idle turn).
56    Injected,
57    /// Surfaced for local display ONLY (CP-13 fix1 I3): `never_persist`
58    /// events must not enter `messages`, `pending_events`, or a live
59    /// steering channel — all three feed session persistence.
60    DisplayOnly,
61}
62
63/// A single event that was drained, together with its formatted payload and
64/// how it was handled.
65#[derive(Debug, Clone)]
66pub struct DrainedEvent {
67    pub event: crate::events::types::Event,
68    pub formatted: String,
69    pub disposition: EventDisposition,
70}
71
72/// What the caller's event-loop arm should do after `drain_event_queue` + `wake_action`.
73#[derive(Debug, Clone, PartialEq, Eq)]
74pub enum WakeAction {
75    /// Spawn a new model turn (all preconditions satisfied).
76    RunTurn,
77    /// Forward / present the drained events but do NOT spawn a turn
78    /// (already streaming, no events, cap reached, etc.).
79    Forward,
80    /// Nothing was drained — no work to do.
81    Nothing,
82}
83
84// ── Core functions ────────────────────────────────────────────────────────────
85
86/// Drain every event currently in `queue`.
87///
88/// For each event:
89/// * **busy** (streaming or compact running):
90///   - try to steer via `steer_tx`; on success → `Steered`
91///   - if no live channel → `Buffered` (pushed to `pending_events`)
92/// * **idle**: inject as a `role=user` `SharedMessage` → `Injected`
93///
94/// Formatting is always done via the existing `format_event_for_agent` so
95/// canonical output is produced in exactly one place.
96pub fn drain_event_queue(
97    queue: &EventQueue,
98    messages: &mut Vec<SharedMessage>,
99    pending_events: &mut Vec<String>,
100    busy: bool,
101    steer_tx: Option<&UnboundedSender<String>>,
102) -> Vec<DrainedEvent> {
103    let mut drained = Vec::new();
104
105    while let Some(event) = queue.pop() {
106        let formatted = format_event_for_agent(&event);
107
108        // Persistence gate (spec §9.7): message history, pending buffers,
109        // and steered stream content all end up in saved sessions. Events
110        // whose class refuses persistence never cross this boundary.
111        let class = event.content.disclosure.unwrap_or_default();
112        if !agent_core::disclosure::may_persist(class) {
113            drained.push(DrainedEvent {
114                event,
115                formatted,
116                disposition: EventDisposition::DisplayOnly,
117            });
118            continue;
119        }
120
121        let disposition = if busy {
122            // Steer into active stream if the channel is live.
123            let steered = steer_tx
124                .map(|tx| tx.send(formatted.clone()).is_ok())
125                .unwrap_or(false);
126            if steered {
127                EventDisposition::Steered
128            } else {
129                pending_events.push(formatted.clone());
130                EventDisposition::Buffered
131            }
132        } else {
133            // Idle: inject directly into message history.
134            messages.push(std::sync::Arc::new(serde_json::json!({
135                "role": "user",
136                "content": formatted
137            })));
138            EventDisposition::Injected
139        };
140
141        drained.push(DrainedEvent {
142            event,
143            formatted,
144            disposition,
145        });
146    }
147
148    drained
149}
150
151/// Decide what the caller should do after draining the queue.
152///
153/// Rules (in order):
154/// 1. Nothing drained → `Nothing`.
155/// 2. Any `Injected` event + idle + auto-turn enabled + last message is
156///    `role=user` + consecutive cap not reached → `RunTurn`.
157/// 3. Otherwise → `Forward` (show events; caller may still steer/display).
158///
159/// Convenience wrapper over [`wake_action_with_cap`] using the default
160/// [`AUTO_TURN_CAP`]. Production callers should pass the configured cap.
161pub fn wake_action(
162    drained: &[DrainedEvent],
163    messages: &[SharedMessage],
164    busy: bool,
165    auto_turn_enabled: bool,
166    consecutive_auto_turns: u32,
167) -> WakeAction {
168    wake_action_with_cap(
169        drained,
170        messages,
171        busy,
172        auto_turn_enabled,
173        consecutive_auto_turns,
174        AUTO_TURN_CAP,
175    )
176}
177
178/// [`wake_action`] with an explicit cap (`events.auto_turn_cap`); `cap == 0`
179/// means unlimited.
180pub fn wake_action_with_cap(
181    drained: &[DrainedEvent],
182    messages: &[SharedMessage],
183    busy: bool,
184    auto_turn_enabled: bool,
185    consecutive_auto_turns: u32,
186    cap: u32,
187) -> WakeAction {
188    if drained.is_empty() {
189        return WakeAction::Nothing;
190    }
191
192    let has_injected = drained
193        .iter()
194        .any(|d| d.disposition == EventDisposition::Injected);
195
196    if has_injected
197        && !busy
198        && auto_turn_enabled
199        && !auto_turn_cap_reached(consecutive_auto_turns, cap)
200        && messages
201            .last()
202            .map(|m| m["role"].as_str() == Some("user"))
203            .unwrap_or(false)
204    {
205        return WakeAction::RunTurn;
206    }
207
208    WakeAction::Forward
209}
210
211// ── Wire-payload constructor ──────────────────────────────────────────────────
212
213/// Build the canonical `EventPayload` from a `DrainedEvent`.
214///
215/// This is the **single canonical constructor** shared by RPC and server
216/// modes — both produce identical structured payloads from the same source.
217/// The `formatted` field carries the full XML-tagged, injection-safe string
218/// produced by `format_event_for_agent`.
219pub fn event_payload_from_drained(drained: &DrainedEvent) -> EventPayload {
220    let ev = &drained.event;
221    let severity = ev
222        .content
223        .severity
224        .as_ref()
225        .map(|s| s.as_str())
226        .unwrap_or("medium")
227        .to_string();
228    EventPayload {
229        id: ev.id.clone(),
230        source: ev.source.source_type.clone(),
231        severity,
232        content_type: ev.content.content_type.clone(),
233        text: ev.content.text.clone(),
234        timestamp: ev.timestamp.to_rfc3339(),
235        formatted: drained.formatted.clone(),
236        disclosure: ev.content.disclosure.map(|c| c.as_str().to_string()),
237    }
238}
239
240// ── Auto-turn cap helper ──────────────────────────────────────────────────────
241
242/// Centralised auto-turn claim gate.
243///
244/// **Semantics (single source of truth):**
245/// * `cap == 0` → always allowed (unlimited): increment `counter`, return `true`.
246/// * `counter < cap` → allowed: increment `counter` and return `true`.
247/// * `counter >= cap` → denied: leave `counter` unchanged, return `false`.
248/// * User input resets `counter` to 0 (caller responsibility — not this function).
249///
250/// `claim_auto_turn` uses the default [`AUTO_TURN_CAP`]; production callers
251/// should use [`claim_auto_turn_with_cap`] with the configured
252/// `events.auto_turn_cap`.
253///
254/// All paths that may fire an auto-triggered model turn (TUI event arm, TUI
255/// stream arm, chat EventWake, chat AutoTriggerEvents, server idle wake, server
256/// AutoTriggerEvents) **must** call this function and bail when it returns `false`
257/// instead of duplicating the `fetch_add / >= cap` pattern inline.
258///
259/// # Arguments
260/// * `counter` — mutable reference to the caller's `u32` counter.
261///
262/// # Returns
263/// `true` if a turn is allowed (counter was incremented), `false` if parked.
264#[inline]
265pub fn claim_auto_turn(counter: &mut u32) -> bool {
266    claim_auto_turn_with_cap(counter, AUTO_TURN_CAP)
267}
268
269/// [`claim_auto_turn`] with an explicit cap; `cap == 0` means unlimited.
270#[inline]
271pub fn claim_auto_turn_with_cap(counter: &mut u32, cap: u32) -> bool {
272    if auto_turn_cap_reached(*counter, cap) {
273        false
274    } else {
275        *counter = counter.saturating_add(1);
276        true
277    }
278}
279
280// ─── terminal_flush seam for integration testing ─────────────────────────────
281
282/// Pure decision logic mirroring `terminal_flush` from `cmd/rpc.rs` for use in
283/// integration tests.
284///
285/// This seam allows tests to verify the `allow_chain` contract on the mutable
286/// state fields that `terminal_flush` operates on, **without** requiring a live
287/// `Runtime`, `Session`, or `Mutex<RpcState>`.
288///
289/// Rules (identical to `terminal_flush`):
290/// * Always: clear `auto_turn_pending`; take `pending_events` → inject into `api_messages`.
291/// * `allow_chain = false`: never reserve; return `None`; leave `counter` unchanged.
292/// * `allow_chain = true`: if all conditions met, increment `counter`, set
293///   `auto_turn_pending = true`, return `Some("auto:seam")`.
294pub fn terminal_flush_seam(
295    allow_chain: bool,
296    auto_turn_pending: &mut bool,
297    pending_events: &mut Vec<String>,
298    api_messages: &mut Vec<crate::SharedMessage>,
299    events_auto_turn: bool,
300    consecutive_auto_turns: &mut u32,
301) -> Option<String> {
302    terminal_flush_seam_with_cap(
303        allow_chain,
304        auto_turn_pending,
305        pending_events,
306        api_messages,
307        events_auto_turn,
308        consecutive_auto_turns,
309        AUTO_TURN_CAP,
310    )
311}
312
313/// [`terminal_flush_seam`] with an explicit cap; `cap == 0` means unlimited.
314pub fn terminal_flush_seam_with_cap(
315    allow_chain: bool,
316    auto_turn_pending: &mut bool,
317    pending_events: &mut Vec<String>,
318    api_messages: &mut Vec<crate::SharedMessage>,
319    events_auto_turn: bool,
320    consecutive_auto_turns: &mut u32,
321    cap: u32,
322) -> Option<String> {
323    *auto_turn_pending = false;
324    let to_inject = std::mem::take(pending_events);
325    let had_buffered = !to_inject.is_empty();
326    for formatted in to_inject {
327        api_messages.push(std::sync::Arc::new(
328            serde_json::json!({"role": "user", "content": formatted}),
329        ));
330    }
331    if allow_chain
332        && had_buffered
333        && events_auto_turn
334        && !auto_turn_cap_reached(*consecutive_auto_turns, cap)
335        && api_messages
336            .last()
337            .map(|m| m["role"].as_str() == Some("user"))
338            .unwrap_or(false)
339        && claim_auto_turn_with_cap(consecutive_auto_turns, cap)
340    {
341        *auto_turn_pending = true;
342        return Some("auto:seam".to_string());
343    }
344    None
345}
346
347// ─── spawn_prompt registration seam ─────────────────────────────────────────
348
349/// Pure decision logic for the `spawn_prompt` registration re-check (Bug 1 fix).
350///
351/// Models the guard that runs **inside** the registration lock just before
352/// `in_flight` is written. Returns `true` if it is safe to proceed with
353/// registration (caller should write `in_flight` and clear `auto_turn_pending`);
354/// returns `false` if the reservation was revoked by an intervening Abort and
355/// the caller must abort the spawned task (drop `start_tx`).
356///
357/// Rules (identical to the re-check in `spawn_prompt`):
358/// * Non-auto prompts (`!is_auto`) always return `true` — they are validated
359///   by `handle_prompt`'s `is_busy()` check before reaching here.
360/// * Auto prompts: `true` iff `auto_turn_pending && !in_flight_live`.
361/// * If returning `false`, the helper also defensively clears `auto_turn_pending`.
362///
363/// Used by `spawn_prompt` and by integration tests (not the replica scheduler).
364pub fn spawn_prompt_registration_check(
365    is_auto: bool,
366    auto_turn_pending: &mut bool,
367    in_flight_live: bool,
368) -> bool {
369    if !is_auto {
370        // A client prompt was checked before spawning, but an auto-turn or
371        // another prompt may have reserved/registered in the meantime.
372        return !*auto_turn_pending && !in_flight_live;
373    }
374    if *auto_turn_pending && !in_flight_live {
375        return true;
376    }
377    // Reservation revoked — clear pending defensively before returning false.
378    *auto_turn_pending = false;
379    false
380}
381
382// ── Tests ─────────────────────────────────────────────────────────────────────
383
384#[cfg(test)]
385mod tests {
386    use super::*;
387    use crate::events::{types::Severity, EventQueue};
388
389    fn make_queue_with(texts: &[(&str, Option<Severity>)]) -> EventQueue {
390        let q = EventQueue::new(64);
391        for (text, sev) in texts {
392            q.push(crate::events::types::Event::simple(
393                "test",
394                text,
395                sev.clone(),
396            ))
397            .unwrap();
398        }
399        q
400    }
401
402    fn user_msg(text: &str) -> SharedMessage {
403        std::sync::Arc::new(serde_json::json!({"role": "user", "content": text}))
404    }
405
406    fn assistant_msg() -> SharedMessage {
407        std::sync::Arc::new(serde_json::json!({"role": "assistant", "content": "ok"}))
408    }
409
410    // ── drain_event_queue ────────────────────────────────────────────────
411
412    #[test]
413    fn idle_single_event_injected() {
414        let q = make_queue_with(&[("hello", Some(Severity::Medium))]);
415        let mut messages: Vec<SharedMessage> = Vec::new();
416        let mut pending: Vec<String> = Vec::new();
417
418        let drained = drain_event_queue(&q, &mut messages, &mut pending, false, None);
419
420        assert_eq!(drained.len(), 1);
421        assert_eq!(drained[0].disposition, EventDisposition::Injected);
422        assert!(drained[0].formatted.contains("hello"));
423        assert_eq!(messages.len(), 1);
424        assert_eq!(messages[0]["role"], "user");
425        // Byte-equal: the model sees exactly the canonical rendering.
426        assert_eq!(messages[0]["content"].as_str(), Some(drained[0].formatted.as_str()));
427        assert!(pending.is_empty());
428        assert!(q.is_empty());
429    }
430
431    #[test]
432    fn idle_multiple_events_injected_in_order() {
433        let q = make_queue_with(&[
434            ("first", Some(Severity::Medium)),
435            ("second", Some(Severity::Medium)),
436            ("third", Some(Severity::Low)),
437        ]);
438        let mut messages: Vec<SharedMessage> = Vec::new();
439        let mut pending: Vec<String> = Vec::new();
440
441        let drained = drain_event_queue(&q, &mut messages, &mut pending, false, None);
442
443        assert_eq!(drained.len(), 3);
444        assert!(drained
445            .iter()
446            .all(|d| d.disposition == EventDisposition::Injected));
447        // Canonical order preserved
448        assert!(drained[0].formatted.contains("first"));
449        assert!(drained[1].formatted.contains("second"));
450        assert!(drained[2].formatted.contains("third"));
451        assert_eq!(messages.len(), 3);
452    }
453
454    #[test]
455    fn busy_live_steer_tx_steers() {
456        let q = make_queue_with(&[("ev", Some(Severity::Medium))]);
457        let mut messages: Vec<SharedMessage> = Vec::new();
458        let mut pending: Vec<String> = Vec::new();
459        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<String>();
460
461        let drained = drain_event_queue(&q, &mut messages, &mut pending, true, Some(&tx));
462
463        assert_eq!(drained.len(), 1);
464        assert_eq!(drained[0].disposition, EventDisposition::Steered);
465        assert!(messages.is_empty());
466        assert!(pending.is_empty());
467        // The formatted payload was actually sent
468        let sent = rx.try_recv().expect("message should have been steered");
469        assert!(sent.contains("ev"));
470    }
471
472    #[test]
473    fn busy_dead_steer_tx_buffers() {
474        let q = make_queue_with(&[("ev", Some(Severity::Medium))]);
475        let mut messages: Vec<SharedMessage> = Vec::new();
476        let mut pending: Vec<String> = Vec::new();
477        // Create a tx whose rx is immediately dropped → send will fail
478        let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<String>();
479        drop(rx);
480
481        let drained = drain_event_queue(&q, &mut messages, &mut pending, true, Some(&tx));
482
483        assert_eq!(drained.len(), 1);
484        assert_eq!(drained[0].disposition, EventDisposition::Buffered);
485        assert!(messages.is_empty());
486        assert_eq!(pending.len(), 1);
487        assert!(pending[0].contains("ev"));
488    }
489
490    #[test]
491    fn busy_no_steer_tx_buffers() {
492        let q = make_queue_with(&[("ev", Some(Severity::Medium))]);
493        let mut messages: Vec<SharedMessage> = Vec::new();
494        let mut pending: Vec<String> = Vec::new();
495
496        let drained = drain_event_queue(&q, &mut messages, &mut pending, true, None);
497
498        assert_eq!(drained.len(), 1);
499        assert_eq!(drained[0].disposition, EventDisposition::Buffered);
500        assert!(pending.len() == 1);
501    }
502
503    #[test]
504    fn empty_queue_returns_nothing() {
505        let q = EventQueue::new(10);
506        let mut messages: Vec<SharedMessage> = Vec::new();
507        let mut pending: Vec<String> = Vec::new();
508
509        let drained = drain_event_queue(&q, &mut messages, &mut pending, false, None);
510
511        assert!(drained.is_empty());
512        assert!(messages.is_empty());
513        assert!(pending.is_empty());
514    }
515
516    #[test]
517    fn canonical_format_contains_xml_event_tags() {
518        let q = make_queue_with(&[("ping", Some(Severity::High))]);
519        let mut messages: Vec<SharedMessage> = Vec::new();
520        let mut pending: Vec<String> = Vec::new();
521
522        let drained = drain_event_queue(&q, &mut messages, &mut pending, false, None);
523
524        assert!(drained[0].formatted.starts_with("<event "));
525        assert!(drained[0].formatted.ends_with("</event>"));
526        assert!(drained[0].formatted.contains("severity=\"high\""));
527    }
528
529    #[test]
530    fn priority_ordering_preserved_critical_first() {
531        // Push medium first, then critical — critical should be drained first
532        let q = EventQueue::new(10);
533        q.push(crate::events::types::Event::simple(
534            "test",
535            "medium",
536            Some(Severity::Medium),
537        ))
538        .unwrap();
539        q.push(crate::events::types::Event::simple(
540            "test",
541            "critical",
542            Some(Severity::Critical),
543        ))
544        .unwrap();
545
546        let mut messages: Vec<SharedMessage> = Vec::new();
547        let mut pending: Vec<String> = Vec::new();
548
549        let drained = drain_event_queue(&q, &mut messages, &mut pending, false, None);
550
551        assert_eq!(drained.len(), 2);
552        assert!(drained[0].formatted.contains("critical"));
553        assert!(drained[1].formatted.contains("medium"));
554    }
555
556    // ── wake_action truth table ──────────────────────────────────────────
557
558    fn injected_event() -> DrainedEvent {
559        let event = crate::events::types::Event::simple("test", "x", None);
560        DrainedEvent {
561            formatted: format_event_for_agent(&event),
562            event,
563            disposition: EventDisposition::Injected,
564        }
565    }
566
567    fn steered_event() -> DrainedEvent {
568        let event = crate::events::types::Event::simple("test", "x", None);
569        DrainedEvent {
570            formatted: format_event_for_agent(&event),
571            event,
572            disposition: EventDisposition::Steered,
573        }
574    }
575
576    fn buffered_event() -> DrainedEvent {
577        let event = crate::events::types::Event::simple("test", "x", None);
578        DrainedEvent {
579            formatted: format_event_for_agent(&event),
580            event,
581            disposition: EventDisposition::Buffered,
582        }
583    }
584
585    #[test]
586    fn wake_nothing_when_drained_empty() {
587        let result = wake_action(&[], &[], false, true, 0);
588        assert_eq!(result, WakeAction::Nothing);
589    }
590
591    #[test]
592    fn wake_run_turn_when_injected_idle_enabled_under_cap() {
593        let drained = vec![injected_event()];
594        let messages = vec![user_msg("hello")];
595        let result = wake_action(&drained, &messages, false, true, 0);
596        assert_eq!(result, WakeAction::RunTurn);
597    }
598
599    #[test]
600    fn wake_forward_when_busy() {
601        let drained = vec![injected_event()];
602        let messages = vec![user_msg("hello")];
603        let result = wake_action(&drained, &messages, true, true, 0);
604        assert_eq!(result, WakeAction::Forward);
605    }
606
607    #[test]
608    fn wake_forward_when_auto_turn_disabled() {
609        let drained = vec![injected_event()];
610        let messages = vec![user_msg("hello")];
611        let result = wake_action(&drained, &messages, false, false, 0);
612        assert_eq!(result, WakeAction::Forward);
613    }
614
615    #[test]
616    fn wake_forward_when_last_message_not_user() {
617        let drained = vec![injected_event()];
618        let messages = vec![assistant_msg()];
619        let result = wake_action(&drained, &messages, false, true, 0);
620        assert_eq!(result, WakeAction::Forward);
621    }
622
623    #[test]
624    fn wake_forward_when_messages_empty() {
625        let drained = vec![injected_event()];
626        let result = wake_action(&drained, &[], false, true, 0);
627        assert_eq!(result, WakeAction::Forward);
628    }
629
630    #[test]
631    fn wake_forward_when_cap_reached() {
632        let drained = vec![injected_event()];
633        let messages = vec![user_msg("hello")];
634        // Exactly at cap — should NOT run
635        let result = wake_action(&drained, &messages, false, true, AUTO_TURN_CAP);
636        assert_eq!(result, WakeAction::Forward);
637    }
638
639    #[test]
640    fn wake_run_turn_when_one_below_cap() {
641        let drained = vec![injected_event()];
642        let messages = vec![user_msg("hello")];
643        let result = wake_action(&drained, &messages, false, true, AUTO_TURN_CAP - 1);
644        assert_eq!(result, WakeAction::RunTurn);
645    }
646
647    #[test]
648    fn wake_forward_when_only_steered_events() {
649        // Steered events don't qualify for RunTurn
650        let drained = vec![steered_event()];
651        let messages = vec![user_msg("hello")];
652        let result = wake_action(&drained, &messages, false, true, 0);
653        assert_eq!(result, WakeAction::Forward);
654    }
655
656    #[test]
657    fn wake_forward_when_only_buffered_events() {
658        let drained = vec![buffered_event()];
659        let messages = vec![user_msg("hello")];
660        let result = wake_action(&drained, &messages, false, true, 0);
661        assert_eq!(result, WakeAction::Forward);
662    }
663
664    #[test]
665    fn wake_run_turn_with_mixed_injected_and_steered() {
666        // At least one Injected is enough
667        let drained = vec![steered_event(), injected_event()];
668        let messages = vec![user_msg("hello")];
669        let result = wake_action(&drained, &messages, false, true, 0);
670        assert_eq!(result, WakeAction::RunTurn);
671    }
672
673    // ── claim_auto_turn boundary tests ─────────────────────────────────────
674
675    #[test]
676    fn claim_auto_turn_first_five_allowed() {
677        let mut counter: u32 = 0;
678        for _ in 0..AUTO_TURN_CAP {
679            assert!(
680                claim_auto_turn(&mut counter),
681                "turn within cap must be allowed"
682            );
683        }
684        assert_eq!(
685            counter, AUTO_TURN_CAP,
686            "counter must equal cap after 5 claims"
687        );
688    }
689
690    #[test]
691    fn claim_auto_turn_sixth_denied() {
692        let mut counter: u32 = AUTO_TURN_CAP;
693        assert!(!claim_auto_turn(&mut counter), "turn at cap must be denied");
694        assert_eq!(
695            counter, AUTO_TURN_CAP,
696            "counter must remain unchanged when denied"
697        );
698    }
699
700    #[test]
701    fn claim_auto_turn_remains_denied_until_reset() {
702        let mut counter: u32 = AUTO_TURN_CAP;
703        assert!(!claim_auto_turn(&mut counter));
704        assert!(!claim_auto_turn(&mut counter));
705        assert!(!claim_auto_turn(&mut counter));
706        assert_eq!(
707            counter, AUTO_TURN_CAP,
708            "counter must not change while denied"
709        );
710    }
711
712    #[test]
713    fn claim_auto_turn_reset_re_enables() {
714        let mut counter: u32 = AUTO_TURN_CAP;
715        assert!(!claim_auto_turn(&mut counter));
716        // Simulate user input reset
717        counter = 0;
718        assert!(
719            claim_auto_turn(&mut counter),
720            "after reset first turn must be allowed"
721        );
722        assert_eq!(counter, 1);
723    }
724
725    #[test]
726    fn claim_auto_turn_exact_boundary_sequence() {
727        // Exactly AUTO_TURN_CAP (5) allowed, 6th denied
728        let mut c: u32 = 0;
729        for i in 1..=AUTO_TURN_CAP {
730            assert!(claim_auto_turn(&mut c), "turn {i} must be allowed");
731            assert_eq!(c, i);
732        }
733        // 6th
734        assert!(!claim_auto_turn(&mut c));
735        assert_eq!(c, AUTO_TURN_CAP);
736    }
737
738    // ── configurable cap (events.auto_turn_cap) ─────────────────────────
739
740    #[test]
741    fn cap_reached_zero_is_unlimited() {
742        assert!(!auto_turn_cap_reached(0, AUTO_TURN_UNLIMITED));
743        assert!(!auto_turn_cap_reached(u32::MAX, AUTO_TURN_UNLIMITED));
744        assert!(auto_turn_cap_reached(5, 5));
745        assert!(!auto_turn_cap_reached(4, 5));
746    }
747
748    #[test]
749    fn claim_with_cap_zero_never_trips_across_100_turns() {
750        let mut counter: u32 = 0;
751        for i in 1..=100u32 {
752            assert!(
753                claim_auto_turn_with_cap(&mut counter, AUTO_TURN_UNLIMITED),
754                "unlimited: turn {i} must be allowed"
755            );
756        }
757        assert_eq!(counter, 100);
758    }
759
760    #[test]
761    fn claim_with_cap_two_trips_on_third() {
762        let mut counter: u32 = 0;
763        assert!(claim_auto_turn_with_cap(&mut counter, 2));
764        assert!(claim_auto_turn_with_cap(&mut counter, 2));
765        assert!(!claim_auto_turn_with_cap(&mut counter, 2), "3rd must be denied");
766        assert_eq!(counter, 2);
767    }
768
769    #[test]
770    fn claim_with_cap_unlimited_saturates_instead_of_overflowing() {
771        let mut counter: u32 = u32::MAX;
772        assert!(claim_auto_turn_with_cap(&mut counter, AUTO_TURN_UNLIMITED));
773        assert_eq!(counter, u32::MAX);
774    }
775
776    #[test]
777    fn wake_with_cap_zero_never_parks_across_100_turns() {
778        let drained = vec![injected_event()];
779        let messages = vec![user_msg("ev")];
780        for consecutive in 0..100u32 {
781            assert_eq!(
782                wake_action_with_cap(&drained, &messages, false, true, consecutive, 0),
783                WakeAction::RunTurn,
784                "unlimited: consecutive={consecutive} must still RunTurn"
785            );
786        }
787    }
788
789    #[test]
790    fn wake_with_cap_two_forwards_on_third() {
791        let drained = vec![injected_event()];
792        let messages = vec![user_msg("ev")];
793        assert_eq!(
794            wake_action_with_cap(&drained, &messages, false, true, 1, 2),
795            WakeAction::RunTurn
796        );
797        assert_eq!(
798            wake_action_with_cap(&drained, &messages, false, true, 2, 2),
799            WakeAction::Forward
800        );
801    }
802
803    #[test]
804    fn wake_default_wrapper_matches_default_cap() {
805        let drained = vec![injected_event()];
806        let messages = vec![user_msg("ev")];
807        assert_eq!(
808            wake_action(&drained, &messages, false, true, AUTO_TURN_CAP),
809            wake_action_with_cap(&drained, &messages, false, true, AUTO_TURN_CAP, AUTO_TURN_CAP)
810        );
811    }
812
813    #[test]
814    fn terminal_flush_seam_with_cap_zero_reserves_at_high_counter() {
815        let mut pending = false;
816        let mut events = vec!["<event>x</event>".to_string()];
817        let mut msgs: Vec<SharedMessage> = Vec::new();
818        let mut counter: u32 = 1_000;
819        let r = terminal_flush_seam_with_cap(
820            true, &mut pending, &mut events, &mut msgs, true, &mut counter, 0,
821        );
822        assert_eq!(r.as_deref(), Some("auto:seam"));
823        assert!(pending);
824        assert_eq!(counter, 1_001);
825    }
826
827    #[test]
828    fn describe_cap_renders_unlimited_and_numbers() {
829        assert_eq!(describe_auto_turn_cap(0), "unlimited");
830        assert_eq!(describe_auto_turn_cap(12), "12");
831    }
832
833}
834
835#[cfg(test)]
836mod disclosure_persistence_tests {
837    use super::*;
838    use crate::events::types::{Event, Severity};
839    use agent_core::disclosure::DisclosureClass;
840    use serial_test::serial;
841
842    use crate::test_env::BaseDirGuard;
843
844    const SENTINEL: &str = "PERSIST-BOUNDARY-SENTINEL-5e3b";
845
846    fn classed_event(class: Option<DisclosureClass>) -> Event {
847        let mut ev = Event::simple("kuma", SENTINEL, Some(Severity::High));
848        ev.content.content_type = "message".into();
849        ev.content.disclosure = class;
850        ev
851    }
852
853    fn drain_one(
854        class: Option<DisclosureClass>,
855        busy: bool,
856        steer: bool,
857    ) -> (
858        Vec<crate::SharedMessage>,
859        Vec<String>,
860        Vec<DrainedEvent>,
861        Option<tokio::sync::mpsc::UnboundedReceiver<String>>,
862    ) {
863        let queue = EventQueue::new(16);
864        queue.push(classed_event(class)).unwrap();
865        let mut messages = Vec::new();
866        let mut pending = Vec::new();
867        let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
868        let steer_tx = if steer { Some(&tx) } else { None };
869        let drained = drain_event_queue(&queue, &mut messages, &mut pending, busy, steer_tx);
870        (messages, pending, drained, Some(rx))
871    }
872
873    /// CP-13 fix1 I3: the event→message boundary IS a persistence boundary
874    /// (messages are saved verbatim into session files). never_persist
875    /// events must not cross it in ANY disposition; withheld classes cross
876    /// only as their typed marker.
877    #[tokio::test]
878    #[serial(synaps_base_dir)]
879    async fn all_disclosure_classes_are_honored_through_real_session_persistence() {
880        let _base = BaseDirGuard::new();
881
882        // Visible classes persist their content.
883        for class in [None, Some(DisclosureClass::ModelVisible)] {
884            let (messages, _, drained, _) = drain_one(class, false, false);
885            assert_eq!(drained[0].disposition, EventDisposition::Injected);
886            let mut session = agent_core::session::Session::new("claude-sonnet-4-6", "high", None);
887            session.api_messages = messages;
888            session.save().await.unwrap();
889            let raw = std::fs::read_to_string(
890                agent_core::config::get_active_config_dir()
891                    .join("sessions")
892                    .join(format!("{}.json", session.id)),
893            )
894            .unwrap();
895            assert!(raw.contains(SENTINEL), "{class:?} persists content");
896        }
897
898        // Withheld-from-model classes persist ONLY the typed marker.
899        for class in [
900            DisclosureClass::LocalOnly,
901            DisclosureClass::ModelVisibleAfterRedaction,
902            DisclosureClass::ModelVisibleAfterConsent,
903            DisclosureClass::PersistNeverTransmit,
904        ] {
905            let (messages, _, drained, _) = drain_one(Some(class), false, false);
906            assert_eq!(drained[0].disposition, EventDisposition::Injected);
907            let mut session = agent_core::session::Session::new("claude-sonnet-4-6", "high", None);
908            session.api_messages = messages;
909            session.save().await.unwrap();
910            let raw = std::fs::read_to_string(
911                agent_core::config::get_active_config_dir()
912                    .join("sessions")
913                    .join(format!("{}.json", session.id)),
914            )
915            .unwrap();
916            assert!(
917                !raw.contains(SENTINEL),
918                "{class:?}: withheld content must never persist"
919            );
920            assert!(
921                raw.contains("content withheld"),
922                "{class:?}: marker persists"
923            );
924        }
925
926        // never_persist: nothing enters messages, pending buffers, or the
927        // steering channel — display-only in every mode.
928        let (messages, pending, drained, _) =
929            drain_one(Some(DisclosureClass::NeverPersist), false, false);
930        assert_eq!(
931            drained[0].disposition,
932            EventDisposition::DisplayOnly,
933            "idle: never_persist must not be injected"
934        );
935        assert!(messages.is_empty());
936        assert!(pending.is_empty());
937        assert!(
938            drained[0].formatted.contains(SENTINEL),
939            "never_persist stays locally displayable"
940        );
941
942        let (messages, pending, drained, rx) =
943            drain_one(Some(DisclosureClass::NeverPersist), true, true);
944        assert_eq!(drained[0].disposition, EventDisposition::DisplayOnly);
945        assert!(messages.is_empty() && pending.is_empty());
946        let mut rx = rx.unwrap();
947        assert!(
948            rx.try_recv().is_err(),
949            "never_persist must not be steered into a live stream (streams \
950             persist into history)"
951        );
952    }
953
954    /// Typed disclosure metadata rides the wire payload so RPC/server
955    /// parents can honor persistence policy after handoff.
956    #[test]
957    fn wire_payload_retains_typed_disclosure_metadata() {
958        let queue = EventQueue::new(16);
959        queue
960            .push(classed_event(Some(DisclosureClass::NeverPersist)))
961            .unwrap();
962        let mut messages = Vec::new();
963        let mut pending = Vec::new();
964        let drained = drain_event_queue(&queue, &mut messages, &mut pending, false, None);
965        let payload = event_payload_from_drained(&drained[0]);
966        assert_eq!(payload.disclosure.as_deref(), Some("never_persist"));
967
968        let queue = EventQueue::new(16);
969        queue.push(classed_event(None)).unwrap();
970        let drained = drain_event_queue(&queue, &mut messages, &mut pending, false, None);
971        let payload = event_payload_from_drained(&drained[0]);
972        assert_eq!(payload.disclosure, None, "absent class stays absent");
973    }
974}