Skip to main content

bamboo_agent_core/tools/
context.rs

1//! Execution context for tool calls.
2//!
3//! Tools normally return a single `ToolResult` after completion. Some tools
4//! (for example, long-running CLIs) may want to stream intermediate progress
5//! to clients. The agent loop passes a `ToolExecutionContext` that allows tools
6//! to emit `AgentEvent`s while they run.
7
8use 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/// Per-session flags that flow into every tool call's [`ToolExecutionContext`].
18///
19/// These are derived ONCE from the executing [`Session`] (via
20/// [`ToolExecutionSessionFlags::from_session`]) and copied into the context. To
21/// add a new per-session execution flag, add a field here, derive it in
22/// `from_session`, and map it in [`ToolExecutionContext::for_dispatch`]. Because
23/// both agent loops build their context through `for_dispatch`, a new flag
24/// reaches every dispatch path automatically — it can't be wired into one loop
25/// and silently skipped in the other.
26#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
27pub struct ToolExecutionSessionFlags {
28    /// When `true`, the session is in "bypass permissions" mode and tool
29    /// permission checks are skipped. Sourced from the session's runtime state.
30    pub bypass_permissions: bool,
31}
32
33impl ToolExecutionSessionFlags {
34    /// Derive the per-session tool-execution flags from a session's runtime
35    /// state. This is the single source of truth for both agent loops.
36    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/// Context passed to tools during execution.
47///
48/// All fields are optional and should be treated as best-effort hints.
49///
50/// ⚠️ Real tool dispatch must build this via [`ToolExecutionContext::for_dispatch`]
51/// (both agent loops do), NOT a struct literal — that routes every per-session
52/// flag through [`ToolExecutionSessionFlags`] so a new flag can't be wired into
53/// one loop and silently skipped in the other. Struct literals are for tests
54/// and tools that synthesize a child context.
55#[derive(Clone, Copy)]
56pub struct ToolExecutionContext<'a> {
57    /// Bamboo session id that is executing the tool.
58    pub session_id: Option<&'a str>,
59    /// Tool call id from the model (`ToolCall.id`).
60    pub tool_call_id: &'a str,
61    /// Event sender for streaming progress to clients (agent SSE stream).
62    pub event_tx: Option<&'a mpsc::Sender<AgentEvent>>,
63    /// Snapshot of tools currently available to the executing session.
64    pub available_tool_schemas: Option<&'a [ToolSchema]>,
65    /// When `true`, the executing session is in "bypass permissions" mode, so
66    /// tool permission checks are skipped. Sourced per-session from the
67    /// session's runtime state (`runtime.json`), not the global checker.
68    pub bypass_permissions: bool,
69    /// When `true`, the executing agent loop can suspend the current turn for a
70    /// backgrounded shell and self-resume once it finishes (i.e. a
71    /// `bash_resume_hook` AND persistence are wired). The Bash tool uses this to
72    /// decide whether its auto path (`run_in_background` omitted) may promote a
73    /// long command to background: when `false`, the auto path stays purely
74    /// synchronous so the command's output is never orphaned on a loop that
75    /// can't resume it (issue #84, phase 2d). Derived from the loop config at
76    /// the dispatch site — NOT session-derived — so it is a direct
77    /// `for_dispatch` parameter rather than a `ToolExecutionSessionFlags` field.
78    pub can_async_resume: bool,
79    /// Loop-facing sink invoked once when a background Bash shell owned by this
80    /// session completes (issue #84 Phase 2b follow-up). When wired, the Bash
81    /// tool hands it to the background completion-poll task so the shell's result
82    /// is pushed into the loop (injected at the next round boundary while it is
83    /// actively looping, or via a resume when it is idle) — instead of the model
84    /// having to poll `BashOutput`. Borrowed like `event_tx` (kept `Copy`) and
85    /// cloned into the spawned task via [`Self::cloned_bash_completion_sink`].
86    /// Derived from the loop config at the dispatch site — NOT session-derived —
87    /// so it is a direct `for_dispatch` parameter, not a session flag. `None`
88    /// leaves the push inert (the durable end-of-turn poll backstop still runs).
89    pub bash_completion_sink: Option<&'a Arc<dyn BashCompletionSink>>,
90    /// The tool call's `function.arguments` JSON string, already parsed once by
91    /// the dispatching agent loop (which also parses it to populate the
92    /// `ToolStart` event). When `Some`, downstream executors should reuse this
93    /// instead of calling `parse_tool_args_best_effort` on the raw string a
94    /// second time — the value here is the *exact* output of that same parser on
95    /// the same input, so reuse is behavior-preserving (issue #106, deferred B1
96    /// from #17). When `None` (e.g. `none()` contexts, tests, or executors that
97    /// synthesize a child call), executors parse the raw string themselves,
98    /// preserving the original single-parse-per-consumer behavior.
99    pub pre_parsed_args: Option<&'a Value>,
100}
101
102// Hand-written so implementors of `BashCompletionSink` (a trait object stored
103// here) don't have to be `Debug`. The sink is rendered as a presence flag.
104impl 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    /// Build a context for a real tool dispatch, applying every per-session flag
134    /// from [`ToolExecutionSessionFlags`]. This is the SINGLE place that maps
135    /// session flags onto the context, and the only constructor the agent loops
136    /// use — keep both loops (`per_call.rs`, `result_handler.rs`) on it so a new
137    /// per-session field reaches all dispatch paths without per-site edits.
138    pub fn for_dispatch(
139        session_id: &'a str,
140        tool_call_id: &'a str,
141        event_tx: &'a mpsc::Sender<AgentEvent>,
142        available_tool_schemas: &'a [ToolSchema],
143        flags: ToolExecutionSessionFlags,
144        // Whether the executing loop can suspend for and self-resume a
145        // backgrounded bash shell (`bash_resume_hook` + persistence wired).
146        // When `false`, the Bash auto path stays synchronous (issue #84,
147        // phase 2d). NOT session-derived — set by the dispatch site.
148        can_async_resume: bool,
149        // Loop-facing sink for background-Bash completion (issue #84 Phase 2b
150        // follow-up). Set by the dispatch site from the loop config; `None` on
151        // loops without the engine suspend/resume machinery so the push stays
152        // inert. NOT session-derived.
153        bash_completion_sink: Option<&'a Arc<dyn BashCompletionSink>>,
154        // The call's arguments, already parsed once at the dispatch site (to
155        // populate the `ToolStart` event). Threaded down so the executor reuses
156        // it instead of re-parsing the raw JSON string (issue #106). Only pass
157        // `Some` when the value was produced by `parse_tool_args_best_effort`
158        // (the executor's own parser) so reuse is byte-for-byte equivalent; a
159        // dispatch site that parses with a different/stricter parser must pass
160        // `None` so the executor re-parses leniently and behavior is preserved.
161        pre_parsed_args: Option<&'a Value>,
162    ) -> Self {
163        Self {
164            session_id: Some(session_id),
165            tool_call_id,
166            event_tx: Some(event_tx),
167            available_tool_schemas: Some(available_tool_schemas),
168            bypass_permissions: flags.bypass_permissions,
169            can_async_resume,
170            bash_completion_sink,
171            pre_parsed_args,
172        }
173    }
174
175    /// Clone the sender (when present) for use in spawned tasks.
176    pub fn cloned_sender(&self) -> Option<mpsc::Sender<AgentEvent>> {
177        self.event_tx.cloned()
178    }
179
180    /// Clone the background-Bash completion sink (when present) into an owned
181    /// handle for a spawned task — mirrors [`Self::cloned_sender`]. Returns an
182    /// owned `Arc` so the shell's detached completion-poll task can outlive the
183    /// borrowed dispatch context.
184    pub fn cloned_bash_completion_sink(&self) -> Option<Arc<dyn BashCompletionSink>> {
185        self.bash_completion_sink.map(Arc::clone)
186    }
187
188    /// TRANSITIONAL bridge to the owned [`ToolCtx`](crate::tools::ToolCtx) that the
189    /// rewritten `Tool::invoke` takes. Clones this borrowed dispatch context into
190    /// owned/`Arc` form at the concrete-executor seam, so the trait + dispatch
191    /// path keep using `ToolExecutionContext` (no wide ripple) while tools run on
192    /// `ToolCtx`. Removed in Phase B when the dispatch path adopts `ToolCtx`
193    /// directly.
194    pub fn to_tool_ctx(&self) -> crate::tools::ToolCtx {
195        crate::tools::ToolCtx {
196            session_id: self.session_id.map(Arc::from),
197            tool_call_id: Arc::from(self.tool_call_id),
198            event_tx: self.event_tx.cloned(),
199            available_tool_schemas: self
200                .available_tool_schemas
201                .map(Arc::from)
202                .unwrap_or_else(|| Arc::from(Vec::new())),
203            bypass_permissions: self.bypass_permissions,
204            can_async_resume: self.can_async_resume,
205            async_completion_sink: None,
206            bash_completion_sink: self.bash_completion_sink.map(Arc::clone),
207        }
208    }
209
210    /// Best-effort emit of an event (ignored if no sender).
211    pub async fn emit(&self, event: AgentEvent) {
212        if let Some(tx) = self.event_tx {
213            // Tools sometimes want to stream incremental output. Historically they emitted
214            // `AgentEvent::Token`, but that mixes tool output into the assistant stream.
215            // When emitting from a tool context, treat `Token` as tool-scoped output.
216            let event = match event {
217                AgentEvent::Token { content } => AgentEvent::ToolToken {
218                    tool_call_id: self.tool_call_id.to_string(),
219                    content,
220                },
221                other => other,
222            };
223            let _ = tx.try_send(event);
224        }
225    }
226
227    /// Convenience helper for streaming tool-scoped output.
228    pub async fn emit_tool_token(&self, content: impl Into<String>) {
229        self.emit(AgentEvent::ToolToken {
230            tool_call_id: self.tool_call_id.to_string(),
231            content: content.into(),
232        })
233        .await;
234    }
235}
236
237#[cfg(test)]
238mod session_flags_tests {
239    use super::*;
240    use bamboo_domain::AgentRuntimeState;
241
242    #[test]
243    fn from_session_defaults_false_without_runtime_state() {
244        let session = Session::new("s-none", "test-model");
245        assert_eq!(
246            ToolExecutionSessionFlags::from_session(&session),
247            ToolExecutionSessionFlags {
248                bypass_permissions: false
249            }
250        );
251    }
252
253    #[test]
254    fn from_session_reads_bypass_from_runtime_state() {
255        let mut session = Session::new("s-bypass", "test-model");
256        let mut runtime = AgentRuntimeState::new("run-1");
257        runtime.bypass_permissions = true;
258        session.agent_runtime_state = Some(runtime);
259        assert!(ToolExecutionSessionFlags::from_session(&session).bypass_permissions);
260    }
261
262    #[test]
263    fn for_dispatch_maps_flags_onto_context() {
264        let (tx, _rx) = mpsc::channel(1);
265        let ctx = ToolExecutionContext::for_dispatch(
266            "s1",
267            "call-1",
268            &tx,
269            &[],
270            ToolExecutionSessionFlags {
271                bypass_permissions: true,
272            },
273            true,
274            None,
275            None,
276        );
277        assert_eq!(ctx.session_id, Some("s1"));
278        assert!(ctx.bypass_permissions);
279        assert!(ctx.can_async_resume);
280        assert!(ctx.pre_parsed_args.is_none());
281    }
282
283    #[test]
284    fn for_dispatch_threads_pre_parsed_args() {
285        let (tx, _rx) = mpsc::channel(1);
286        let parsed = serde_json::json!({"v": "x"});
287        let ctx = ToolExecutionContext::for_dispatch(
288            "s1",
289            "call-1",
290            &tx,
291            &[],
292            ToolExecutionSessionFlags::default(),
293            false,
294            None,
295            Some(&parsed),
296        );
297        assert_eq!(ctx.pre_parsed_args, Some(&parsed));
298    }
299}
300
301#[cfg(test)]
302mod tests {
303    use super::*;
304
305    #[tokio::test]
306    async fn emit_does_not_block_when_channel_is_full() {
307        let (tx, mut rx) = mpsc::channel(1);
308        tx.send(AgentEvent::Token {
309            content: "full".to_string(),
310        })
311        .await
312        .unwrap();
313        let ctx = ToolExecutionContext {
314            session_id: Some("session_1"),
315            tool_call_id: "call_1",
316            event_tx: Some(&tx),
317            available_tool_schemas: None,
318            bypass_permissions: false,
319            can_async_resume: false,
320            bash_completion_sink: None,
321            pre_parsed_args: None,
322        };
323
324        tokio::time::timeout(
325            std::time::Duration::from_millis(100),
326            ctx.emit(AgentEvent::Token {
327                content: "next".to_string(),
328            }),
329        )
330        .await
331        .expect("emit should not block on full channel");
332
333        let first = rx.recv().await.unwrap();
334        match first {
335            AgentEvent::Token { content } => assert_eq!(content, "full"),
336            other => panic!("unexpected event: {other:?}"),
337        }
338    }
339
340    #[tokio::test]
341    async fn emit_converts_token_to_tool_token() {
342        let (tx, mut rx) = mpsc::channel(10);
343        let ctx = ToolExecutionContext {
344            session_id: Some("session_1"),
345            tool_call_id: "call_123",
346            event_tx: Some(&tx),
347            available_tool_schemas: None,
348            bypass_permissions: false,
349            can_async_resume: false,
350            bash_completion_sink: None,
351            pre_parsed_args: None,
352        };
353
354        ctx.emit(AgentEvent::Token {
355            content: "test content".to_string(),
356        })
357        .await;
358
359        let event = rx.recv().await.unwrap();
360        match event {
361            AgentEvent::ToolToken {
362                tool_call_id,
363                content,
364            } => {
365                assert_eq!(tool_call_id, "call_123");
366                assert_eq!(content, "test content");
367            }
368            other => panic!("Expected ToolToken, got: {other:?}"),
369        }
370    }
371
372    #[tokio::test]
373    async fn emit_passes_through_non_token_events() {
374        let (tx, mut rx) = mpsc::channel(10);
375        let ctx = ToolExecutionContext {
376            session_id: Some("session_1"),
377            tool_call_id: "call_456",
378            event_tx: Some(&tx),
379            available_tool_schemas: None,
380            bypass_permissions: false,
381            can_async_resume: false,
382            bash_completion_sink: None,
383            pre_parsed_args: None,
384        };
385
386        // Test with various non-Token events
387        ctx.emit(AgentEvent::ToolToken {
388            tool_call_id: "other".to_string(),
389            content: "direct tool token".to_string(),
390        })
391        .await;
392
393        let event = rx.recv().await.unwrap();
394        match event {
395            AgentEvent::ToolToken { content, .. } => {
396                assert_eq!(content, "direct tool token");
397            }
398            other => panic!("Expected ToolToken, got: {other:?}"),
399        }
400    }
401
402    #[tokio::test]
403    async fn emit_does_nothing_when_no_sender() {
404        let ctx = ToolExecutionContext::none("call_789");
405
406        // Should not panic or block
407        ctx.emit(AgentEvent::Token {
408            content: "test".to_string(),
409        })
410        .await;
411
412        // Success if we get here
413    }
414
415    #[tokio::test]
416    async fn emit_tool_token_convenience_method() {
417        let (tx, mut rx) = mpsc::channel(10);
418        let ctx = ToolExecutionContext {
419            session_id: None,
420            tool_call_id: "call_abc",
421            event_tx: Some(&tx),
422            available_tool_schemas: None,
423            bypass_permissions: false,
424            can_async_resume: false,
425            bash_completion_sink: None,
426            pre_parsed_args: None,
427        };
428
429        ctx.emit_tool_token("convenient output").await;
430
431        let event = rx.recv().await.unwrap();
432        match event {
433            AgentEvent::ToolToken {
434                tool_call_id,
435                content,
436            } => {
437                assert_eq!(tool_call_id, "call_abc");
438                assert_eq!(content, "convenient output");
439            }
440            other => panic!("Expected ToolToken, got: {other:?}"),
441        }
442    }
443
444    #[tokio::test]
445    async fn emit_tool_token_with_no_sender_does_nothing() {
446        let ctx = ToolExecutionContext::none("call_def");
447
448        // Should not panic or block
449        ctx.emit_tool_token("test").await;
450
451        // Success if we get here
452    }
453
454    #[test]
455    fn none_creates_context_with_no_optional_fields() {
456        let ctx = ToolExecutionContext::none("call_xyz");
457
458        assert_eq!(ctx.session_id, None);
459        assert_eq!(ctx.tool_call_id, "call_xyz");
460        assert!(ctx.event_tx.is_none());
461    }
462
463    #[test]
464    fn cloned_sender_returns_none_when_no_sender() {
465        let ctx = ToolExecutionContext::none("call_test");
466        assert!(ctx.cloned_sender().is_none());
467    }
468
469    #[tokio::test]
470    async fn cloned_sender_returns_clone_when_sender_present() {
471        let (tx, _rx) = mpsc::channel(10);
472        let ctx = ToolExecutionContext {
473            session_id: None,
474            tool_call_id: "call_clone",
475            event_tx: Some(&tx),
476            available_tool_schemas: None,
477            bypass_permissions: false,
478            can_async_resume: false,
479            bash_completion_sink: None,
480            pre_parsed_args: None,
481        };
482
483        let cloned = ctx.cloned_sender();
484        assert!(cloned.is_some());
485
486        // Can use cloned sender
487        cloned
488            .unwrap()
489            .send(AgentEvent::Token {
490                content: "test".to_string(),
491            })
492            .await
493            .unwrap();
494    }
495
496    #[tokio::test]
497    async fn emit_handles_multiple_sequential_calls() {
498        let (tx, mut rx) = mpsc::channel(10);
499        let ctx = ToolExecutionContext {
500            session_id: Some("session_multi"),
501            tool_call_id: "call_multi",
502            event_tx: Some(&tx),
503            available_tool_schemas: None,
504            bypass_permissions: false,
505            can_async_resume: false,
506            bash_completion_sink: None,
507            pre_parsed_args: None,
508        };
509
510        for i in 0..5 {
511            ctx.emit(AgentEvent::Token {
512                content: format!("message {}", i),
513            })
514            .await;
515        }
516
517        for i in 0..5 {
518            let event = rx.recv().await.unwrap();
519            match event {
520                AgentEvent::ToolToken { content, .. } => {
521                    assert_eq!(content, format!("message {}", i));
522                }
523                other => panic!("Expected ToolToken, got: {other:?}"),
524            }
525        }
526    }
527
528    #[test]
529    fn context_is_clone_and_copy() {
530        let (tx, _rx) = mpsc::channel(10);
531        let ctx = ToolExecutionContext {
532            session_id: Some("session_copy"),
533            tool_call_id: "call_copy",
534            event_tx: Some(&tx),
535            available_tool_schemas: None,
536            bypass_permissions: false,
537            can_async_resume: false,
538            bash_completion_sink: None,
539            pre_parsed_args: None,
540        };
541
542        // Can clone (Copy implies Clone)
543        let _cloned = ctx;
544
545        // Can copy
546        let copied = ctx;
547
548        // Both are valid
549        assert_eq!(copied.tool_call_id, "call_copy");
550    }
551
552    #[test]
553    fn context_is_debug() {
554        let ctx = ToolExecutionContext::none("call_debug");
555        let debug_str = format!("{:?}", ctx);
556        assert!(debug_str.contains("call_debug"));
557    }
558
559    #[tokio::test]
560    async fn emit_with_empty_tool_call_id() {
561        let (tx, mut rx) = mpsc::channel(10);
562        let ctx = ToolExecutionContext {
563            session_id: None,
564            tool_call_id: "",
565            event_tx: Some(&tx),
566            available_tool_schemas: None,
567            bypass_permissions: false,
568            can_async_resume: false,
569            bash_completion_sink: None,
570            pre_parsed_args: None,
571        };
572
573        ctx.emit(AgentEvent::Token {
574            content: "test".to_string(),
575        })
576        .await;
577
578        let event = rx.recv().await.unwrap();
579        match event {
580            AgentEvent::ToolToken { tool_call_id, .. } => {
581                assert_eq!(tool_call_id, "");
582            }
583            other => panic!("Expected ToolToken, got: {other:?}"),
584        }
585    }
586
587    #[tokio::test]
588    async fn emit_with_unicode_content() {
589        let (tx, mut rx) = mpsc::channel(10);
590        let ctx = ToolExecutionContext {
591            session_id: Some("会话"),
592            tool_call_id: "调用_123",
593            event_tx: Some(&tx),
594            available_tool_schemas: None,
595            bypass_permissions: false,
596            can_async_resume: false,
597            bash_completion_sink: None,
598            pre_parsed_args: None,
599        };
600
601        ctx.emit(AgentEvent::Token {
602            content: "测试内容 🎯".to_string(),
603        })
604        .await;
605
606        let event = rx.recv().await.unwrap();
607        match event {
608            AgentEvent::ToolToken {
609                tool_call_id,
610                content,
611            } => {
612                assert_eq!(tool_call_id, "调用_123");
613                assert_eq!(content, "测试内容 🎯");
614            }
615            other => panic!("Expected ToolToken, got: {other:?}"),
616        }
617    }
618
619    #[tokio::test]
620    async fn emit_with_special_characters_in_tool_call_id() {
621        let (tx, mut rx) = mpsc::channel(10);
622        let ctx = ToolExecutionContext {
623            session_id: None,
624            tool_call_id: "call-with_special.chars:123",
625            event_tx: Some(&tx),
626            available_tool_schemas: None,
627            bypass_permissions: false,
628            can_async_resume: false,
629            bash_completion_sink: None,
630            pre_parsed_args: None,
631        };
632
633        ctx.emit(AgentEvent::Token {
634            content: "test".to_string(),
635        })
636        .await;
637
638        let event = rx.recv().await.unwrap();
639        match event {
640            AgentEvent::ToolToken { tool_call_id, .. } => {
641                assert_eq!(tool_call_id, "call-with_special.chars:123");
642            }
643            other => panic!("Expected ToolToken, got: {other:?}"),
644        }
645    }
646
647    #[tokio::test]
648    async fn emit_tool_token_with_string_content() {
649        let (tx, mut rx) = mpsc::channel(10);
650        let ctx = ToolExecutionContext {
651            session_id: None,
652            tool_call_id: "call_string",
653            event_tx: Some(&tx),
654            available_tool_schemas: None,
655            bypass_permissions: false,
656            can_async_resume: false,
657            bash_completion_sink: None,
658            pre_parsed_args: None,
659        };
660
661        let content = String::from("owned string");
662        ctx.emit_tool_token(content).await;
663
664        let event = rx.recv().await.unwrap();
665        match event {
666            AgentEvent::ToolToken { content, .. } => {
667                assert_eq!(content, "owned string");
668            }
669            other => panic!("Expected ToolToken, got: {other:?}"),
670        }
671    }
672
673    #[tokio::test]
674    async fn emit_tool_token_with_str_content() {
675        let (tx, mut rx) = mpsc::channel(10);
676        let ctx = ToolExecutionContext {
677            session_id: None,
678            tool_call_id: "call_str",
679            event_tx: Some(&tx),
680            available_tool_schemas: None,
681            bypass_permissions: false,
682            can_async_resume: false,
683            bash_completion_sink: None,
684            pre_parsed_args: None,
685        };
686
687        ctx.emit_tool_token("string slice").await;
688
689        let event = rx.recv().await.unwrap();
690        match event {
691            AgentEvent::ToolToken { content, .. } => {
692                assert_eq!(content, "string slice");
693            }
694            other => panic!("Expected ToolToken, got: {other:?}"),
695        }
696    }
697}