1use tokio::sync::mpsc::UnboundedSender;
11
12use crate::events::{format_event_for_agent, EventQueue};
13use crate::SharedMessage;
14
15pub use agent_core::core::rpc_protocol::EventPayload;
17
18pub const AUTO_TURN_CAP: u32 = 5;
24
25pub const AUTO_TURN_UNLIMITED: u32 = 0;
27
28pub const AUTO_TURN_CAP_CONFIG_KEY: &str = "events.auto_turn_cap";
30
31#[inline]
36pub fn auto_turn_cap_reached(consecutive: u32, cap: u32) -> bool {
37 cap != AUTO_TURN_UNLIMITED && consecutive >= cap
38}
39
40pub 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#[derive(Debug, Clone, PartialEq, Eq)]
53pub enum EventDisposition {
54 Steered,
56 Buffered,
58 Injected,
60 DisplayOnly,
64}
65
66#[derive(Debug, Clone)]
69pub struct DrainedEvent {
70 pub event: crate::events::types::Event,
71 pub formatted: String,
72 pub disposition: EventDisposition,
73}
74
75#[derive(Debug, Clone, PartialEq, Eq)]
77pub enum WakeAction {
78 RunTurn,
80 Forward,
83 Nothing,
85}
86
87pub 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 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 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 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
154pub 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
181pub 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
214pub 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#[inline]
268pub fn claim_auto_turn(counter: &mut u32) -> bool {
269 claim_auto_turn_with_cap(counter, AUTO_TURN_CAP)
270}
271
272#[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#[inline]
295pub fn idle_should_wait(children_running: bool, queue_len: usize) -> bool {
296 children_running || queue_len > 0
297}
298
299pub 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
332pub 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
366pub 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 return !*auto_turn_pending && !in_flight_live;
392 }
393 if *auto_turn_pending && !in_flight_live {
394 return true;
395 }
396 *auto_turn_pending = false;
398 false
399}
400
401#[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 #[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 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 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 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 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 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 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 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 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 #[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 #[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 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 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 assert!(!claim_auto_turn(&mut c));
780 assert_eq!(c, AUTO_TURN_CAP);
781 }
782
783 #[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 #[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 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 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 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 #[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}