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