1use std::sync::Arc;
9
10use tokio::sync::mpsc;
11
12use serde_json::Value;
13
14use crate::tools::{BashCompletionSink, ToolSchema};
15use crate::{AgentEvent, Session};
16use bamboo_domain::PermissionMode;
17
18#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
28pub struct ToolExecutionSessionFlags {
29 pub bypass_permissions: bool,
32 pub auto_approve_permissions: bool,
35 pub plan_read_only: bool,
38}
39
40impl ToolExecutionSessionFlags {
41 pub fn from_session(session: &Session) -> Self {
44 Self::from_session_and_configured_mode(session, PermissionMode::Default)
45 }
46
47 pub fn from_session_and_configured_mode(
52 session: &Session,
53 configured_mode: PermissionMode,
54 ) -> Self {
55 let requested = session
56 .agent_runtime_state
57 .as_ref()
58 .map(|state| state.effective_permission_mode())
59 .unwrap_or_default();
60 let configured_mode = if session
61 .agent_runtime_state
62 .as_ref()
63 .is_some_and(|state| state.plan_mode.is_some())
64 {
65 PermissionMode::Plan
66 } else {
67 configured_mode
68 };
69 let resolution = bamboo_domain::resolve_permission_mode(requested, configured_mode);
70 Self {
71 bypass_permissions: resolution.bypass_permissions(),
72 auto_approve_permissions: resolution.auto_approve_permissions(),
73 plan_read_only: resolution.effective == PermissionMode::Plan,
74 }
75 }
76}
77
78#[derive(Clone, Copy)]
88pub struct ToolExecutionContext<'a> {
89 pub session_id: Option<&'a str>,
91 pub tool_call_id: &'a str,
93 pub event_tx: Option<&'a mpsc::Sender<AgentEvent>>,
95 pub available_tool_schemas: Option<&'a [ToolSchema]>,
97 pub bypass_permissions: bool,
101 pub auto_approve_permissions: bool,
104 pub plan_read_only: bool,
106 pub can_async_resume: bool,
116 pub bash_completion_sink: Option<&'a Arc<dyn BashCompletionSink>>,
127 pub pre_parsed_args: Option<&'a Value>,
137}
138
139impl std::fmt::Debug for ToolExecutionContext<'_> {
142 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
143 f.debug_struct("ToolExecutionContext")
144 .field("session_id", &self.session_id)
145 .field("tool_call_id", &self.tool_call_id)
146 .field("event_tx", &self.event_tx)
147 .field("available_tool_schemas", &self.available_tool_schemas)
148 .field("bypass_permissions", &self.bypass_permissions)
149 .field("auto_approve_permissions", &self.auto_approve_permissions)
150 .field("plan_read_only", &self.plan_read_only)
151 .field("can_async_resume", &self.can_async_resume)
152 .field("bash_completion_sink", &self.bash_completion_sink.is_some())
153 .field("pre_parsed_args", &self.pre_parsed_args)
154 .finish()
155 }
156}
157
158impl<'a> ToolExecutionContext<'a> {
159 pub fn none(tool_call_id: &'a str) -> Self {
160 Self {
161 session_id: None,
162 tool_call_id,
163 event_tx: None,
164 available_tool_schemas: None,
165 bypass_permissions: false,
166 auto_approve_permissions: false,
167 plan_read_only: false,
168 can_async_resume: false,
169 bash_completion_sink: None,
170 pre_parsed_args: None,
171 }
172 }
173
174 #[allow(clippy::too_many_arguments)]
180 pub fn for_dispatch(
181 session_id: &'a str,
182 tool_call_id: &'a str,
183 event_tx: &'a mpsc::Sender<AgentEvent>,
184 available_tool_schemas: &'a [ToolSchema],
185 flags: ToolExecutionSessionFlags,
186 can_async_resume: bool,
191 bash_completion_sink: Option<&'a Arc<dyn BashCompletionSink>>,
196 pre_parsed_args: Option<&'a Value>,
204 ) -> Self {
205 Self {
206 session_id: Some(session_id),
207 tool_call_id,
208 event_tx: Some(event_tx),
209 available_tool_schemas: Some(available_tool_schemas),
210 bypass_permissions: flags.bypass_permissions,
211 auto_approve_permissions: flags.auto_approve_permissions,
212 plan_read_only: flags.plan_read_only,
213 can_async_resume,
214 bash_completion_sink,
215 pre_parsed_args,
216 }
217 }
218
219 pub fn cloned_sender(&self) -> Option<mpsc::Sender<AgentEvent>> {
221 self.event_tx.cloned()
222 }
223
224 pub fn cloned_bash_completion_sink(&self) -> Option<Arc<dyn BashCompletionSink>> {
229 self.bash_completion_sink.map(Arc::clone)
230 }
231
232 pub fn to_tool_ctx(&self) -> crate::tools::ToolCtx {
239 crate::tools::ToolCtx {
240 session_id: self.session_id.map(Arc::from),
241 tool_call_id: Arc::from(self.tool_call_id),
242 event_tx: self.event_tx.cloned(),
243 available_tool_schemas: self
244 .available_tool_schemas
245 .map(Arc::from)
246 .unwrap_or_else(|| Arc::from(Vec::new())),
247 bypass_permissions: self.bypass_permissions,
248 auto_approve_permissions: self.auto_approve_permissions,
249 plan_read_only: self.plan_read_only,
250 can_async_resume: self.can_async_resume,
251 async_completion_sink: None,
252 bash_completion_sink: self.bash_completion_sink.map(Arc::clone),
253 }
254 }
255
256 pub async fn emit(&self, event: AgentEvent) {
258 if let Some(tx) = self.event_tx {
259 let event = match event {
263 AgentEvent::Token { content } => AgentEvent::ToolToken {
264 tool_call_id: self.tool_call_id.to_string(),
265 content,
266 },
267 other => other,
268 };
269 let _ = tx.try_send(event);
270 }
271 }
272
273 pub async fn emit_tool_token(&self, content: impl Into<String>) {
275 self.emit(AgentEvent::ToolToken {
276 tool_call_id: self.tool_call_id.to_string(),
277 content: content.into(),
278 })
279 .await;
280 }
281}
282
283#[cfg(test)]
284mod session_flags_tests {
285 use super::*;
286 use bamboo_domain::{AgentRuntimeState, SessionPermissionMode};
287
288 #[test]
289 fn from_session_defaults_false_without_runtime_state() {
290 let session = Session::new("s-none", "test-model");
291 assert_eq!(
292 ToolExecutionSessionFlags::from_session(&session),
293 ToolExecutionSessionFlags {
294 bypass_permissions: false,
295 auto_approve_permissions: false,
296 plan_read_only: false,
297 }
298 );
299 }
300
301 #[test]
302 fn from_session_reads_bypass_from_runtime_state() {
303 let mut session = Session::new("s-bypass", "test-model");
304 let mut runtime = AgentRuntimeState::new("run-1");
305 runtime.bypass_permissions = true;
306 session.agent_runtime_state = Some(runtime);
307 assert!(ToolExecutionSessionFlags::from_session(&session).bypass_permissions);
308 assert!(!ToolExecutionSessionFlags::from_session(&session).auto_approve_permissions);
309 }
310
311 #[test]
312 fn from_session_distinguishes_auto_from_legacy_bypass() {
313 let mut session = Session::new("s-auto", "test-model");
314 let mut runtime = AgentRuntimeState::new("run-1");
315 runtime.set_permission_mode(SessionPermissionMode::Auto);
316 session.agent_runtime_state = Some(runtime);
317
318 let flags = ToolExecutionSessionFlags::from_session(&session);
319 assert!(!flags.bypass_permissions);
320 assert!(flags.auto_approve_permissions);
321 }
322
323 #[test]
324 fn configured_auto_applies_to_default_but_not_explicit_bypass() {
325 let default_session = Session::new("s-global-auto", "test-model");
326 let flags = ToolExecutionSessionFlags::from_session_and_configured_mode(
327 &default_session,
328 PermissionMode::Auto,
329 );
330 assert_eq!(
331 flags,
332 ToolExecutionSessionFlags {
333 bypass_permissions: false,
334 auto_approve_permissions: true,
335 plan_read_only: false,
336 }
337 );
338
339 let mut bypass_session = default_session;
340 bypass_session
341 .agent_runtime_state
342 .get_or_insert_default()
343 .set_permission_mode(SessionPermissionMode::Bypass);
344 let flags = ToolExecutionSessionFlags::from_session_and_configured_mode(
345 &bypass_session,
346 PermissionMode::Auto,
347 );
348 assert!(flags.bypass_permissions);
349 assert!(!flags.auto_approve_permissions);
350 }
351
352 #[test]
353 fn configured_plan_preserves_no_prompt_but_sets_read_only_gate() {
354 let mut session = Session::new("s-plan-auto", "test-model");
355 session
356 .agent_runtime_state
357 .get_or_insert_default()
358 .set_permission_mode(SessionPermissionMode::Auto);
359 let flags = ToolExecutionSessionFlags::from_session_and_configured_mode(
360 &session,
361 PermissionMode::Plan,
362 );
363 assert!(!flags.bypass_permissions);
364 assert!(flags.auto_approve_permissions);
365 assert!(flags.plan_read_only);
366 }
367
368 #[test]
369 fn persisted_plan_overlay_is_honored_without_config_reconstruction() {
370 let mut session = Session::new("s-persisted-plan-auto", "test-model");
371 let runtime = session.agent_runtime_state.get_or_insert_default();
372 runtime.set_permission_mode(SessionPermissionMode::Auto);
373 runtime.plan_mode = Some(bamboo_domain::PlanModeState {
374 entered_at: chrono::Utc::now(),
375 pre_permission_mode: "auto".to_string(),
376 plan_file_path: None,
377 status: bamboo_domain::PlanModeStatus::Designing,
378 });
379
380 let flags = ToolExecutionSessionFlags::from_session(&session);
381 assert!(flags.plan_read_only);
382 assert!(flags.auto_approve_permissions);
383 assert!(!flags.bypass_permissions);
384 }
385
386 #[test]
387 fn for_dispatch_maps_flags_onto_context() {
388 let (tx, _rx) = mpsc::channel(1);
389 let ctx = ToolExecutionContext::for_dispatch(
390 "s1",
391 "call-1",
392 &tx,
393 &[],
394 ToolExecutionSessionFlags {
395 bypass_permissions: true,
396 auto_approve_permissions: false,
397 plan_read_only: false,
398 },
399 true,
400 None,
401 None,
402 );
403 assert_eq!(ctx.session_id, Some("s1"));
404 assert!(ctx.bypass_permissions);
405 assert!(!ctx.auto_approve_permissions);
406 assert!(ctx.can_async_resume);
407 assert!(ctx.pre_parsed_args.is_none());
408
409 let owned = ctx.to_tool_ctx();
410 assert!(owned.bypass_permissions);
411 assert!(!owned.auto_approve_permissions);
412 assert!(!owned.plan_read_only);
413 }
414
415 #[test]
416 fn for_dispatch_threads_pre_parsed_args() {
417 let (tx, _rx) = mpsc::channel(1);
418 let parsed = serde_json::json!({"v": "x"});
419 let ctx = ToolExecutionContext::for_dispatch(
420 "s1",
421 "call-1",
422 &tx,
423 &[],
424 ToolExecutionSessionFlags::default(),
425 false,
426 None,
427 Some(&parsed),
428 );
429 assert_eq!(ctx.pre_parsed_args, Some(&parsed));
430 }
431}
432
433#[cfg(test)]
434mod tests {
435 use super::*;
436
437 #[tokio::test]
438 async fn emit_does_not_block_when_channel_is_full() {
439 let (tx, mut rx) = mpsc::channel(1);
440 tx.send(AgentEvent::Token {
441 content: "full".to_string(),
442 })
443 .await
444 .unwrap();
445 let ctx = ToolExecutionContext {
446 session_id: Some("session_1"),
447 tool_call_id: "call_1",
448 event_tx: Some(&tx),
449 available_tool_schemas: None,
450 bypass_permissions: false,
451 auto_approve_permissions: false,
452 plan_read_only: false,
453 can_async_resume: false,
454 bash_completion_sink: None,
455 pre_parsed_args: None,
456 };
457
458 tokio::time::timeout(
459 std::time::Duration::from_millis(100),
460 ctx.emit(AgentEvent::Token {
461 content: "next".to_string(),
462 }),
463 )
464 .await
465 .expect("emit should not block on full channel");
466
467 let first = rx.recv().await.unwrap();
468 match first {
469 AgentEvent::Token { content } => assert_eq!(content, "full"),
470 other => panic!("unexpected event: {other:?}"),
471 }
472 }
473
474 #[tokio::test]
475 async fn emit_converts_token_to_tool_token() {
476 let (tx, mut rx) = mpsc::channel(10);
477 let ctx = ToolExecutionContext {
478 session_id: Some("session_1"),
479 tool_call_id: "call_123",
480 event_tx: Some(&tx),
481 available_tool_schemas: None,
482 bypass_permissions: false,
483 auto_approve_permissions: false,
484 plan_read_only: false,
485 can_async_resume: false,
486 bash_completion_sink: None,
487 pre_parsed_args: None,
488 };
489
490 ctx.emit(AgentEvent::Token {
491 content: "test content".to_string(),
492 })
493 .await;
494
495 let event = rx.recv().await.unwrap();
496 match event {
497 AgentEvent::ToolToken {
498 tool_call_id,
499 content,
500 } => {
501 assert_eq!(tool_call_id, "call_123");
502 assert_eq!(content, "test content");
503 }
504 other => panic!("Expected ToolToken, got: {other:?}"),
505 }
506 }
507
508 #[tokio::test]
509 async fn emit_passes_through_non_token_events() {
510 let (tx, mut rx) = mpsc::channel(10);
511 let ctx = ToolExecutionContext {
512 session_id: Some("session_1"),
513 tool_call_id: "call_456",
514 event_tx: Some(&tx),
515 available_tool_schemas: None,
516 bypass_permissions: false,
517 auto_approve_permissions: false,
518 plan_read_only: false,
519 can_async_resume: false,
520 bash_completion_sink: None,
521 pre_parsed_args: None,
522 };
523
524 ctx.emit(AgentEvent::ToolToken {
526 tool_call_id: "other".to_string(),
527 content: "direct tool token".to_string(),
528 })
529 .await;
530
531 let event = rx.recv().await.unwrap();
532 match event {
533 AgentEvent::ToolToken { content, .. } => {
534 assert_eq!(content, "direct tool token");
535 }
536 other => panic!("Expected ToolToken, got: {other:?}"),
537 }
538 }
539
540 #[tokio::test]
541 async fn emit_does_nothing_when_no_sender() {
542 let ctx = ToolExecutionContext::none("call_789");
543
544 ctx.emit(AgentEvent::Token {
546 content: "test".to_string(),
547 })
548 .await;
549
550 }
552
553 #[tokio::test]
554 async fn emit_tool_token_convenience_method() {
555 let (tx, mut rx) = mpsc::channel(10);
556 let ctx = ToolExecutionContext {
557 session_id: None,
558 tool_call_id: "call_abc",
559 event_tx: Some(&tx),
560 available_tool_schemas: None,
561 bypass_permissions: false,
562 auto_approve_permissions: false,
563 plan_read_only: false,
564 can_async_resume: false,
565 bash_completion_sink: None,
566 pre_parsed_args: None,
567 };
568
569 ctx.emit_tool_token("convenient output").await;
570
571 let event = rx.recv().await.unwrap();
572 match event {
573 AgentEvent::ToolToken {
574 tool_call_id,
575 content,
576 } => {
577 assert_eq!(tool_call_id, "call_abc");
578 assert_eq!(content, "convenient output");
579 }
580 other => panic!("Expected ToolToken, got: {other:?}"),
581 }
582 }
583
584 #[tokio::test]
585 async fn emit_tool_token_with_no_sender_does_nothing() {
586 let ctx = ToolExecutionContext::none("call_def");
587
588 ctx.emit_tool_token("test").await;
590
591 }
593
594 #[test]
595 fn none_creates_context_with_no_optional_fields() {
596 let ctx = ToolExecutionContext::none("call_xyz");
597
598 assert_eq!(ctx.session_id, None);
599 assert_eq!(ctx.tool_call_id, "call_xyz");
600 assert!(ctx.event_tx.is_none());
601 }
602
603 #[test]
604 fn cloned_sender_returns_none_when_no_sender() {
605 let ctx = ToolExecutionContext::none("call_test");
606 assert!(ctx.cloned_sender().is_none());
607 }
608
609 #[tokio::test]
610 async fn cloned_sender_returns_clone_when_sender_present() {
611 let (tx, _rx) = mpsc::channel(10);
612 let ctx = ToolExecutionContext {
613 session_id: None,
614 tool_call_id: "call_clone",
615 event_tx: Some(&tx),
616 available_tool_schemas: None,
617 bypass_permissions: false,
618 auto_approve_permissions: false,
619 plan_read_only: false,
620 can_async_resume: false,
621 bash_completion_sink: None,
622 pre_parsed_args: None,
623 };
624
625 let cloned = ctx.cloned_sender();
626 assert!(cloned.is_some());
627
628 cloned
630 .unwrap()
631 .send(AgentEvent::Token {
632 content: "test".to_string(),
633 })
634 .await
635 .unwrap();
636 }
637
638 #[tokio::test]
639 async fn emit_handles_multiple_sequential_calls() {
640 let (tx, mut rx) = mpsc::channel(10);
641 let ctx = ToolExecutionContext {
642 session_id: Some("session_multi"),
643 tool_call_id: "call_multi",
644 event_tx: Some(&tx),
645 available_tool_schemas: None,
646 bypass_permissions: false,
647 auto_approve_permissions: false,
648 plan_read_only: false,
649 can_async_resume: false,
650 bash_completion_sink: None,
651 pre_parsed_args: None,
652 };
653
654 for i in 0..5 {
655 ctx.emit(AgentEvent::Token {
656 content: format!("message {}", i),
657 })
658 .await;
659 }
660
661 for i in 0..5 {
662 let event = rx.recv().await.unwrap();
663 match event {
664 AgentEvent::ToolToken { content, .. } => {
665 assert_eq!(content, format!("message {}", i));
666 }
667 other => panic!("Expected ToolToken, got: {other:?}"),
668 }
669 }
670 }
671
672 #[test]
673 fn context_is_clone_and_copy() {
674 let (tx, _rx) = mpsc::channel(10);
675 let ctx = ToolExecutionContext {
676 session_id: Some("session_copy"),
677 tool_call_id: "call_copy",
678 event_tx: Some(&tx),
679 available_tool_schemas: None,
680 bypass_permissions: false,
681 auto_approve_permissions: false,
682 plan_read_only: false,
683 can_async_resume: false,
684 bash_completion_sink: None,
685 pre_parsed_args: None,
686 };
687
688 let _cloned = ctx;
690
691 let copied = ctx;
693
694 assert_eq!(copied.tool_call_id, "call_copy");
696 }
697
698 #[test]
699 fn context_is_debug() {
700 let ctx = ToolExecutionContext::none("call_debug");
701 let debug_str = format!("{:?}", ctx);
702 assert!(debug_str.contains("call_debug"));
703 }
704
705 #[tokio::test]
706 async fn emit_with_empty_tool_call_id() {
707 let (tx, mut rx) = mpsc::channel(10);
708 let ctx = ToolExecutionContext {
709 session_id: None,
710 tool_call_id: "",
711 event_tx: Some(&tx),
712 available_tool_schemas: None,
713 bypass_permissions: false,
714 auto_approve_permissions: false,
715 plan_read_only: false,
716 can_async_resume: false,
717 bash_completion_sink: None,
718 pre_parsed_args: None,
719 };
720
721 ctx.emit(AgentEvent::Token {
722 content: "test".to_string(),
723 })
724 .await;
725
726 let event = rx.recv().await.unwrap();
727 match event {
728 AgentEvent::ToolToken { tool_call_id, .. } => {
729 assert_eq!(tool_call_id, "");
730 }
731 other => panic!("Expected ToolToken, got: {other:?}"),
732 }
733 }
734
735 #[tokio::test]
736 async fn emit_with_unicode_content() {
737 let (tx, mut rx) = mpsc::channel(10);
738 let ctx = ToolExecutionContext {
739 session_id: Some("会话"),
740 tool_call_id: "调用_123",
741 event_tx: Some(&tx),
742 available_tool_schemas: None,
743 bypass_permissions: false,
744 auto_approve_permissions: false,
745 plan_read_only: false,
746 can_async_resume: false,
747 bash_completion_sink: None,
748 pre_parsed_args: None,
749 };
750
751 ctx.emit(AgentEvent::Token {
752 content: "测试内容 🎯".to_string(),
753 })
754 .await;
755
756 let event = rx.recv().await.unwrap();
757 match event {
758 AgentEvent::ToolToken {
759 tool_call_id,
760 content,
761 } => {
762 assert_eq!(tool_call_id, "调用_123");
763 assert_eq!(content, "测试内容 🎯");
764 }
765 other => panic!("Expected ToolToken, got: {other:?}"),
766 }
767 }
768
769 #[tokio::test]
770 async fn emit_with_special_characters_in_tool_call_id() {
771 let (tx, mut rx) = mpsc::channel(10);
772 let ctx = ToolExecutionContext {
773 session_id: None,
774 tool_call_id: "call-with_special.chars:123",
775 event_tx: Some(&tx),
776 available_tool_schemas: None,
777 bypass_permissions: false,
778 auto_approve_permissions: false,
779 plan_read_only: false,
780 can_async_resume: false,
781 bash_completion_sink: None,
782 pre_parsed_args: None,
783 };
784
785 ctx.emit(AgentEvent::Token {
786 content: "test".to_string(),
787 })
788 .await;
789
790 let event = rx.recv().await.unwrap();
791 match event {
792 AgentEvent::ToolToken { tool_call_id, .. } => {
793 assert_eq!(tool_call_id, "call-with_special.chars:123");
794 }
795 other => panic!("Expected ToolToken, got: {other:?}"),
796 }
797 }
798
799 #[tokio::test]
800 async fn emit_tool_token_with_string_content() {
801 let (tx, mut rx) = mpsc::channel(10);
802 let ctx = ToolExecutionContext {
803 session_id: None,
804 tool_call_id: "call_string",
805 event_tx: Some(&tx),
806 available_tool_schemas: None,
807 bypass_permissions: false,
808 auto_approve_permissions: false,
809 plan_read_only: false,
810 can_async_resume: false,
811 bash_completion_sink: None,
812 pre_parsed_args: None,
813 };
814
815 let content = String::from("owned string");
816 ctx.emit_tool_token(content).await;
817
818 let event = rx.recv().await.unwrap();
819 match event {
820 AgentEvent::ToolToken { content, .. } => {
821 assert_eq!(content, "owned string");
822 }
823 other => panic!("Expected ToolToken, got: {other:?}"),
824 }
825 }
826
827 #[tokio::test]
828 async fn emit_tool_token_with_str_content() {
829 let (tx, mut rx) = mpsc::channel(10);
830 let ctx = ToolExecutionContext {
831 session_id: None,
832 tool_call_id: "call_str",
833 event_tx: Some(&tx),
834 available_tool_schemas: None,
835 bypass_permissions: false,
836 auto_approve_permissions: false,
837 plan_read_only: false,
838 can_async_resume: false,
839 bash_completion_sink: None,
840 pre_parsed_args: None,
841 };
842
843 ctx.emit_tool_token("string slice").await;
844
845 let event = rx.recv().await.unwrap();
846 match event {
847 AgentEvent::ToolToken { content, .. } => {
848 assert_eq!(content, "string slice");
849 }
850 other => panic!("Expected ToolToken, got: {other:?}"),
851 }
852 }
853}