1use std::sync::Arc;
9
10use tokio::sync::mpsc;
11
12use serde_json::Value;
13
14use crate::tools::{BashCompletionSink, ToolSchema};
15use crate::{AgentEvent, Session};
16
17#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
27pub struct ToolExecutionSessionFlags {
28 pub bypass_permissions: bool,
31}
32
33impl ToolExecutionSessionFlags {
34 pub fn from_session(session: &Session) -> Self {
37 Self {
38 bypass_permissions: session
39 .agent_runtime_state
40 .as_ref()
41 .is_some_and(|state| state.bypass_permissions),
42 }
43 }
44}
45
46#[derive(Clone, Copy)]
56pub struct ToolExecutionContext<'a> {
57 pub session_id: Option<&'a str>,
59 pub tool_call_id: &'a str,
61 pub event_tx: Option<&'a mpsc::Sender<AgentEvent>>,
63 pub available_tool_schemas: Option<&'a [ToolSchema]>,
65 pub bypass_permissions: bool,
69 pub can_async_resume: bool,
79 pub bash_completion_sink: Option<&'a Arc<dyn BashCompletionSink>>,
90 pub pre_parsed_args: Option<&'a Value>,
100}
101
102impl std::fmt::Debug for ToolExecutionContext<'_> {
105 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
106 f.debug_struct("ToolExecutionContext")
107 .field("session_id", &self.session_id)
108 .field("tool_call_id", &self.tool_call_id)
109 .field("event_tx", &self.event_tx)
110 .field("available_tool_schemas", &self.available_tool_schemas)
111 .field("bypass_permissions", &self.bypass_permissions)
112 .field("can_async_resume", &self.can_async_resume)
113 .field("bash_completion_sink", &self.bash_completion_sink.is_some())
114 .field("pre_parsed_args", &self.pre_parsed_args)
115 .finish()
116 }
117}
118
119impl<'a> ToolExecutionContext<'a> {
120 pub fn none(tool_call_id: &'a str) -> Self {
121 Self {
122 session_id: None,
123 tool_call_id,
124 event_tx: None,
125 available_tool_schemas: None,
126 bypass_permissions: false,
127 can_async_resume: false,
128 bash_completion_sink: None,
129 pre_parsed_args: None,
130 }
131 }
132
133 #[allow(clippy::too_many_arguments)]
139 pub fn for_dispatch(
140 session_id: &'a str,
141 tool_call_id: &'a str,
142 event_tx: &'a mpsc::Sender<AgentEvent>,
143 available_tool_schemas: &'a [ToolSchema],
144 flags: ToolExecutionSessionFlags,
145 can_async_resume: bool,
150 bash_completion_sink: Option<&'a Arc<dyn BashCompletionSink>>,
155 pre_parsed_args: Option<&'a Value>,
163 ) -> Self {
164 Self {
165 session_id: Some(session_id),
166 tool_call_id,
167 event_tx: Some(event_tx),
168 available_tool_schemas: Some(available_tool_schemas),
169 bypass_permissions: flags.bypass_permissions,
170 can_async_resume,
171 bash_completion_sink,
172 pre_parsed_args,
173 }
174 }
175
176 pub fn cloned_sender(&self) -> Option<mpsc::Sender<AgentEvent>> {
178 self.event_tx.cloned()
179 }
180
181 pub fn cloned_bash_completion_sink(&self) -> Option<Arc<dyn BashCompletionSink>> {
186 self.bash_completion_sink.map(Arc::clone)
187 }
188
189 pub fn to_tool_ctx(&self) -> crate::tools::ToolCtx {
196 crate::tools::ToolCtx {
197 session_id: self.session_id.map(Arc::from),
198 tool_call_id: Arc::from(self.tool_call_id),
199 event_tx: self.event_tx.cloned(),
200 available_tool_schemas: self
201 .available_tool_schemas
202 .map(Arc::from)
203 .unwrap_or_else(|| Arc::from(Vec::new())),
204 bypass_permissions: self.bypass_permissions,
205 can_async_resume: self.can_async_resume,
206 async_completion_sink: None,
207 bash_completion_sink: self.bash_completion_sink.map(Arc::clone),
208 }
209 }
210
211 pub async fn emit(&self, event: AgentEvent) {
213 if let Some(tx) = self.event_tx {
214 let event = match event {
218 AgentEvent::Token { content } => AgentEvent::ToolToken {
219 tool_call_id: self.tool_call_id.to_string(),
220 content,
221 },
222 other => other,
223 };
224 let _ = tx.try_send(event);
225 }
226 }
227
228 pub async fn emit_tool_token(&self, content: impl Into<String>) {
230 self.emit(AgentEvent::ToolToken {
231 tool_call_id: self.tool_call_id.to_string(),
232 content: content.into(),
233 })
234 .await;
235 }
236}
237
238#[cfg(test)]
239mod session_flags_tests {
240 use super::*;
241 use bamboo_domain::AgentRuntimeState;
242
243 #[test]
244 fn from_session_defaults_false_without_runtime_state() {
245 let session = Session::new("s-none", "test-model");
246 assert_eq!(
247 ToolExecutionSessionFlags::from_session(&session),
248 ToolExecutionSessionFlags {
249 bypass_permissions: false
250 }
251 );
252 }
253
254 #[test]
255 fn from_session_reads_bypass_from_runtime_state() {
256 let mut session = Session::new("s-bypass", "test-model");
257 let mut runtime = AgentRuntimeState::new("run-1");
258 runtime.bypass_permissions = true;
259 session.agent_runtime_state = Some(runtime);
260 assert!(ToolExecutionSessionFlags::from_session(&session).bypass_permissions);
261 }
262
263 #[test]
264 fn for_dispatch_maps_flags_onto_context() {
265 let (tx, _rx) = mpsc::channel(1);
266 let ctx = ToolExecutionContext::for_dispatch(
267 "s1",
268 "call-1",
269 &tx,
270 &[],
271 ToolExecutionSessionFlags {
272 bypass_permissions: true,
273 },
274 true,
275 None,
276 None,
277 );
278 assert_eq!(ctx.session_id, Some("s1"));
279 assert!(ctx.bypass_permissions);
280 assert!(ctx.can_async_resume);
281 assert!(ctx.pre_parsed_args.is_none());
282 }
283
284 #[test]
285 fn for_dispatch_threads_pre_parsed_args() {
286 let (tx, _rx) = mpsc::channel(1);
287 let parsed = serde_json::json!({"v": "x"});
288 let ctx = ToolExecutionContext::for_dispatch(
289 "s1",
290 "call-1",
291 &tx,
292 &[],
293 ToolExecutionSessionFlags::default(),
294 false,
295 None,
296 Some(&parsed),
297 );
298 assert_eq!(ctx.pre_parsed_args, Some(&parsed));
299 }
300}
301
302#[cfg(test)]
303mod tests {
304 use super::*;
305
306 #[tokio::test]
307 async fn emit_does_not_block_when_channel_is_full() {
308 let (tx, mut rx) = mpsc::channel(1);
309 tx.send(AgentEvent::Token {
310 content: "full".to_string(),
311 })
312 .await
313 .unwrap();
314 let ctx = ToolExecutionContext {
315 session_id: Some("session_1"),
316 tool_call_id: "call_1",
317 event_tx: Some(&tx),
318 available_tool_schemas: None,
319 bypass_permissions: false,
320 can_async_resume: false,
321 bash_completion_sink: None,
322 pre_parsed_args: None,
323 };
324
325 tokio::time::timeout(
326 std::time::Duration::from_millis(100),
327 ctx.emit(AgentEvent::Token {
328 content: "next".to_string(),
329 }),
330 )
331 .await
332 .expect("emit should not block on full channel");
333
334 let first = rx.recv().await.unwrap();
335 match first {
336 AgentEvent::Token { content } => assert_eq!(content, "full"),
337 other => panic!("unexpected event: {other:?}"),
338 }
339 }
340
341 #[tokio::test]
342 async fn emit_converts_token_to_tool_token() {
343 let (tx, mut rx) = mpsc::channel(10);
344 let ctx = ToolExecutionContext {
345 session_id: Some("session_1"),
346 tool_call_id: "call_123",
347 event_tx: Some(&tx),
348 available_tool_schemas: None,
349 bypass_permissions: false,
350 can_async_resume: false,
351 bash_completion_sink: None,
352 pre_parsed_args: None,
353 };
354
355 ctx.emit(AgentEvent::Token {
356 content: "test content".to_string(),
357 })
358 .await;
359
360 let event = rx.recv().await.unwrap();
361 match event {
362 AgentEvent::ToolToken {
363 tool_call_id,
364 content,
365 } => {
366 assert_eq!(tool_call_id, "call_123");
367 assert_eq!(content, "test content");
368 }
369 other => panic!("Expected ToolToken, got: {other:?}"),
370 }
371 }
372
373 #[tokio::test]
374 async fn emit_passes_through_non_token_events() {
375 let (tx, mut rx) = mpsc::channel(10);
376 let ctx = ToolExecutionContext {
377 session_id: Some("session_1"),
378 tool_call_id: "call_456",
379 event_tx: Some(&tx),
380 available_tool_schemas: None,
381 bypass_permissions: false,
382 can_async_resume: false,
383 bash_completion_sink: None,
384 pre_parsed_args: None,
385 };
386
387 ctx.emit(AgentEvent::ToolToken {
389 tool_call_id: "other".to_string(),
390 content: "direct tool token".to_string(),
391 })
392 .await;
393
394 let event = rx.recv().await.unwrap();
395 match event {
396 AgentEvent::ToolToken { content, .. } => {
397 assert_eq!(content, "direct tool token");
398 }
399 other => panic!("Expected ToolToken, got: {other:?}"),
400 }
401 }
402
403 #[tokio::test]
404 async fn emit_does_nothing_when_no_sender() {
405 let ctx = ToolExecutionContext::none("call_789");
406
407 ctx.emit(AgentEvent::Token {
409 content: "test".to_string(),
410 })
411 .await;
412
413 }
415
416 #[tokio::test]
417 async fn emit_tool_token_convenience_method() {
418 let (tx, mut rx) = mpsc::channel(10);
419 let ctx = ToolExecutionContext {
420 session_id: None,
421 tool_call_id: "call_abc",
422 event_tx: Some(&tx),
423 available_tool_schemas: None,
424 bypass_permissions: false,
425 can_async_resume: false,
426 bash_completion_sink: None,
427 pre_parsed_args: None,
428 };
429
430 ctx.emit_tool_token("convenient output").await;
431
432 let event = rx.recv().await.unwrap();
433 match event {
434 AgentEvent::ToolToken {
435 tool_call_id,
436 content,
437 } => {
438 assert_eq!(tool_call_id, "call_abc");
439 assert_eq!(content, "convenient output");
440 }
441 other => panic!("Expected ToolToken, got: {other:?}"),
442 }
443 }
444
445 #[tokio::test]
446 async fn emit_tool_token_with_no_sender_does_nothing() {
447 let ctx = ToolExecutionContext::none("call_def");
448
449 ctx.emit_tool_token("test").await;
451
452 }
454
455 #[test]
456 fn none_creates_context_with_no_optional_fields() {
457 let ctx = ToolExecutionContext::none("call_xyz");
458
459 assert_eq!(ctx.session_id, None);
460 assert_eq!(ctx.tool_call_id, "call_xyz");
461 assert!(ctx.event_tx.is_none());
462 }
463
464 #[test]
465 fn cloned_sender_returns_none_when_no_sender() {
466 let ctx = ToolExecutionContext::none("call_test");
467 assert!(ctx.cloned_sender().is_none());
468 }
469
470 #[tokio::test]
471 async fn cloned_sender_returns_clone_when_sender_present() {
472 let (tx, _rx) = mpsc::channel(10);
473 let ctx = ToolExecutionContext {
474 session_id: None,
475 tool_call_id: "call_clone",
476 event_tx: Some(&tx),
477 available_tool_schemas: None,
478 bypass_permissions: false,
479 can_async_resume: false,
480 bash_completion_sink: None,
481 pre_parsed_args: None,
482 };
483
484 let cloned = ctx.cloned_sender();
485 assert!(cloned.is_some());
486
487 cloned
489 .unwrap()
490 .send(AgentEvent::Token {
491 content: "test".to_string(),
492 })
493 .await
494 .unwrap();
495 }
496
497 #[tokio::test]
498 async fn emit_handles_multiple_sequential_calls() {
499 let (tx, mut rx) = mpsc::channel(10);
500 let ctx = ToolExecutionContext {
501 session_id: Some("session_multi"),
502 tool_call_id: "call_multi",
503 event_tx: Some(&tx),
504 available_tool_schemas: None,
505 bypass_permissions: false,
506 can_async_resume: false,
507 bash_completion_sink: None,
508 pre_parsed_args: None,
509 };
510
511 for i in 0..5 {
512 ctx.emit(AgentEvent::Token {
513 content: format!("message {}", i),
514 })
515 .await;
516 }
517
518 for i in 0..5 {
519 let event = rx.recv().await.unwrap();
520 match event {
521 AgentEvent::ToolToken { content, .. } => {
522 assert_eq!(content, format!("message {}", i));
523 }
524 other => panic!("Expected ToolToken, got: {other:?}"),
525 }
526 }
527 }
528
529 #[test]
530 fn context_is_clone_and_copy() {
531 let (tx, _rx) = mpsc::channel(10);
532 let ctx = ToolExecutionContext {
533 session_id: Some("session_copy"),
534 tool_call_id: "call_copy",
535 event_tx: Some(&tx),
536 available_tool_schemas: None,
537 bypass_permissions: false,
538 can_async_resume: false,
539 bash_completion_sink: None,
540 pre_parsed_args: None,
541 };
542
543 let _cloned = ctx;
545
546 let copied = ctx;
548
549 assert_eq!(copied.tool_call_id, "call_copy");
551 }
552
553 #[test]
554 fn context_is_debug() {
555 let ctx = ToolExecutionContext::none("call_debug");
556 let debug_str = format!("{:?}", ctx);
557 assert!(debug_str.contains("call_debug"));
558 }
559
560 #[tokio::test]
561 async fn emit_with_empty_tool_call_id() {
562 let (tx, mut rx) = mpsc::channel(10);
563 let ctx = ToolExecutionContext {
564 session_id: None,
565 tool_call_id: "",
566 event_tx: Some(&tx),
567 available_tool_schemas: None,
568 bypass_permissions: false,
569 can_async_resume: false,
570 bash_completion_sink: None,
571 pre_parsed_args: None,
572 };
573
574 ctx.emit(AgentEvent::Token {
575 content: "test".to_string(),
576 })
577 .await;
578
579 let event = rx.recv().await.unwrap();
580 match event {
581 AgentEvent::ToolToken { tool_call_id, .. } => {
582 assert_eq!(tool_call_id, "");
583 }
584 other => panic!("Expected ToolToken, got: {other:?}"),
585 }
586 }
587
588 #[tokio::test]
589 async fn emit_with_unicode_content() {
590 let (tx, mut rx) = mpsc::channel(10);
591 let ctx = ToolExecutionContext {
592 session_id: Some("会话"),
593 tool_call_id: "调用_123",
594 event_tx: Some(&tx),
595 available_tool_schemas: None,
596 bypass_permissions: false,
597 can_async_resume: false,
598 bash_completion_sink: None,
599 pre_parsed_args: None,
600 };
601
602 ctx.emit(AgentEvent::Token {
603 content: "测试内容 🎯".to_string(),
604 })
605 .await;
606
607 let event = rx.recv().await.unwrap();
608 match event {
609 AgentEvent::ToolToken {
610 tool_call_id,
611 content,
612 } => {
613 assert_eq!(tool_call_id, "调用_123");
614 assert_eq!(content, "测试内容 🎯");
615 }
616 other => panic!("Expected ToolToken, got: {other:?}"),
617 }
618 }
619
620 #[tokio::test]
621 async fn emit_with_special_characters_in_tool_call_id() {
622 let (tx, mut rx) = mpsc::channel(10);
623 let ctx = ToolExecutionContext {
624 session_id: None,
625 tool_call_id: "call-with_special.chars:123",
626 event_tx: Some(&tx),
627 available_tool_schemas: None,
628 bypass_permissions: false,
629 can_async_resume: false,
630 bash_completion_sink: None,
631 pre_parsed_args: None,
632 };
633
634 ctx.emit(AgentEvent::Token {
635 content: "test".to_string(),
636 })
637 .await;
638
639 let event = rx.recv().await.unwrap();
640 match event {
641 AgentEvent::ToolToken { tool_call_id, .. } => {
642 assert_eq!(tool_call_id, "call-with_special.chars:123");
643 }
644 other => panic!("Expected ToolToken, got: {other:?}"),
645 }
646 }
647
648 #[tokio::test]
649 async fn emit_tool_token_with_string_content() {
650 let (tx, mut rx) = mpsc::channel(10);
651 let ctx = ToolExecutionContext {
652 session_id: None,
653 tool_call_id: "call_string",
654 event_tx: Some(&tx),
655 available_tool_schemas: None,
656 bypass_permissions: false,
657 can_async_resume: false,
658 bash_completion_sink: None,
659 pre_parsed_args: None,
660 };
661
662 let content = String::from("owned string");
663 ctx.emit_tool_token(content).await;
664
665 let event = rx.recv().await.unwrap();
666 match event {
667 AgentEvent::ToolToken { content, .. } => {
668 assert_eq!(content, "owned string");
669 }
670 other => panic!("Expected ToolToken, got: {other:?}"),
671 }
672 }
673
674 #[tokio::test]
675 async fn emit_tool_token_with_str_content() {
676 let (tx, mut rx) = mpsc::channel(10);
677 let ctx = ToolExecutionContext {
678 session_id: None,
679 tool_call_id: "call_str",
680 event_tx: Some(&tx),
681 available_tool_schemas: None,
682 bypass_permissions: false,
683 can_async_resume: false,
684 bash_completion_sink: None,
685 pre_parsed_args: None,
686 };
687
688 ctx.emit_tool_token("string slice").await;
689
690 let event = rx.recv().await.unwrap();
691 match event {
692 AgentEvent::ToolToken { content, .. } => {
693 assert_eq!(content, "string slice");
694 }
695 other => panic!("Expected ToolToken, got: {other:?}"),
696 }
697 }
698}