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;
21
22#[derive(Debug, Clone, PartialEq, Eq)]
26pub enum EventDisposition {
27 Steered,
29 Buffered,
31 Injected,
33 DisplayOnly,
37}
38
39#[derive(Debug, Clone)]
42pub struct DrainedEvent {
43 pub event: crate::events::types::Event,
44 pub formatted: String,
45 pub disposition: EventDisposition,
46}
47
48#[derive(Debug, Clone, PartialEq, Eq)]
50pub enum WakeAction {
51 RunTurn,
53 Forward,
56 Nothing,
58}
59
60pub 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 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 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 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
127pub 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
164pub 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#[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#[inline]
234pub fn idle_should_wait(children_running: bool, queue_len: usize) -> bool {
235 children_running || queue_len > 0
236}
237
238pub 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
284pub 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 return !*auto_turn_pending && !in_flight_live;
310 }
311 if *auto_turn_pending && !in_flight_live {
312 return true;
313 }
314 *auto_turn_pending = false;
316 false
317}
318
319#[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 #[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 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 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 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 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 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 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 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 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 #[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 #[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 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 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 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 #[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 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 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 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 #[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}