bamboo_engine/runtime/execution/
event_forwarder.rs1use std::collections::HashMap;
8use std::sync::Arc;
9
10use chrono::Utc;
11use tokio::sync::{broadcast, mpsc, RwLock};
12
13use bamboo_agent_core::AgentEvent;
14
15use super::runner_state::AgentRunner;
16
17pub type AccountFeedInbox = mpsc::Sender<(Option<String>, AgentEvent)>;
22
23fn mirror_to_account_feed(inbox: &Option<AccountFeedInbox>, session_id: &str, event: &AgentEvent) {
29 if let Some(inbox) = inbox {
30 if event.is_durable_change() {
31 let route_session_id = event.session_id().unwrap_or(session_id);
32 let _ = inbox.try_send((Some(route_session_id.to_string()), event.clone()));
33 }
34 }
35}
36
37#[cfg(test)]
38#[allow(clippy::items_after_test_module)]
39mod tests {
40 use super::*;
41
42 #[tokio::test]
43 async fn child_approval_change_routes_to_parent_account_envelope() {
44 let (tx, mut rx) = mpsc::channel(4);
45 let event = AgentEvent::ChildApprovalChanged {
46 parent_session_id: "parent-1".into(),
47 child_session_id: "child-1".into(),
48 child_attempt: 1,
49 request_id: "req-1".into(),
50 version: 2,
51 status: "approved".into(),
52 reason: None,
53 tool_name: "Bash".into(),
54 permission: "execute".into(),
55 resource: "/tmp/x".into(),
56 created_at: "2026-01-01T00:00:00Z".into(),
57 resolved_at: Some("2026-01-01T00:00:01Z".into()),
58 };
59
60 mirror_to_account_feed(&Some(tx), "child-1", &event);
61 let (session_id, mirrored) = rx.recv().await.unwrap();
62 assert_eq!(session_id.as_deref(), Some("parent-1"));
63 assert!(matches!(mirrored, AgentEvent::ChildApprovalChanged { .. }));
64 }
65
66 #[tokio::test]
67 async fn delayed_old_forwarder_cannot_publish_after_successor_reservation() {
68 let session_id = "session-generation";
69 let (broadcast_tx, mut broadcast_rx) = broadcast::channel(16);
70 let mut successor = AgentRunner::new();
71 successor.run_id = "run-new".to_string();
72 successor.status = super::super::runner_state::AgentStatus::Running;
73 successor.event_sender = broadcast_tx.clone();
74 let runners = Arc::new(RwLock::new(HashMap::from([(
75 session_id.to_string(),
76 successor,
77 )])));
78
79 broadcast_tx
82 .send(AgentEvent::ExecutionStarted {
83 run_id: "run-new".to_string(),
84 session_id: session_id.to_string(),
85 started_at: Utc::now().to_rfc3339(),
86 })
87 .unwrap();
88 let (old_tx, old_forwarder) = create_event_forwarder(
89 session_id.to_string(),
90 "run-old".to_string(),
91 broadcast_tx.clone(),
92 runners,
93 None,
94 );
95 let _ = old_tx
96 .send(AgentEvent::NeedClarification {
97 question: "stale".to_string(),
98 options: Some(vec!["A".to_string()]),
99 tool_call_id: Some("old-tool".to_string()),
100 tool_name: Some("ConclusionWithOptions".to_string()),
101 allow_custom: false,
102 source: Some(bamboo_agent_core::PendingQuestionSource::PauseTool),
103 })
104 .await;
105 drop(old_tx);
106 old_forwarder.await.unwrap();
107
108 assert!(matches!(
109 broadcast_rx.recv().await.unwrap(),
110 AgentEvent::ExecutionStarted { ref run_id, .. } if run_id == "run-new"
111 ));
112 assert!(
113 tokio::time::timeout(std::time::Duration::from_millis(50), broadcast_rx.recv())
114 .await
115 .is_err(),
116 "old Started/Need/Complete must all be suppressed"
117 );
118 }
119}
120
121pub fn create_event_forwarder(
130 session_id: String,
131 run_id: String,
132 broadcast_tx: broadcast::Sender<AgentEvent>,
133 runners: Arc<RwLock<HashMap<String, AgentRunner>>>,
134 account_feed_inbox: Option<AccountFeedInbox>,
135) -> (mpsc::Sender<AgentEvent>, tokio::task::JoinHandle<()>) {
136 let (mpsc_tx, mut mpsc_rx) = mpsc::channel::<AgentEvent>(100);
137
138 let forwarder = tokio::spawn(async move {
139 let started_event = AgentEvent::ExecutionStarted {
144 run_id: run_id.clone(),
145 session_id: session_id.clone(),
146 started_at: Utc::now().to_rfc3339(),
147 };
148 {
149 let runners = runners.read().await;
150 if runners
151 .get(&session_id)
152 .is_none_or(|runner| runner.run_id != run_id)
153 {
154 return;
155 }
156 mirror_to_account_feed(&account_feed_inbox, &session_id, &started_event);
157 let _ = broadcast_tx.send(started_event);
158 }
159
160 while let Some(event) = mpsc_rx.recv().await {
161 let mut runners = runners.write().await;
162 let Some(runner) = runners
163 .get_mut(&session_id)
164 .filter(|runner| runner.run_id == run_id)
165 else {
166 return;
171 };
172 runner.last_event_at = Some(Utc::now());
173
174 if event.is_replayable_session_state() {
180 runner.push_critical_event(event.clone());
181 }
182
183 match &event {
184 AgentEvent::TokenBudgetUpdated { .. } => {
185 runner.last_budget_event = Some(event.clone());
186 }
187 AgentEvent::ToolStart { tool_name, .. } => {
188 runner.last_tool_name = Some(tool_name.clone());
189 runner.last_tool_phase = Some("begin".to_string());
190 }
191 AgentEvent::ToolLifecycle {
192 tool_name, phase, ..
193 } => {
194 runner.last_tool_name = Some(tool_name.clone());
195 runner.last_tool_phase = Some(phase.clone());
196 }
197 AgentEvent::RunnerProgress { round_count, .. } => {
198 runner.round_count = *round_count;
199 }
200 _ => {}
201 }
202 mirror_to_account_feed(&account_feed_inbox, &session_id, &event);
203 let _ = broadcast_tx.send(event);
204 }
205 });
206
207 (mpsc_tx, forwarder)
208}