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
28#[inline]
33pub fn auto_turn_cap_reached(consecutive: u32, cap: u32) -> bool {
34 cap != AUTO_TURN_UNLIMITED && consecutive >= cap
35}
36
37pub fn describe_auto_turn_cap(cap: u32) -> String {
39 if cap == AUTO_TURN_UNLIMITED {
40 "unlimited".to_string()
41 } else {
42 cap.to_string()
43 }
44}
45
46#[derive(Debug, Clone, PartialEq, Eq)]
50pub enum EventDisposition {
51 Steered,
53 Buffered,
55 Injected,
57 DisplayOnly,
61}
62
63#[derive(Debug, Clone)]
66pub struct DrainedEvent {
67 pub event: crate::events::types::Event,
68 pub formatted: String,
69 pub disposition: EventDisposition,
70}
71
72#[derive(Debug, Clone, PartialEq, Eq)]
74pub enum WakeAction {
75 RunTurn,
77 Forward,
80 Nothing,
82}
83
84pub fn drain_event_queue(
97 queue: &EventQueue,
98 messages: &mut Vec<SharedMessage>,
99 pending_events: &mut Vec<String>,
100 busy: bool,
101 steer_tx: Option<&UnboundedSender<String>>,
102) -> Vec<DrainedEvent> {
103 let mut drained = Vec::new();
104
105 while let Some(event) = queue.pop() {
106 let formatted = format_event_for_agent(&event);
107
108 let class = event.content.disclosure.unwrap_or_default();
112 if !agent_core::disclosure::may_persist(class) {
113 drained.push(DrainedEvent {
114 event,
115 formatted,
116 disposition: EventDisposition::DisplayOnly,
117 });
118 continue;
119 }
120
121 let disposition = if busy {
122 let steered = steer_tx
124 .map(|tx| tx.send(formatted.clone()).is_ok())
125 .unwrap_or(false);
126 if steered {
127 EventDisposition::Steered
128 } else {
129 pending_events.push(formatted.clone());
130 EventDisposition::Buffered
131 }
132 } else {
133 messages.push(std::sync::Arc::new(serde_json::json!({
135 "role": "user",
136 "content": formatted
137 })));
138 EventDisposition::Injected
139 };
140
141 drained.push(DrainedEvent {
142 event,
143 formatted,
144 disposition,
145 });
146 }
147
148 drained
149}
150
151pub fn wake_action(
162 drained: &[DrainedEvent],
163 messages: &[SharedMessage],
164 busy: bool,
165 auto_turn_enabled: bool,
166 consecutive_auto_turns: u32,
167) -> WakeAction {
168 wake_action_with_cap(
169 drained,
170 messages,
171 busy,
172 auto_turn_enabled,
173 consecutive_auto_turns,
174 AUTO_TURN_CAP,
175 )
176}
177
178pub fn wake_action_with_cap(
181 drained: &[DrainedEvent],
182 messages: &[SharedMessage],
183 busy: bool,
184 auto_turn_enabled: bool,
185 consecutive_auto_turns: u32,
186 cap: u32,
187) -> WakeAction {
188 if drained.is_empty() {
189 return WakeAction::Nothing;
190 }
191
192 let has_injected = drained
193 .iter()
194 .any(|d| d.disposition == EventDisposition::Injected);
195
196 if has_injected
197 && !busy
198 && auto_turn_enabled
199 && !auto_turn_cap_reached(consecutive_auto_turns, cap)
200 && messages
201 .last()
202 .map(|m| m["role"].as_str() == Some("user"))
203 .unwrap_or(false)
204 {
205 return WakeAction::RunTurn;
206 }
207
208 WakeAction::Forward
209}
210
211pub fn event_payload_from_drained(drained: &DrainedEvent) -> EventPayload {
220 let ev = &drained.event;
221 let severity = ev
222 .content
223 .severity
224 .as_ref()
225 .map(|s| s.as_str())
226 .unwrap_or("medium")
227 .to_string();
228 EventPayload {
229 id: ev.id.clone(),
230 source: ev.source.source_type.clone(),
231 severity,
232 content_type: ev.content.content_type.clone(),
233 text: ev.content.text.clone(),
234 timestamp: ev.timestamp.to_rfc3339(),
235 formatted: drained.formatted.clone(),
236 disclosure: ev.content.disclosure.map(|c| c.as_str().to_string()),
237 }
238}
239
240#[inline]
265pub fn claim_auto_turn(counter: &mut u32) -> bool {
266 claim_auto_turn_with_cap(counter, AUTO_TURN_CAP)
267}
268
269#[inline]
271pub fn claim_auto_turn_with_cap(counter: &mut u32, cap: u32) -> bool {
272 if auto_turn_cap_reached(*counter, cap) {
273 false
274 } else {
275 *counter = counter.saturating_add(1);
276 true
277 }
278}
279
280pub fn terminal_flush_seam(
295 allow_chain: bool,
296 auto_turn_pending: &mut bool,
297 pending_events: &mut Vec<String>,
298 api_messages: &mut Vec<crate::SharedMessage>,
299 events_auto_turn: bool,
300 consecutive_auto_turns: &mut u32,
301) -> Option<String> {
302 terminal_flush_seam_with_cap(
303 allow_chain,
304 auto_turn_pending,
305 pending_events,
306 api_messages,
307 events_auto_turn,
308 consecutive_auto_turns,
309 AUTO_TURN_CAP,
310 )
311}
312
313pub fn terminal_flush_seam_with_cap(
315 allow_chain: bool,
316 auto_turn_pending: &mut bool,
317 pending_events: &mut Vec<String>,
318 api_messages: &mut Vec<crate::SharedMessage>,
319 events_auto_turn: bool,
320 consecutive_auto_turns: &mut u32,
321 cap: u32,
322) -> Option<String> {
323 *auto_turn_pending = false;
324 let to_inject = std::mem::take(pending_events);
325 let had_buffered = !to_inject.is_empty();
326 for formatted in to_inject {
327 api_messages.push(std::sync::Arc::new(
328 serde_json::json!({"role": "user", "content": formatted}),
329 ));
330 }
331 if allow_chain
332 && had_buffered
333 && events_auto_turn
334 && !auto_turn_cap_reached(*consecutive_auto_turns, cap)
335 && api_messages
336 .last()
337 .map(|m| m["role"].as_str() == Some("user"))
338 .unwrap_or(false)
339 && claim_auto_turn_with_cap(consecutive_auto_turns, cap)
340 {
341 *auto_turn_pending = true;
342 return Some("auto:seam".to_string());
343 }
344 None
345}
346
347pub fn spawn_prompt_registration_check(
365 is_auto: bool,
366 auto_turn_pending: &mut bool,
367 in_flight_live: bool,
368) -> bool {
369 if !is_auto {
370 return !*auto_turn_pending && !in_flight_live;
373 }
374 if *auto_turn_pending && !in_flight_live {
375 return true;
376 }
377 *auto_turn_pending = false;
379 false
380}
381
382#[cfg(test)]
385mod tests {
386 use super::*;
387 use crate::events::{types::Severity, EventQueue};
388
389 fn make_queue_with(texts: &[(&str, Option<Severity>)]) -> EventQueue {
390 let q = EventQueue::new(64);
391 for (text, sev) in texts {
392 q.push(crate::events::types::Event::simple(
393 "test",
394 text,
395 sev.clone(),
396 ))
397 .unwrap();
398 }
399 q
400 }
401
402 fn user_msg(text: &str) -> SharedMessage {
403 std::sync::Arc::new(serde_json::json!({"role": "user", "content": text}))
404 }
405
406 fn assistant_msg() -> SharedMessage {
407 std::sync::Arc::new(serde_json::json!({"role": "assistant", "content": "ok"}))
408 }
409
410 #[test]
413 fn idle_single_event_injected() {
414 let q = make_queue_with(&[("hello", Some(Severity::Medium))]);
415 let mut messages: Vec<SharedMessage> = Vec::new();
416 let mut pending: Vec<String> = Vec::new();
417
418 let drained = drain_event_queue(&q, &mut messages, &mut pending, false, None);
419
420 assert_eq!(drained.len(), 1);
421 assert_eq!(drained[0].disposition, EventDisposition::Injected);
422 assert!(drained[0].formatted.contains("hello"));
423 assert_eq!(messages.len(), 1);
424 assert_eq!(messages[0]["role"], "user");
425 assert_eq!(messages[0]["content"].as_str(), Some(drained[0].formatted.as_str()));
427 assert!(pending.is_empty());
428 assert!(q.is_empty());
429 }
430
431 #[test]
432 fn idle_multiple_events_injected_in_order() {
433 let q = make_queue_with(&[
434 ("first", Some(Severity::Medium)),
435 ("second", Some(Severity::Medium)),
436 ("third", Some(Severity::Low)),
437 ]);
438 let mut messages: Vec<SharedMessage> = Vec::new();
439 let mut pending: Vec<String> = Vec::new();
440
441 let drained = drain_event_queue(&q, &mut messages, &mut pending, false, None);
442
443 assert_eq!(drained.len(), 3);
444 assert!(drained
445 .iter()
446 .all(|d| d.disposition == EventDisposition::Injected));
447 assert!(drained[0].formatted.contains("first"));
449 assert!(drained[1].formatted.contains("second"));
450 assert!(drained[2].formatted.contains("third"));
451 assert_eq!(messages.len(), 3);
452 }
453
454 #[test]
455 fn busy_live_steer_tx_steers() {
456 let q = make_queue_with(&[("ev", Some(Severity::Medium))]);
457 let mut messages: Vec<SharedMessage> = Vec::new();
458 let mut pending: Vec<String> = Vec::new();
459 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<String>();
460
461 let drained = drain_event_queue(&q, &mut messages, &mut pending, true, Some(&tx));
462
463 assert_eq!(drained.len(), 1);
464 assert_eq!(drained[0].disposition, EventDisposition::Steered);
465 assert!(messages.is_empty());
466 assert!(pending.is_empty());
467 let sent = rx.try_recv().expect("message should have been steered");
469 assert!(sent.contains("ev"));
470 }
471
472 #[test]
473 fn busy_dead_steer_tx_buffers() {
474 let q = make_queue_with(&[("ev", Some(Severity::Medium))]);
475 let mut messages: Vec<SharedMessage> = Vec::new();
476 let mut pending: Vec<String> = Vec::new();
477 let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<String>();
479 drop(rx);
480
481 let drained = drain_event_queue(&q, &mut messages, &mut pending, true, Some(&tx));
482
483 assert_eq!(drained.len(), 1);
484 assert_eq!(drained[0].disposition, EventDisposition::Buffered);
485 assert!(messages.is_empty());
486 assert_eq!(pending.len(), 1);
487 assert!(pending[0].contains("ev"));
488 }
489
490 #[test]
491 fn busy_no_steer_tx_buffers() {
492 let q = make_queue_with(&[("ev", Some(Severity::Medium))]);
493 let mut messages: Vec<SharedMessage> = Vec::new();
494 let mut pending: Vec<String> = Vec::new();
495
496 let drained = drain_event_queue(&q, &mut messages, &mut pending, true, None);
497
498 assert_eq!(drained.len(), 1);
499 assert_eq!(drained[0].disposition, EventDisposition::Buffered);
500 assert!(pending.len() == 1);
501 }
502
503 #[test]
504 fn empty_queue_returns_nothing() {
505 let q = EventQueue::new(10);
506 let mut messages: Vec<SharedMessage> = Vec::new();
507 let mut pending: Vec<String> = Vec::new();
508
509 let drained = drain_event_queue(&q, &mut messages, &mut pending, false, None);
510
511 assert!(drained.is_empty());
512 assert!(messages.is_empty());
513 assert!(pending.is_empty());
514 }
515
516 #[test]
517 fn canonical_format_contains_xml_event_tags() {
518 let q = make_queue_with(&[("ping", Some(Severity::High))]);
519 let mut messages: Vec<SharedMessage> = Vec::new();
520 let mut pending: Vec<String> = Vec::new();
521
522 let drained = drain_event_queue(&q, &mut messages, &mut pending, false, None);
523
524 assert!(drained[0].formatted.starts_with("<event "));
525 assert!(drained[0].formatted.ends_with("</event>"));
526 assert!(drained[0].formatted.contains("severity=\"high\""));
527 }
528
529 #[test]
530 fn priority_ordering_preserved_critical_first() {
531 let q = EventQueue::new(10);
533 q.push(crate::events::types::Event::simple(
534 "test",
535 "medium",
536 Some(Severity::Medium),
537 ))
538 .unwrap();
539 q.push(crate::events::types::Event::simple(
540 "test",
541 "critical",
542 Some(Severity::Critical),
543 ))
544 .unwrap();
545
546 let mut messages: Vec<SharedMessage> = Vec::new();
547 let mut pending: Vec<String> = Vec::new();
548
549 let drained = drain_event_queue(&q, &mut messages, &mut pending, false, None);
550
551 assert_eq!(drained.len(), 2);
552 assert!(drained[0].formatted.contains("critical"));
553 assert!(drained[1].formatted.contains("medium"));
554 }
555
556 fn injected_event() -> DrainedEvent {
559 let event = crate::events::types::Event::simple("test", "x", None);
560 DrainedEvent {
561 formatted: format_event_for_agent(&event),
562 event,
563 disposition: EventDisposition::Injected,
564 }
565 }
566
567 fn steered_event() -> DrainedEvent {
568 let event = crate::events::types::Event::simple("test", "x", None);
569 DrainedEvent {
570 formatted: format_event_for_agent(&event),
571 event,
572 disposition: EventDisposition::Steered,
573 }
574 }
575
576 fn buffered_event() -> DrainedEvent {
577 let event = crate::events::types::Event::simple("test", "x", None);
578 DrainedEvent {
579 formatted: format_event_for_agent(&event),
580 event,
581 disposition: EventDisposition::Buffered,
582 }
583 }
584
585 #[test]
586 fn wake_nothing_when_drained_empty() {
587 let result = wake_action(&[], &[], false, true, 0);
588 assert_eq!(result, WakeAction::Nothing);
589 }
590
591 #[test]
592 fn wake_run_turn_when_injected_idle_enabled_under_cap() {
593 let drained = vec![injected_event()];
594 let messages = vec![user_msg("hello")];
595 let result = wake_action(&drained, &messages, false, true, 0);
596 assert_eq!(result, WakeAction::RunTurn);
597 }
598
599 #[test]
600 fn wake_forward_when_busy() {
601 let drained = vec![injected_event()];
602 let messages = vec![user_msg("hello")];
603 let result = wake_action(&drained, &messages, true, true, 0);
604 assert_eq!(result, WakeAction::Forward);
605 }
606
607 #[test]
608 fn wake_forward_when_auto_turn_disabled() {
609 let drained = vec![injected_event()];
610 let messages = vec![user_msg("hello")];
611 let result = wake_action(&drained, &messages, false, false, 0);
612 assert_eq!(result, WakeAction::Forward);
613 }
614
615 #[test]
616 fn wake_forward_when_last_message_not_user() {
617 let drained = vec![injected_event()];
618 let messages = vec![assistant_msg()];
619 let result = wake_action(&drained, &messages, false, true, 0);
620 assert_eq!(result, WakeAction::Forward);
621 }
622
623 #[test]
624 fn wake_forward_when_messages_empty() {
625 let drained = vec![injected_event()];
626 let result = wake_action(&drained, &[], false, true, 0);
627 assert_eq!(result, WakeAction::Forward);
628 }
629
630 #[test]
631 fn wake_forward_when_cap_reached() {
632 let drained = vec![injected_event()];
633 let messages = vec![user_msg("hello")];
634 let result = wake_action(&drained, &messages, false, true, AUTO_TURN_CAP);
636 assert_eq!(result, WakeAction::Forward);
637 }
638
639 #[test]
640 fn wake_run_turn_when_one_below_cap() {
641 let drained = vec![injected_event()];
642 let messages = vec![user_msg("hello")];
643 let result = wake_action(&drained, &messages, false, true, AUTO_TURN_CAP - 1);
644 assert_eq!(result, WakeAction::RunTurn);
645 }
646
647 #[test]
648 fn wake_forward_when_only_steered_events() {
649 let drained = vec![steered_event()];
651 let messages = vec![user_msg("hello")];
652 let result = wake_action(&drained, &messages, false, true, 0);
653 assert_eq!(result, WakeAction::Forward);
654 }
655
656 #[test]
657 fn wake_forward_when_only_buffered_events() {
658 let drained = vec![buffered_event()];
659 let messages = vec![user_msg("hello")];
660 let result = wake_action(&drained, &messages, false, true, 0);
661 assert_eq!(result, WakeAction::Forward);
662 }
663
664 #[test]
665 fn wake_run_turn_with_mixed_injected_and_steered() {
666 let drained = vec![steered_event(), injected_event()];
668 let messages = vec![user_msg("hello")];
669 let result = wake_action(&drained, &messages, false, true, 0);
670 assert_eq!(result, WakeAction::RunTurn);
671 }
672
673 #[test]
676 fn claim_auto_turn_first_five_allowed() {
677 let mut counter: u32 = 0;
678 for _ in 0..AUTO_TURN_CAP {
679 assert!(
680 claim_auto_turn(&mut counter),
681 "turn within cap must be allowed"
682 );
683 }
684 assert_eq!(
685 counter, AUTO_TURN_CAP,
686 "counter must equal cap after 5 claims"
687 );
688 }
689
690 #[test]
691 fn claim_auto_turn_sixth_denied() {
692 let mut counter: u32 = AUTO_TURN_CAP;
693 assert!(!claim_auto_turn(&mut counter), "turn at cap must be denied");
694 assert_eq!(
695 counter, AUTO_TURN_CAP,
696 "counter must remain unchanged when denied"
697 );
698 }
699
700 #[test]
701 fn claim_auto_turn_remains_denied_until_reset() {
702 let mut counter: u32 = AUTO_TURN_CAP;
703 assert!(!claim_auto_turn(&mut counter));
704 assert!(!claim_auto_turn(&mut counter));
705 assert!(!claim_auto_turn(&mut counter));
706 assert_eq!(
707 counter, AUTO_TURN_CAP,
708 "counter must not change while denied"
709 );
710 }
711
712 #[test]
713 fn claim_auto_turn_reset_re_enables() {
714 let mut counter: u32 = AUTO_TURN_CAP;
715 assert!(!claim_auto_turn(&mut counter));
716 counter = 0;
718 assert!(
719 claim_auto_turn(&mut counter),
720 "after reset first turn must be allowed"
721 );
722 assert_eq!(counter, 1);
723 }
724
725 #[test]
726 fn claim_auto_turn_exact_boundary_sequence() {
727 let mut c: u32 = 0;
729 for i in 1..=AUTO_TURN_CAP {
730 assert!(claim_auto_turn(&mut c), "turn {i} must be allowed");
731 assert_eq!(c, i);
732 }
733 assert!(!claim_auto_turn(&mut c));
735 assert_eq!(c, AUTO_TURN_CAP);
736 }
737
738 #[test]
741 fn cap_reached_zero_is_unlimited() {
742 assert!(!auto_turn_cap_reached(0, AUTO_TURN_UNLIMITED));
743 assert!(!auto_turn_cap_reached(u32::MAX, AUTO_TURN_UNLIMITED));
744 assert!(auto_turn_cap_reached(5, 5));
745 assert!(!auto_turn_cap_reached(4, 5));
746 }
747
748 #[test]
749 fn claim_with_cap_zero_never_trips_across_100_turns() {
750 let mut counter: u32 = 0;
751 for i in 1..=100u32 {
752 assert!(
753 claim_auto_turn_with_cap(&mut counter, AUTO_TURN_UNLIMITED),
754 "unlimited: turn {i} must be allowed"
755 );
756 }
757 assert_eq!(counter, 100);
758 }
759
760 #[test]
761 fn claim_with_cap_two_trips_on_third() {
762 let mut counter: u32 = 0;
763 assert!(claim_auto_turn_with_cap(&mut counter, 2));
764 assert!(claim_auto_turn_with_cap(&mut counter, 2));
765 assert!(!claim_auto_turn_with_cap(&mut counter, 2), "3rd must be denied");
766 assert_eq!(counter, 2);
767 }
768
769 #[test]
770 fn claim_with_cap_unlimited_saturates_instead_of_overflowing() {
771 let mut counter: u32 = u32::MAX;
772 assert!(claim_auto_turn_with_cap(&mut counter, AUTO_TURN_UNLIMITED));
773 assert_eq!(counter, u32::MAX);
774 }
775
776 #[test]
777 fn wake_with_cap_zero_never_parks_across_100_turns() {
778 let drained = vec![injected_event()];
779 let messages = vec![user_msg("ev")];
780 for consecutive in 0..100u32 {
781 assert_eq!(
782 wake_action_with_cap(&drained, &messages, false, true, consecutive, 0),
783 WakeAction::RunTurn,
784 "unlimited: consecutive={consecutive} must still RunTurn"
785 );
786 }
787 }
788
789 #[test]
790 fn wake_with_cap_two_forwards_on_third() {
791 let drained = vec![injected_event()];
792 let messages = vec![user_msg("ev")];
793 assert_eq!(
794 wake_action_with_cap(&drained, &messages, false, true, 1, 2),
795 WakeAction::RunTurn
796 );
797 assert_eq!(
798 wake_action_with_cap(&drained, &messages, false, true, 2, 2),
799 WakeAction::Forward
800 );
801 }
802
803 #[test]
804 fn wake_default_wrapper_matches_default_cap() {
805 let drained = vec![injected_event()];
806 let messages = vec![user_msg("ev")];
807 assert_eq!(
808 wake_action(&drained, &messages, false, true, AUTO_TURN_CAP),
809 wake_action_with_cap(&drained, &messages, false, true, AUTO_TURN_CAP, AUTO_TURN_CAP)
810 );
811 }
812
813 #[test]
814 fn terminal_flush_seam_with_cap_zero_reserves_at_high_counter() {
815 let mut pending = false;
816 let mut events = vec!["<event>x</event>".to_string()];
817 let mut msgs: Vec<SharedMessage> = Vec::new();
818 let mut counter: u32 = 1_000;
819 let r = terminal_flush_seam_with_cap(
820 true, &mut pending, &mut events, &mut msgs, true, &mut counter, 0,
821 );
822 assert_eq!(r.as_deref(), Some("auto:seam"));
823 assert!(pending);
824 assert_eq!(counter, 1_001);
825 }
826
827 #[test]
828 fn describe_cap_renders_unlimited_and_numbers() {
829 assert_eq!(describe_auto_turn_cap(0), "unlimited");
830 assert_eq!(describe_auto_turn_cap(12), "12");
831 }
832
833}
834
835#[cfg(test)]
836mod disclosure_persistence_tests {
837 use super::*;
838 use crate::events::types::{Event, Severity};
839 use agent_core::disclosure::DisclosureClass;
840 use serial_test::serial;
841
842 use crate::test_env::BaseDirGuard;
843
844 const SENTINEL: &str = "PERSIST-BOUNDARY-SENTINEL-5e3b";
845
846 fn classed_event(class: Option<DisclosureClass>) -> Event {
847 let mut ev = Event::simple("kuma", SENTINEL, Some(Severity::High));
848 ev.content.content_type = "message".into();
849 ev.content.disclosure = class;
850 ev
851 }
852
853 fn drain_one(
854 class: Option<DisclosureClass>,
855 busy: bool,
856 steer: bool,
857 ) -> (
858 Vec<crate::SharedMessage>,
859 Vec<String>,
860 Vec<DrainedEvent>,
861 Option<tokio::sync::mpsc::UnboundedReceiver<String>>,
862 ) {
863 let queue = EventQueue::new(16);
864 queue.push(classed_event(class)).unwrap();
865 let mut messages = Vec::new();
866 let mut pending = Vec::new();
867 let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
868 let steer_tx = if steer { Some(&tx) } else { None };
869 let drained = drain_event_queue(&queue, &mut messages, &mut pending, busy, steer_tx);
870 (messages, pending, drained, Some(rx))
871 }
872
873 #[tokio::test]
878 #[serial(synaps_base_dir)]
879 async fn all_disclosure_classes_are_honored_through_real_session_persistence() {
880 let _base = BaseDirGuard::new();
881
882 for class in [None, Some(DisclosureClass::ModelVisible)] {
884 let (messages, _, drained, _) = drain_one(class, false, false);
885 assert_eq!(drained[0].disposition, EventDisposition::Injected);
886 let mut session = agent_core::session::Session::new("claude-sonnet-4-6", "high", None);
887 session.api_messages = messages;
888 session.save().await.unwrap();
889 let raw = std::fs::read_to_string(
890 agent_core::config::get_active_config_dir()
891 .join("sessions")
892 .join(format!("{}.json", session.id)),
893 )
894 .unwrap();
895 assert!(raw.contains(SENTINEL), "{class:?} persists content");
896 }
897
898 for class in [
900 DisclosureClass::LocalOnly,
901 DisclosureClass::ModelVisibleAfterRedaction,
902 DisclosureClass::ModelVisibleAfterConsent,
903 DisclosureClass::PersistNeverTransmit,
904 ] {
905 let (messages, _, drained, _) = drain_one(Some(class), false, false);
906 assert_eq!(drained[0].disposition, EventDisposition::Injected);
907 let mut session = agent_core::session::Session::new("claude-sonnet-4-6", "high", None);
908 session.api_messages = messages;
909 session.save().await.unwrap();
910 let raw = std::fs::read_to_string(
911 agent_core::config::get_active_config_dir()
912 .join("sessions")
913 .join(format!("{}.json", session.id)),
914 )
915 .unwrap();
916 assert!(
917 !raw.contains(SENTINEL),
918 "{class:?}: withheld content must never persist"
919 );
920 assert!(
921 raw.contains("content withheld"),
922 "{class:?}: marker persists"
923 );
924 }
925
926 let (messages, pending, drained, _) =
929 drain_one(Some(DisclosureClass::NeverPersist), false, false);
930 assert_eq!(
931 drained[0].disposition,
932 EventDisposition::DisplayOnly,
933 "idle: never_persist must not be injected"
934 );
935 assert!(messages.is_empty());
936 assert!(pending.is_empty());
937 assert!(
938 drained[0].formatted.contains(SENTINEL),
939 "never_persist stays locally displayable"
940 );
941
942 let (messages, pending, drained, rx) =
943 drain_one(Some(DisclosureClass::NeverPersist), true, true);
944 assert_eq!(drained[0].disposition, EventDisposition::DisplayOnly);
945 assert!(messages.is_empty() && pending.is_empty());
946 let mut rx = rx.unwrap();
947 assert!(
948 rx.try_recv().is_err(),
949 "never_persist must not be steered into a live stream (streams \
950 persist into history)"
951 );
952 }
953
954 #[test]
957 fn wire_payload_retains_typed_disclosure_metadata() {
958 let queue = EventQueue::new(16);
959 queue
960 .push(classed_event(Some(DisclosureClass::NeverPersist)))
961 .unwrap();
962 let mut messages = Vec::new();
963 let mut pending = Vec::new();
964 let drained = drain_event_queue(&queue, &mut messages, &mut pending, false, None);
965 let payload = event_payload_from_drained(&drained[0]);
966 assert_eq!(payload.disclosure.as_deref(), Some("never_persist"));
967
968 let queue = EventQueue::new(16);
969 queue.push(classed_event(None)).unwrap();
970 let drained = drain_event_queue(&queue, &mut messages, &mut pending, false, None);
971 let payload = event_payload_from_drained(&drained[0]);
972 assert_eq!(payload.disclosure, None, "absent class stays absent");
973 }
974}