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