1use async_trait::async_trait;
8use std::sync::{
9 atomic::{AtomicUsize, Ordering},
10 Arc,
11};
12use tokio::sync::{mpsc, oneshot, Semaphore};
13use tokio_util::sync::CancellationToken;
14
15use crate::proto::{
16 RunSpec, SessionMessageAdmissionConfirmation, SessionMessageDelivery, TerminalStatus,
17};
18
19#[derive(Debug, Clone, PartialEq, Eq)]
21pub enum ExecutorControl {
22 SessionMessageAdmitted(SessionMessageAdmissionConfirmation),
23 InitialInputReleaseRequest(crate::proto::InitialInputReleaseRequest),
24}
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum HostRequestKind {
29 Approval,
31 OwnedTree,
33 SubAgent,
35}
36
37pub struct HostRequest {
40 pub kind: HostRequestKind,
41 pub body: serde_json::Value,
42 pub reply: oneshot::Sender<serde_json::Value>,
43}
44
45#[derive(Clone)]
49pub struct HostBridge {
50 req_tx: mpsc::UnboundedSender<HostRequest>,
51 tree_permits: Arc<Semaphore>,
52 tree_calls: Arc<AtomicUsize>,
53 subagent_permits: Arc<Semaphore>,
54 subagent_calls: Arc<AtomicUsize>,
55}
56
57impl HostBridge {
58 pub fn channel() -> (Self, mpsc::UnboundedReceiver<HostRequest>) {
60 let (req_tx, req_rx) = mpsc::unbounded_channel();
61 (
62 HostBridge {
63 req_tx,
64 tree_permits: Arc::new(Semaphore::new(1)),
65 tree_calls: Arc::new(AtomicUsize::new(0)),
66 subagent_permits: Arc::new(Semaphore::new(1)),
67 subagent_calls: Arc::new(AtomicUsize::new(0)),
68 },
69 req_rx,
70 )
71 }
72
73 pub async fn approval_call(
77 &self,
78 body: serde_json::Value,
79 ) -> Result<serde_json::Value, String> {
80 self.call(HostRequestKind::Approval, body).await
81 }
82
83 pub async fn owned_tree_call(&self, cursor: Option<&str>) -> Result<serde_json::Value, String> {
86 const MAX_CURSOR_BYTES: usize = 128;
87 const MAX_RESULT_BYTES: usize = 8192;
88 const MAX_CALLS_PER_RUN: usize = 16;
89 const TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
90 if cursor.is_some_and(|cursor| cursor.is_empty() || cursor.len() > MAX_CURSOR_BYTES) {
91 return Err("invalid owned tree cursor".into());
92 }
93 let _permit = tokio::time::timeout(TIMEOUT, self.tree_permits.acquire())
94 .await
95 .map_err(|_| "owned tree request timed out")?
96 .map_err(|_| "owned tree inspection unavailable")?;
97 if self.tree_calls.fetch_add(1, Ordering::AcqRel) >= MAX_CALLS_PER_RUN {
98 return Err("owned tree request limit reached".into());
99 }
100 let reply = tokio::time::timeout(
101 TIMEOUT,
102 self.call(
103 HostRequestKind::OwnedTree,
104 serde_json::json!({"cursor":cursor}),
105 ),
106 )
107 .await
108 .map_err(|_| "owned tree request timed out")??;
109 let page = reply
110 .get("page")
111 .filter(|page| !page.is_null())
112 .ok_or_else(|| "owned tree inspection rejected".to_string())?;
113 if serde_json::to_vec(page).map_or(true, |bytes| bytes.len() > MAX_RESULT_BYTES) {
114 return Err("owned tree observation exceeds its limit".into());
115 }
116 Ok(page.clone())
117 }
118
119 pub async fn subagent_call(
122 &self,
123 args: serde_json::Value,
124 tool_call_id: &str,
125 ) -> Result<serde_json::Value, String> {
126 const MAX_ARGS_BYTES: usize = 64 * 1024;
127 const MAX_REPLY_BYTES: usize = 16 * 1024;
128 const MAX_CALLS_PER_RUN: usize = 64;
129 const TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
130 if tool_call_id.is_empty()
131 || tool_call_id.len() > 128
132 || serde_json::to_vec(&args).map_or(true, |bytes| bytes.len() > MAX_ARGS_BYTES)
133 {
134 return Err("invalid canonical SubAgent request".into());
135 }
136 let _permit = tokio::time::timeout(TIMEOUT, self.subagent_permits.acquire())
137 .await
138 .map_err(|_| "canonical SubAgent request timed out")?
139 .map_err(|_| "canonical SubAgent unavailable")?;
140 if self.subagent_calls.fetch_add(1, Ordering::AcqRel) >= MAX_CALLS_PER_RUN {
141 return Err("canonical SubAgent request limit reached".into());
142 }
143 let reply = tokio::time::timeout(
144 TIMEOUT,
145 self.call(
146 HostRequestKind::SubAgent,
147 serde_json::json!({"args":args,"tool_call_id":tool_call_id}),
148 ),
149 )
150 .await
151 .map_err(|_| "canonical SubAgent request timed out")??;
152 if serde_json::to_vec(&reply).map_or(true, |bytes| bytes.len() > MAX_REPLY_BYTES) {
153 return Err("canonical SubAgent result exceeds its limit".into());
154 }
155 if let Some(error) = reply.get("error").and_then(serde_json::Value::as_str) {
156 return Err(error.to_owned());
157 }
158 reply
159 .get("result")
160 .cloned()
161 .ok_or_else(|| "canonical SubAgent operation rejected".into())
162 }
163
164 async fn call(
165 &self,
166 kind: HostRequestKind,
167 body: serde_json::Value,
168 ) -> Result<serde_json::Value, String> {
169 let (reply, rx) = oneshot::channel();
170 self.req_tx
171 .send(HostRequest { kind, body, reply })
172 .map_err(|_| "host bridge closed".to_string())?;
173 rx.await
174 .map_err(|_| "host bridge dropped reply".to_string())
175 }
176}
177
178#[derive(Clone)]
180pub struct EventSink {
181 tx: mpsc::Sender<serde_json::Value>,
182 control_tx: Option<mpsc::Sender<ExecutorControl>>,
183 host: Option<HostBridge>,
184}
185
186impl EventSink {
187 pub const EVENT_CAPACITY: usize = 256;
188 pub const CONTROL_CAPACITY: usize = 32;
189
190 pub fn channel() -> (Self, mpsc::Receiver<serde_json::Value>) {
192 let (tx, rx) = mpsc::channel(Self::EVENT_CAPACITY);
193 (
194 EventSink {
195 tx,
196 control_tx: None,
197 host: None,
198 },
199 rx,
200 )
201 }
202 pub fn channel_with_control() -> (
204 Self,
205 mpsc::Receiver<serde_json::Value>,
206 mpsc::Receiver<ExecutorControl>,
207 ) {
208 let (tx, rx) = mpsc::channel(Self::EVENT_CAPACITY);
209 let (control_tx, control_rx) = mpsc::channel(Self::CONTROL_CAPACITY);
210 (
211 EventSink {
212 tx,
213 control_tx: Some(control_tx),
214 host: None,
215 },
216 rx,
217 control_rx,
218 )
219 }
220 pub fn with_host_bridge(mut self, bridge: HostBridge) -> Self {
222 self.host = Some(bridge);
223 self
224 }
225 pub fn host(&self) -> Option<&HostBridge> {
227 self.host.as_ref()
228 }
229 pub async fn emit(&self, event: serde_json::Value) {
233 let _ = self.tx.send(event).await;
234 }
235 pub async fn request_initial_release(
236 &self,
237 request: crate::proto::InitialInputReleaseRequest,
238 ) -> Result<(), String> {
239 self.control_tx
240 .as_ref()
241 .ok_or("initial release channel missing")?
242 .send(ExecutorControl::InitialInputReleaseRequest(request))
243 .await
244 .map_err(|_| "initial release channel closed".into())
245 }
246 pub async fn confirm_session_message(&self, confirmation: SessionMessageAdmissionConfirmation) {
249 if let Some(tx) = &self.control_tx {
250 let _ = tx
251 .send(ExecutorControl::SessionMessageAdmitted(confirmation))
252 .await;
253 }
254 }
255}
256
257#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
259pub struct ChildOutcome {
260 pub status: TerminalStatus,
261 #[serde(default, skip_serializing_if = "Option::is_none")]
263 pub final_event_watermark: Option<crate::proto::ActorEventWatermark>,
264 pub result: Option<String>,
265 pub error: Option<String>,
266 #[serde(default)]
272 pub transcript: Vec<serde_json::Value>,
273}
274
275impl ChildOutcome {
276 pub fn completed(result: impl Into<String>) -> Self {
277 Self {
278 status: TerminalStatus::Completed,
279 final_event_watermark: None,
280 result: Some(result.into()),
281 error: None,
282 transcript: Vec::new(),
283 }
284 }
285 pub fn error(msg: impl Into<String>) -> Self {
286 Self {
287 status: TerminalStatus::Error,
288 final_event_watermark: None,
289 result: None,
290 error: Some(msg.into()),
291 transcript: Vec::new(),
292 }
293 }
294 pub fn cancelled() -> Self {
295 Self {
296 status: TerminalStatus::Cancelled,
297 final_event_watermark: None,
298 result: None,
299 error: None,
300 transcript: Vec::new(),
301 }
302 }
303 pub fn suspended(transcript: Vec<serde_json::Value>) -> Self {
306 Self {
307 status: TerminalStatus::Suspended,
308 final_event_watermark: None,
309 result: None,
310 error: None,
311 transcript,
312 }
313 }
314}
315
316pub struct SteerInbox {
320 rx: mpsc::UnboundedReceiver<SteerMessage>,
321 accepted_initial_releases: Vec<crate::proto::InitialInputRelease>,
324}
325
326#[derive(Debug, Clone, PartialEq)]
327pub enum SteerMessage {
328 Text(String),
331 DurableText {
334 message_id: String,
335 text: String,
336 },
337 SessionMessage(Box<SessionMessageDelivery>),
338 InitialInputRelease(crate::proto::InitialInputRelease),
339}
340
341impl From<String> for SteerMessage {
342 fn from(value: String) -> Self {
343 Self::Text(value)
344 }
345}
346
347impl From<&str> for SteerMessage {
348 fn from(value: &str) -> Self {
349 Self::Text(value.to_string())
350 }
351}
352
353impl SteerInbox {
354 pub fn channel() -> (mpsc::UnboundedSender<SteerMessage>, Self) {
356 let (tx, rx) = mpsc::unbounded_channel();
357 (
358 tx,
359 SteerInbox {
360 rx,
361 accepted_initial_releases: Vec::new(),
362 },
363 )
364 }
365 pub fn disconnected() -> Self {
367 let (_tx, rx) = mpsc::unbounded_channel();
368 SteerInbox {
369 rx,
370 accepted_initial_releases: Vec::new(),
371 }
372 }
373 pub async fn recv(&mut self) -> Option<String> {
375 match self.recv_message().await? {
376 SteerMessage::Text(text) => Some(text),
377 SteerMessage::DurableText { text, .. } => Some(text),
378 SteerMessage::SessionMessage(delivery) => serde_json::to_string(&delivery).ok(),
379 SteerMessage::InitialInputRelease(_) => None, }
381 }
382 pub async fn wait_initial_release(
385 &mut self,
386 request: &crate::proto::InitialInputReleaseRequest,
387 cancel: &CancellationToken,
388 events: &EventSink,
389 ) -> Result<chrono::DateTime<chrono::Utc>, String> {
390 self.wait_initial_release_bounded(
391 request,
392 cancel,
393 events,
394 std::time::Duration::from_secs(60),
395 )
396 .await
397 }
398 async fn wait_initial_release_bounded(
399 &mut self,
400 request: &crate::proto::InitialInputReleaseRequest,
401 cancel: &CancellationToken,
402 events: &EventSink,
403 wait: std::time::Duration,
404 ) -> Result<chrono::DateTime<chrono::Utc>, String> {
405 let timeout = tokio::time::sleep(wait);
406 tokio::pin!(timeout);
407 let mut retry = tokio::time::interval(std::time::Duration::from_secs(5));
408 retry.tick().await;
409 loop {
410 tokio::select! {
411 biased;
412 _ = cancel.cancelled() => return Err("initial release cancelled".into()),
413 _ = &mut timeout => return Err("initial release timed out".into()),
414 message = self.recv_message() => return match message {
415 Some(SteerMessage::InitialInputRelease(release)) if release.permits(request, chrono::Utc::now()) => {
416 if self.accepted_initial_releases.iter().any(|accepted| accepted.request == release.request) {
417 return Err("initial release missing, expired or mismatched".into());
418 }
419 if self.accepted_initial_releases.len() >= bamboo_domain::SessionInboxLimits::default().max_claim_batch {
420 return Err("initial release completed batch limit exceeded".into());
421 }
422 let deadline = release.expires_at;
423 self.accepted_initial_releases.push(release);
424 Ok(deadline)
425 },
426 _ => Err("initial release missing, expired or mismatched".into()),
427 },
428 _ = retry.tick() => events.request_initial_release(request.clone()).await?,
429 }
430 }
431 }
432 pub async fn recv_message(&mut self) -> Option<SteerMessage> {
435 loop {
436 let message = self.rx.recv().await?;
437 if let SteerMessage::InitialInputRelease(release) = &message {
438 if chrono::Utc::now() < release.expires_at
441 && self.accepted_initial_releases.contains(release)
442 {
443 continue;
444 }
445 }
446 return Some(message);
447 }
448 }
449}
450
451#[async_trait]
453pub trait ChildExecutor: Send + Sync + 'static {
454 fn requires_contiguous_events(&self) -> bool {
458 false
459 }
460
461 fn supports_environment_lease_v1(&self) -> bool {
465 false
466 }
467 fn max_parallel_executions(&self) -> usize {
473 1
474 }
475
476 async fn run(
477 &self,
478 spec: RunSpec,
479 events: EventSink,
480 steer: SteerInbox,
481 cancel: CancellationToken,
482 ) -> ChildOutcome;
483}
484
485pub struct EchoExecutor;
492
493pub const ECHO_SLEEP_PREFIX: &str = "__sleep_ms:";
495
496#[async_trait]
497impl ChildExecutor for EchoExecutor {
498 fn max_parallel_executions(&self) -> usize {
499 256
502 }
503
504 async fn run(
505 &self,
506 spec: RunSpec,
507 events: EventSink,
508 _steer: SteerInbox,
509 cancel: CancellationToken,
510 ) -> ChildOutcome {
511 let mut sleep_ms: Option<u64> = None;
515 let mut words: Vec<&str> = Vec::new();
516 for word in spec.assignment.split_whitespace() {
517 match word
518 .strip_prefix(ECHO_SLEEP_PREFIX)
519 .and_then(|n| n.parse::<u64>().ok())
520 {
521 Some(ms) if sleep_ms.is_none() => sleep_ms = Some(ms),
522 _ => words.push(word),
523 }
524 }
525 if let Some(ms) = sleep_ms {
526 tokio::select! {
527 _ = tokio::time::sleep(std::time::Duration::from_millis(ms)) => {}
528 _ = cancel.cancelled() => return ChildOutcome::cancelled(),
529 }
530 }
531
532 for word in &words {
533 if cancel.is_cancelled() {
534 return ChildOutcome::cancelled();
535 }
536 events
537 .emit(serde_json::json!({ "type": "token", "content": format!("{word} ") }))
538 .await;
539 tokio::task::yield_now().await;
541 }
542 events.emit(serde_json::json!({ "type": "complete" })).await;
543 ChildOutcome::completed(format!("echo: {}", words.join(" ")))
544 }
545}
546
547#[cfg(test)]
548mod tests {
549 use super::*;
550
551 fn duplicate_release_request(slot: u64) -> crate::proto::InitialInputReleaseRequest {
552 crate::proto::InitialInputReleaseRequest {
553 version: 1,
554 nonce: uuid::Uuid::new_v4().to_string(),
555 child_id: "duplicate-child".into(),
556 parent_id: "parent".into(),
557 root_id: "parent".into(),
558 created_at: chrono::Utc::now(),
559 spawn_depth: 1,
560 project_id: None,
561 envelope_id: format!("input-{slot}"),
562 generation: slot + 1,
563 activation_run_id: "same-physical-run".into(),
564 execution_epoch: 1,
565 }
566 }
567
568 #[tokio::test(start_paused = true)]
571 async fn initial_release_duplicate_retry_between_batch_items_is_harmless() {
572 let first = duplicate_release_request(0);
573 let second = duplicate_release_request(1);
574 let first_release = crate::proto::InitialInputRelease {
575 request: first.clone(),
576 expires_at: chrono::Utc::now() + chrono::Duration::seconds(30),
577 };
578 let second_release = crate::proto::InitialInputRelease {
579 request: second.clone(),
580 expires_at: first_release.expires_at - chrono::Duration::seconds(1),
581 };
582 let (events, _events, mut controls) = EventSink::channel_with_control();
583 let (sender, mut inbox) = SteerInbox::channel();
584 let cancel = CancellationToken::new();
585 let mut waiting = Box::pin(inbox.wait_initial_release(&first, &cancel, &events));
586 assert!(futures_util::poll!(waiting.as_mut()).is_pending());
587 tokio::time::advance(std::time::Duration::from_secs(5)).await;
588 assert!(futures_util::poll!(waiting.as_mut()).is_pending());
589 assert_eq!(
590 controls.try_recv().unwrap(),
591 ExecutorControl::InitialInputReleaseRequest(first.clone())
592 );
593 sender
594 .send(SteerMessage::InitialInputRelease(first_release.clone()))
595 .unwrap();
596 assert_eq!(waiting.await.unwrap(), first_release.expires_at);
597 sender
600 .send(SteerMessage::InitialInputRelease(first_release.clone()))
601 .unwrap();
602 sender
603 .send(SteerMessage::InitialInputRelease(second_release.clone()))
604 .unwrap();
605 assert_eq!(
606 inbox
607 .wait_initial_release(&second, &cancel, &events)
608 .await
609 .unwrap(),
610 second_release.expires_at
611 );
612 sender
615 .send(SteerMessage::InitialInputRelease(first_release))
616 .unwrap();
617 sender
618 .send(SteerMessage::InitialInputRelease(second_release))
619 .unwrap();
620 sender
621 .send(SteerMessage::Text("next ordinary steer".into()))
622 .unwrap();
623 assert_eq!(
624 inbox.recv_message().await,
625 Some(SteerMessage::Text("next ordinary steer".into()))
626 );
627 }
628
629 #[tokio::test]
630 async fn initial_release_changed_prior_reply_cannot_be_filtered_or_release_next_input() {
631 for changed in [
632 "nonce",
633 "epoch",
634 "birth",
635 "envelope",
636 "generation",
637 "deadline",
638 "expired",
639 ] {
640 let first = duplicate_release_request(0);
641 let second = duplicate_release_request(1);
642 let original = crate::proto::InitialInputRelease {
643 request: first.clone(),
644 expires_at: chrono::Utc::now() + chrono::Duration::seconds(30),
645 };
646 let (events, _events, _controls) = EventSink::channel_with_control();
647 let (sender, mut inbox) = SteerInbox::channel();
648 let cancel = CancellationToken::new();
649 sender
650 .send(SteerMessage::InitialInputRelease(original.clone()))
651 .unwrap();
652 inbox
653 .wait_initial_release(&first, &cancel, &events)
654 .await
655 .unwrap();
656 let mut altered = original;
657 match changed {
658 "nonce" => altered.request.nonce = uuid::Uuid::new_v4().to_string(),
659 "epoch" => altered.request.execution_epoch += 1,
660 "birth" => altered.request.created_at += chrono::Duration::seconds(1),
661 "envelope" => altered.request.envelope_id = "other-input".into(),
662 "generation" => altered.request.generation += 1,
663 "deadline" => altered.expires_at += chrono::Duration::seconds(1),
664 "expired" => altered.expires_at = chrono::Utc::now() - chrono::Duration::seconds(1),
665 _ => unreachable!(),
666 }
667 sender
668 .send(SteerMessage::InitialInputRelease(altered))
669 .unwrap();
670 let error = inbox
671 .wait_initial_release(&second, &cancel, &events)
672 .await
673 .unwrap_err();
674 assert!(error.contains("mismatched"), "{changed}: {error}");
675 }
676 }
677
678 #[tokio::test(start_paused = true)]
681 async fn initial_release_wait_observes_timeout_disconnect_and_cancel() {
682 let request = crate::proto::InitialInputReleaseRequest {
683 version: 1,
684 nonce: uuid::Uuid::new_v4().to_string(),
685 child_id: "wait-child".into(),
686 parent_id: "parent".into(),
687 root_id: "parent".into(),
688 created_at: chrono::Utc::now(),
689 spawn_depth: 1,
690 project_id: None,
691 envelope_id: "input".into(),
692 generation: 1,
693 activation_run_id: "run".into(),
694 execution_epoch: 1,
695 };
696 let (events, _rx, _controls) = EventSink::channel_with_control();
697 for case in ["timeout", "disconnect", "cancel"] {
698 let (sender, mut inbox) = SteerInbox::channel();
699 let cancel = CancellationToken::new();
700 if case == "disconnect" {
701 drop(sender);
702 } else if case == "cancel" {
703 cancel.cancel();
704 }
705 let error = inbox
706 .wait_initial_release_bounded(
707 &request,
708 &cancel,
709 &events,
710 std::time::Duration::from_millis(2),
711 )
712 .await
713 .unwrap_err();
714 assert!(
715 error.contains(match case {
716 "timeout" => "timed out",
717 "cancel" => "cancelled",
718 _ => "missing",
719 }),
720 "initial release case={case}, actual error={error:?}"
721 );
722 }
723 }
724
725 #[tokio::test]
726 async fn full_event_queue_backpressures_without_losing_durable_order_or_blocking_control() {
727 let (sink, mut events, mut controls) = EventSink::channel_with_control();
728 for index in 0..EventSink::EVENT_CAPACITY {
729 sink.emit(serde_json::json!({"type":"tool_start","index":index}))
730 .await;
731 }
732 assert_eq!(events.len(), EventSink::EVENT_CAPACITY);
733 let terminal = sink.emit(serde_json::json!({"type":"complete"}));
734 tokio::pin!(terminal);
735 assert!(futures_util::poll!(&mut terminal).is_pending());
736
737 let confirmation = SessionMessageAdmissionConfirmation {
740 target_session_id: "bounded-child".into(),
741 envelope_id: "message".into(),
742 canonical_claim_generation: 1,
743 activation_run_id: "activation".into(),
744 };
745 sink.confirm_session_message(confirmation.clone()).await;
746 assert_eq!(
747 controls.recv().await.unwrap(),
748 ExecutorControl::SessionMessageAdmitted(confirmation)
749 );
750 assert_eq!(events.recv().await.unwrap()["index"], 0);
751 terminal.await;
752 for index in 1..EventSink::EVENT_CAPACITY {
753 assert_eq!(events.recv().await.unwrap()["index"], index);
754 }
755 assert_eq!(events.recv().await.unwrap()["type"], "complete");
756 }
757
758 #[test]
759 fn echo_explicitly_opts_into_high_parallelism() {
760 assert!(EchoExecutor.max_parallel_executions() >= 200);
761 }
762
763 #[tokio::test]
764 async fn echo_streams_then_completes() {
765 let (sink, mut rx) = EventSink::channel();
766 let outcome = EchoExecutor
767 .run(
768 RunSpec {
769 assignment: "alpha beta".into(),
770 logical_session: None,
771 project_id: None,
772 reasoning_effort: None,
773 permission_policy: None,
774 messages: Vec::new(),
775 activation_run_id: None,
776 execution_epoch: 0,
777 initial_session_messages: Vec::new(),
778 secrets: Default::default(),
779 },
780 sink,
781 SteerInbox::disconnected(),
782 CancellationToken::new(),
783 )
784 .await;
785 assert_eq!(outcome.status, TerminalStatus::Completed);
786 assert_eq!(outcome.result.as_deref(), Some("echo: alpha beta"));
787
788 let mut events = Vec::new();
789 while let Ok(e) = rx.try_recv() {
790 events.push(e);
791 }
792 assert_eq!(events.len(), 3);
794 assert_eq!(events[0]["content"], "alpha ");
795 }
796
797 #[tokio::test]
798 async fn echo_honors_cancel() {
799 let (sink, _rx) = EventSink::channel();
800 let cancel = CancellationToken::new();
801 cancel.cancel();
802 let outcome = EchoExecutor
803 .run(
804 RunSpec {
805 assignment: "a b c".into(),
806 logical_session: None,
807 project_id: None,
808 reasoning_effort: None,
809 permission_policy: None,
810 messages: Vec::new(),
811 activation_run_id: None,
812 execution_epoch: 0,
813 initial_session_messages: Vec::new(),
814 secrets: Default::default(),
815 },
816 sink,
817 SteerInbox::disconnected(),
818 cancel,
819 )
820 .await;
821 assert_eq!(outcome.status, TerminalStatus::Cancelled);
822 }
823
824 #[tokio::test]
825 async fn approval_call_sends_approval_kind_and_round_trips_reply() {
826 let (bridge, mut req_rx) = HostBridge::channel();
827 let caller = tokio::spawn(async move {
828 bridge
829 .approval_call(serde_json::json!({"resource": "/tmp/x"}))
830 .await
831 });
832 let req = req_rx.recv().await.expect("a host request");
833 assert_eq!(req.kind, HostRequestKind::Approval);
834 assert_eq!(req.body["resource"], "/tmp/x");
835 let _ = req.reply.send(serde_json::json!({"approved": true}));
836 let reply = caller.await.unwrap().expect("decision");
837 assert_eq!(reply["approved"], true);
838 }
839}