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};
16use bamboo_domain::PermissionMode;
17
18/// Per-session flags that flow into every tool call's [`ToolExecutionContext`].
19///
20/// These are derived ONCE from the executing [`Session`] (via
21/// [`ToolExecutionSessionFlags::from_session`]) and copied into the context. To
22/// add a new per-session execution flag, add a field here, derive it in
23/// `from_session`, and map it in [`ToolExecutionContext::for_dispatch`]. Because
24/// both agent loops build their context through `for_dispatch`, a new flag
25/// reaches every dispatch path automatically — it can't be wired into one loop
26/// and silently skipped in the other.
27#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
28pub struct ToolExecutionSessionFlags {
29    /// When `true`, the session is in "bypass permissions" mode and tool
30    /// permission checks are skipped. Sourced from the session's runtime state.
31    pub bypass_permissions: bool,
32    /// When `true`, approval requests are suppressed even for hard-dangerous
33    /// and always-ask operations. Hard policy denials remain enforced.
34    pub auto_approve_permissions: bool,
35    /// Hard read-only authorization overlay. Independent from approval
36    /// suppression: Plan+Auto is no-prompt but still denies every mutating tool.
37    pub plan_read_only: bool,
38}
39
40impl ToolExecutionSessionFlags {
41    /// Derive the per-session tool-execution flags from a session's runtime
42    /// state. This is the single source of truth for both agent loops.
43    pub fn from_session(session: &Session) -> Self {
44        Self::from_session_and_configured_mode(session, PermissionMode::Default)
45    }
46
47    /// Derive flags from the typed session request and the process-wide mode.
48    /// This is the execution-boundary form: default sessions inherit global
49    /// Auto, explicit Bypass remains Bypass, and Plan produces neither
50    /// permissive flag.
51    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/// Context passed to tools during execution.
79///
80/// All fields are optional and should be treated as best-effort hints.
81///
82/// ⚠️ Real tool dispatch must build this via [`ToolExecutionContext::for_dispatch`]
83/// (both agent loops do), NOT a struct literal — that routes every per-session
84/// flag through [`ToolExecutionSessionFlags`] so a new flag can't be wired into
85/// one loop and silently skipped in the other. Struct literals are for tests
86/// and tools that synthesize a child context.
87#[derive(Clone, Copy)]
88pub struct ToolExecutionContext<'a> {
89    /// Bamboo session id that is executing the tool.
90    pub session_id: Option<&'a str>,
91    /// Tool call id from the model (`ToolCall.id`).
92    pub tool_call_id: &'a str,
93    /// Event sender for streaming progress to clients (agent SSE stream).
94    pub event_tx: Option<&'a mpsc::Sender<AgentEvent>>,
95    /// Snapshot of tools currently available to the executing session.
96    pub available_tool_schemas: Option<&'a [ToolSchema]>,
97    /// When `true`, the executing session is in "bypass permissions" mode, so
98    /// tool permission checks are skipped. Sourced per-session from the
99    /// session's runtime state (`runtime.json`), not the global checker.
100    pub bypass_permissions: bool,
101    /// Stronger, explicitly selected auto mode. This skips every approval
102    /// request but is still evaluated behind platform and explicit deny rules.
103    pub auto_approve_permissions: bool,
104    /// Hard Plan/read-only overlay, evaluated before checker/bypass/Auto paths.
105    pub plan_read_only: bool,
106    /// When `true`, the executing agent loop can suspend the current turn for a
107    /// backgrounded shell and self-resume once it finishes (i.e. a
108    /// `bash_resume_hook` AND persistence are wired). The Bash tool uses this to
109    /// decide whether its auto path (`run_in_background` omitted) may promote a
110    /// long command to background: when `false`, the auto path stays purely
111    /// synchronous so the command's output is never orphaned on a loop that
112    /// can't resume it (issue #84, phase 2d). Derived from the loop config at
113    /// the dispatch site — NOT session-derived — so it is a direct
114    /// `for_dispatch` parameter rather than a `ToolExecutionSessionFlags` field.
115    pub can_async_resume: bool,
116    /// Loop-facing sink invoked once when a background Bash shell owned by this
117    /// session completes (issue #84 Phase 2b follow-up). When wired, the Bash
118    /// tool hands it to the background completion-poll task so the shell's result
119    /// is pushed into the loop (injected at the next round boundary while it is
120    /// actively looping, or via a resume when it is idle) — instead of the model
121    /// having to poll `BashOutput`. Borrowed like `event_tx` (kept `Copy`) and
122    /// cloned into the spawned task via [`Self::cloned_bash_completion_sink`].
123    /// Derived from the loop config at the dispatch site — NOT session-derived —
124    /// so it is a direct `for_dispatch` parameter, not a session flag. `None`
125    /// leaves the push inert (the durable end-of-turn poll backstop still runs).
126    pub bash_completion_sink: Option<&'a Arc<dyn BashCompletionSink>>,
127    /// The tool call's `function.arguments` JSON string, already parsed once by
128    /// the dispatching agent loop (which also parses it to populate the
129    /// `ToolStart` event). When `Some`, downstream executors should reuse this
130    /// instead of calling `parse_tool_args_best_effort` on the raw string a
131    /// second time — the value here is the *exact* output of that same parser on
132    /// the same input, so reuse is behavior-preserving (issue #106, deferred B1
133    /// from #17). When `None` (e.g. `none()` contexts, tests, or executors that
134    /// synthesize a child call), executors parse the raw string themselves,
135    /// preserving the original single-parse-per-consumer behavior.
136    pub pre_parsed_args: Option<&'a Value>,
137}
138
139// Hand-written so implementors of `BashCompletionSink` (a trait object stored
140// here) don't have to be `Debug`. The sink is rendered as a presence flag.
141impl 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    /// Build a context for a real tool dispatch, applying every per-session flag
175    /// from [`ToolExecutionSessionFlags`]. This is the SINGLE place that maps
176    /// session flags onto the context, and the only constructor the agent loops
177    /// use — keep both loops (`per_call.rs`, `result_handler.rs`) on it so a new
178    /// per-session field reaches all dispatch paths without per-site edits.
179    #[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        // Whether the executing loop can suspend for and self-resume a
187        // backgrounded bash shell (`bash_resume_hook` + persistence wired).
188        // When `false`, the Bash auto path stays synchronous (issue #84,
189        // phase 2d). NOT session-derived — set by the dispatch site.
190        can_async_resume: bool,
191        // Loop-facing sink for background-Bash completion (issue #84 Phase 2b
192        // follow-up). Set by the dispatch site from the loop config; `None` on
193        // loops without the engine suspend/resume machinery so the push stays
194        // inert. NOT session-derived.
195        bash_completion_sink: Option<&'a Arc<dyn BashCompletionSink>>,
196        // The call's arguments, already parsed once at the dispatch site (to
197        // populate the `ToolStart` event). Threaded down so the executor reuses
198        // it instead of re-parsing the raw JSON string (issue #106). Only pass
199        // `Some` when the value was produced by `parse_tool_args_best_effort`
200        // (the executor's own parser) so reuse is byte-for-byte equivalent; a
201        // dispatch site that parses with a different/stricter parser must pass
202        // `None` so the executor re-parses leniently and behavior is preserved.
203        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    /// Clone the sender (when present) for use in spawned tasks.
220    pub fn cloned_sender(&self) -> Option<mpsc::Sender<AgentEvent>> {
221        self.event_tx.cloned()
222    }
223
224    /// Clone the background-Bash completion sink (when present) into an owned
225    /// handle for a spawned task — mirrors [`Self::cloned_sender`]. Returns an
226    /// owned `Arc` so the shell's detached completion-poll task can outlive the
227    /// borrowed dispatch context.
228    pub fn cloned_bash_completion_sink(&self) -> Option<Arc<dyn BashCompletionSink>> {
229        self.bash_completion_sink.map(Arc::clone)
230    }
231
232    /// TRANSITIONAL bridge to the owned [`ToolCtx`](crate::tools::ToolCtx) that the
233    /// rewritten `Tool::invoke` takes. Clones this borrowed dispatch context into
234    /// owned/`Arc` form at the concrete-executor seam, so the trait + dispatch
235    /// path keep using `ToolExecutionContext` (no wide ripple) while tools run on
236    /// `ToolCtx`. Removed in Phase B when the dispatch path adopts `ToolCtx`
237    /// directly.
238    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    /// Best-effort emit of an event (ignored if no sender).
257    pub async fn emit(&self, event: AgentEvent) {
258        if let Some(tx) = self.event_tx {
259            // Tools sometimes want to stream incremental output. Historically they emitted
260            // `AgentEvent::Token`, but that mixes tool output into the assistant stream.
261            // When emitting from a tool context, treat `Token` as tool-scoped output.
262            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    /// Convenience helper for streaming tool-scoped output.
274    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        // Test with various non-Token events
525        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        // Should not panic or block
545        ctx.emit(AgentEvent::Token {
546            content: "test".to_string(),
547        })
548        .await;
549
550        // Success if we get here
551    }
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        // Should not panic or block
589        ctx.emit_tool_token("test").await;
590
591        // Success if we get here
592    }
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        // Can use cloned sender
629        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        // Can clone (Copy implies Clone)
689        let _cloned = ctx;
690
691        // Can copy
692        let copied = ctx;
693
694        // Both are valid
695        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}