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    #[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        // Whether the executing loop can suspend for and self-resume a
146        // backgrounded bash shell (`bash_resume_hook` + persistence wired).
147        // When `false`, the Bash auto path stays synchronous (issue #84,
148        // phase 2d). NOT session-derived — set by the dispatch site.
149        can_async_resume: bool,
150        // Loop-facing sink for background-Bash completion (issue #84 Phase 2b
151        // follow-up). Set by the dispatch site from the loop config; `None` on
152        // loops without the engine suspend/resume machinery so the push stays
153        // inert. NOT session-derived.
154        bash_completion_sink: Option<&'a Arc<dyn BashCompletionSink>>,
155        // The call's arguments, already parsed once at the dispatch site (to
156        // populate the `ToolStart` event). Threaded down so the executor reuses
157        // it instead of re-parsing the raw JSON string (issue #106). Only pass
158        // `Some` when the value was produced by `parse_tool_args_best_effort`
159        // (the executor's own parser) so reuse is byte-for-byte equivalent; a
160        // dispatch site that parses with a different/stricter parser must pass
161        // `None` so the executor re-parses leniently and behavior is preserved.
162        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    /// Clone the sender (when present) for use in spawned tasks.
177    pub fn cloned_sender(&self) -> Option<mpsc::Sender<AgentEvent>> {
178        self.event_tx.cloned()
179    }
180
181    /// Clone the background-Bash completion sink (when present) into an owned
182    /// handle for a spawned task — mirrors [`Self::cloned_sender`]. Returns an
183    /// owned `Arc` so the shell's detached completion-poll task can outlive the
184    /// borrowed dispatch context.
185    pub fn cloned_bash_completion_sink(&self) -> Option<Arc<dyn BashCompletionSink>> {
186        self.bash_completion_sink.map(Arc::clone)
187    }
188
189    /// TRANSITIONAL bridge to the owned [`ToolCtx`](crate::tools::ToolCtx) that the
190    /// rewritten `Tool::invoke` takes. Clones this borrowed dispatch context into
191    /// owned/`Arc` form at the concrete-executor seam, so the trait + dispatch
192    /// path keep using `ToolExecutionContext` (no wide ripple) while tools run on
193    /// `ToolCtx`. Removed in Phase B when the dispatch path adopts `ToolCtx`
194    /// directly.
195    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    /// Best-effort emit of an event (ignored if no sender).
212    pub async fn emit(&self, event: AgentEvent) {
213        if let Some(tx) = self.event_tx {
214            // Tools sometimes want to stream incremental output. Historically they emitted
215            // `AgentEvent::Token`, but that mixes tool output into the assistant stream.
216            // When emitting from a tool context, treat `Token` as tool-scoped output.
217            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    /// Convenience helper for streaming tool-scoped output.
229    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        // Test with various non-Token events
388        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        // Should not panic or block
408        ctx.emit(AgentEvent::Token {
409            content: "test".to_string(),
410        })
411        .await;
412
413        // Success if we get here
414    }
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        // Should not panic or block
450        ctx.emit_tool_token("test").await;
451
452        // Success if we get here
453    }
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        // Can use cloned sender
488        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        // Can clone (Copy implies Clone)
544        let _cloned = ctx;
545
546        // Can copy
547        let copied = ctx;
548
549        // Both are valid
550        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}