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, ToolCall, ToolSchema};
15use crate::{AgentEvent, Session};
16use bamboo_domain::{
17    PermissionMode, SessionAuthorityIdentity, SessionKind, SupervisorReference,
18    DEFAULT_SUPERVISOR_SESSION_ID,
19};
20use uuid::Uuid;
21
22tokio::task_local! {
23    static ROOT_ACTOR_TOOL_WRITER: Option<bamboo_domain::RootActorRuntimeWrite>;
24}
25
26/// Scope the actual execution's capability across asynchronous tool dispatch.
27/// It is never deserialized or inherited by a spawned Host reconciliation job.
28pub async fn with_root_actor_tool_writer<F: std::future::Future>(
29    owner: Option<bamboo_domain::RootActorRuntimeWrite>,
30    dispatch: F,
31) -> F::Output {
32    ROOT_ACTOR_TOOL_WRITER.scope(owner, dispatch).await
33}
34
35/// None is a Host job; Some(None) is a tool on an unbound execution. The latter
36/// must not mint a Root control execution to replace its missing runtime grant.
37pub fn root_actor_tool_writer() -> Option<Option<bamboo_domain::RootActorRuntimeWrite>> {
38    ROOT_ACTOR_TOOL_WRITER.try_with(Clone::clone).ok()
39}
40
41/// The lifetime observed when a real Supervisor Session admits a tool call.
42///
43/// This is not a grant: consumers must still check the reference against the
44/// canonical Supervisor service. It is intentionally separate from permission
45/// flags and is never deserialized from model arguments or permission lookups.
46#[derive(Clone, Copy, Debug, PartialEq, Eq)]
47pub struct ExecutingSupervisorObservation {
48    incarnation_id: Uuid,
49}
50
51/// Host metadata only; never copied from a tool's result payload.
52const SUPERVISOR_PERMISSION_REPLAY_METADATA_KEY: &str = "permission.executing_supervisor.v1";
53
54#[derive(serde::Serialize, serde::Deserialize)]
55#[serde(deny_unknown_fields)]
56struct SupervisorPermissionReplayRecord {
57    version: u8,
58    incarnation_id: Uuid,
59    session_id: String,
60    result_message_id: String,
61    request_generation: String,
62    tool_call: ToolCall,
63    execution_name: String,
64}
65
66impl ExecutingSupervisorObservation {
67    pub const PERMISSION_REPLAY_METADATA_KEY: &'static str =
68        SUPERVISOR_PERMISSION_REPLAY_METADATA_KEY;
69
70    /// Capture only from the Session actually executing the call, before its
71    /// context is queued or transferred. Never call this on a replacement
72    /// Session loaded to resolve configuration or replay an old operation.
73    pub fn capture_from_executing_session(session: &Session) -> Option<Self> {
74        session
75            .supervisor_incarnation_id()
76            .map(|incarnation_id| Self { incarnation_id })
77    }
78
79    /// Original identity for the canonical service to revalidate. Possession
80    /// of this observation does not attest that this lifetime still exists.
81    pub fn supervisor_reference(self) -> SupervisorReference {
82        SupervisorReference {
83            session_id: DEFAULT_SUPERVISOR_SESSION_ID.to_string(),
84            incarnation_id: self.incarnation_id,
85        }
86    }
87
88    /// Encode this already captured observation at the host's waiting-message
89    /// writer. The original operation and actual new result ID are immutable
90    /// bindings, not values to recover from model-visible result JSON.
91    pub fn permission_replay_record(
92        self,
93        session_id: &str,
94        result_message_id: &str,
95        tool_call: &ToolCall,
96        execution_name: &str,
97        request_generation: &str,
98    ) -> Value {
99        serde_json::to_value(SupervisorPermissionReplayRecord {
100            version: 1,
101            incarnation_id: self.incarnation_id,
102            session_id: session_id.to_string(),
103            result_message_id: result_message_id.to_string(),
104            request_generation: request_generation.to_string(),
105            tool_call: tool_call.clone(),
106            execution_name: execution_name.to_string(),
107        })
108        .expect("Supervisor permission binding serializes")
109    }
110
111    /// Narrow host restoration from an exact durable result occurrence. The
112    /// caller must additionally validate the typed request/receipt contract
113    /// before granting permissions or handing this observation to a tool.
114    /// This never captures an identity from the reloaded Session.
115    pub fn restore_permission_replay_record(
116        session: &Session,
117        result_index: usize,
118        tool_call: &ToolCall,
119        execution_name: &str,
120        request_generation: Option<&str>,
121    ) -> Result<Option<Self>, &'static str> {
122        let message = session
123            .messages
124            .get(result_index)
125            .ok_or("result occurrence missing")?;
126        if serde_json::from_str::<Value>(&message.content)
127            .ok()
128            .is_some_and(|payload| {
129                payload
130                    .get(SUPERVISOR_PERMISSION_REPLAY_METADATA_KEY)
131                    .is_some()
132            })
133        {
134            return Err("Supervisor authority is not accepted from a result payload");
135        }
136        let Some(value) = message
137            .metadata
138            .as_ref()
139            .and_then(|metadata| metadata.get(SUPERVISOR_PERMISSION_REPLAY_METADATA_KEY))
140        else {
141            return Ok(None);
142        };
143        let record: SupervisorPermissionReplayRecord = serde_json::from_value(value.clone())
144            .map_err(|_| "Supervisor permission binding is malformed")?;
145        let latest_result = session
146            .messages
147            .iter()
148            .rposition(|message| message.tool_call_id.as_deref() == Some(tool_call.id.as_str()));
149        let newer_call = session.messages[result_index + 1..].iter().any(|message| {
150            message
151                .tool_calls
152                .as_ref()
153                .is_some_and(|calls| calls.iter().any(|call| call.id == tool_call.id))
154        });
155        let preceding_call = session.messages[..result_index]
156            .iter()
157            .rev()
158            .find(|message| {
159                message
160                    .tool_calls
161                    .as_ref()
162                    .is_some_and(|calls| calls.iter().any(|call| call.id == tool_call.id))
163            });
164        let exact_call = preceding_call.is_some_and(|message| {
165            message.role == bamboo_domain::Role::Assistant
166                && message.tool_calls.as_ref().is_some_and(|calls| {
167                    let matching: Vec<_> = calls
168                        .iter()
169                        .filter(|call| call.id == tool_call.id)
170                        .collect();
171                    matching.len() == 1 && matching[0] == tool_call
172                })
173        });
174        if record.version != 1
175            || record.incarnation_id.is_nil()
176            || session.id != DEFAULT_SUPERVISOR_SESSION_ID
177            || session.kind != SessionKind::Root
178            || session.root_session_id != session.id
179            || session.parent_session_id.is_some()
180            || session.spawn_depth != 0
181            || session.authority_identity
182                != (SessionAuthorityIdentity::Supervisor {
183                    incarnation_id: record.incarnation_id,
184                })
185            || message.role != bamboo_domain::Role::Tool
186            || latest_result != Some(result_index)
187            || newer_call
188            || !exact_call
189            || record.session_id != session.id
190            || record.result_message_id != message.id
191            || record.tool_call != *tool_call
192            || record.execution_name != execution_name
193            || execution_name.trim().is_empty()
194            || record.request_generation.trim().is_empty()
195            || Some(record.request_generation.as_str()) != request_generation
196        {
197            return Err("Supervisor permission binding does not match the current operation");
198        }
199        Ok(Some(Self {
200            incarnation_id: record.incarnation_id,
201        }))
202    }
203
204    pub(super) fn for_caller(self, session_id: Option<&str>) -> Option<Self> {
205        (session_id == Some(DEFAULT_SUPERVISOR_SESSION_ID)).then_some(self)
206    }
207}
208
209/// Per-session flags that flow into every tool call's [`ToolExecutionContext`].
210///
211/// These are derived ONCE from the executing [`Session`] (via
212/// [`ToolExecutionSessionFlags::from_session`]) and copied into the context. To
213/// add a new per-session execution flag, add a field here, derive it in
214/// `from_session`, and map it in [`ToolExecutionContext::for_dispatch`]. Because
215/// both agent loops build their context through `for_dispatch`, a new flag
216/// reaches every dispatch path automatically — it can't be wired into one loop
217/// and silently skipped in the other.
218#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
219pub struct ToolExecutionSessionFlags {
220    /// When `true`, the session is in "bypass permissions" mode and tool
221    /// permission checks are skipped. Sourced from the session's runtime state.
222    pub bypass_permissions: bool,
223    /// When `true`, approval requests are suppressed even for hard-dangerous
224    /// and always-ask operations. Hard policy denials remain enforced.
225    pub auto_approve_permissions: bool,
226    /// Hard read-only authorization overlay. Independent from approval
227    /// suppression: Plan+Auto is no-prompt but still denies every mutating tool.
228    pub plan_read_only: bool,
229}
230
231impl ToolExecutionSessionFlags {
232    /// Derive the per-session tool-execution flags from a session's runtime
233    /// state. This is the single source of truth for both agent loops.
234    pub fn from_session(session: &Session) -> Self {
235        Self::from_session_and_configured_mode(session, PermissionMode::Default)
236    }
237
238    /// Derive flags from the typed session request and the process-wide mode.
239    /// This is the execution-boundary form: default sessions inherit global
240    /// Auto, explicit Bypass remains Bypass, and Plan produces neither
241    /// permissive flag.
242    pub fn from_session_and_configured_mode(
243        session: &Session,
244        configured_mode: PermissionMode,
245    ) -> Self {
246        let requested = session
247            .agent_runtime_state
248            .as_ref()
249            .map(|state| state.effective_permission_mode())
250            .unwrap_or_default();
251        let configured_mode = if session
252            .agent_runtime_state
253            .as_ref()
254            .is_some_and(|state| state.plan_mode.is_some())
255        {
256            PermissionMode::Plan
257        } else {
258            configured_mode
259        };
260        let resolution = bamboo_domain::resolve_permission_mode(requested, configured_mode);
261        Self {
262            bypass_permissions: resolution.bypass_permissions(),
263            auto_approve_permissions: resolution.auto_approve_permissions(),
264            plan_read_only: resolution.effective == PermissionMode::Plan,
265        }
266    }
267}
268
269/// Context passed to tools during execution.
270///
271/// Optional context does not grant authority. A captured Supervisor observation
272/// remains tied to its original caller and requires canonical revalidation.
273///
274/// ⚠️ Real tool dispatch must build this via [`ToolExecutionContext::for_dispatch`]
275/// (both agent loops do), NOT a struct literal — that routes every per-session
276/// flag through [`ToolExecutionSessionFlags`] so a new flag can't be wired into
277/// one loop and silently skipped in the other. Struct literals are for tests
278/// and tools that synthesize a child context.
279#[derive(Clone, Copy)]
280pub struct ToolExecutionContext<'a> {
281    /// Bamboo session id that is executing the tool.
282    pub session_id: Option<&'a str>,
283    /// Original executing lifetime, absent for synthetic/permission-only paths.
284    pub executing_supervisor: Option<ExecutingSupervisorObservation>,
285    /// Authoritative root-session identity for the executing session tree.
286    /// Real dispatch snapshots it from `Session.root_session_id`; synthetic or
287    /// opaque direct contexts leave it absent rather than inventing authority.
288    pub root_session_id: Option<&'a str>,
289    /// Tool call id from the model (`ToolCall.id`).
290    pub tool_call_id: &'a str,
291    /// Event sender for streaming progress to clients (agent SSE stream).
292    pub event_tx: Option<&'a mpsc::Sender<AgentEvent>>,
293    /// Snapshot of tools currently available to the executing session.
294    pub available_tool_schemas: Option<&'a [ToolSchema]>,
295    /// When `true`, the executing session is in "bypass permissions" mode, so
296    /// tool permission checks are skipped. Sourced per-session from the
297    /// session's runtime state (`runtime.json`), not the global checker.
298    pub bypass_permissions: bool,
299    /// Stronger, explicitly selected auto mode. This skips every approval
300    /// request but is still evaluated behind platform and explicit deny rules.
301    pub auto_approve_permissions: bool,
302    /// Hard Plan/read-only overlay, evaluated before checker/bypass/Auto paths.
303    pub plan_read_only: bool,
304    /// When `true`, the executing agent loop can suspend the current turn for a
305    /// backgrounded shell and self-resume once it finishes (i.e. a
306    /// `bash_resume_hook` AND persistence are wired). The Bash tool uses this to
307    /// decide whether its auto path (`run_in_background` omitted) may promote a
308    /// long command to background: when `false`, the auto path stays purely
309    /// synchronous so the command's output is never orphaned on a loop that
310    /// can't resume it (issue #84, phase 2d). Derived from the loop config at
311    /// the dispatch site — NOT session-derived — so it is a direct
312    /// `for_dispatch` parameter rather than a `ToolExecutionSessionFlags` field.
313    pub can_async_resume: bool,
314    /// Loop-facing sink invoked once when a background Bash shell owned by this
315    /// session completes (issue #84 Phase 2b follow-up). When wired, the Bash
316    /// tool hands it to the background completion-poll task so the shell's result
317    /// is pushed into the loop (injected at the next round boundary while it is
318    /// actively looping, or via a resume when it is idle) — instead of the model
319    /// having to poll `BashOutput`. Borrowed like `event_tx` (kept `Copy`) and
320    /// cloned into the spawned task via [`Self::cloned_bash_completion_sink`].
321    /// Derived from the loop config at the dispatch site — NOT session-derived —
322    /// so it is a direct `for_dispatch` parameter, not a session flag. `None`
323    /// leaves the push inert (the durable end-of-turn poll backstop still runs).
324    pub bash_completion_sink: Option<&'a Arc<dyn BashCompletionSink>>,
325    /// The tool call's `function.arguments` JSON string, already parsed once by
326    /// the dispatching agent loop (which also parses it to populate the
327    /// `ToolStart` event). When `Some`, downstream executors should reuse this
328    /// instead of calling `parse_tool_args_best_effort` on the raw string a
329    /// second time — the value here is the *exact* output of that same parser on
330    /// the same input, so reuse is behavior-preserving (issue #106, deferred B1
331    /// from #17). When `None` (e.g. `none()` contexts, tests, or executors that
332    /// synthesize a child call), executors parse the raw string themselves,
333    /// preserving the original single-parse-per-consumer behavior.
334    pub pre_parsed_args: Option<&'a Value>,
335}
336
337// Hand-written so implementors of `BashCompletionSink` (a trait object stored
338// here) don't have to be `Debug`. The sink is rendered as a presence flag.
339impl std::fmt::Debug for ToolExecutionContext<'_> {
340    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
341        f.debug_struct("ToolExecutionContext")
342            .field("session_id", &self.session_id)
343            .field("executing_supervisor", &self.executing_supervisor)
344            .field("root_session_id", &self.root_session_id)
345            .field("tool_call_id", &self.tool_call_id)
346            .field("event_tx", &self.event_tx)
347            .field("available_tool_schemas", &self.available_tool_schemas)
348            .field("bypass_permissions", &self.bypass_permissions)
349            .field("auto_approve_permissions", &self.auto_approve_permissions)
350            .field("plan_read_only", &self.plan_read_only)
351            .field("can_async_resume", &self.can_async_resume)
352            .field("bash_completion_sink", &self.bash_completion_sink.is_some())
353            .field("pre_parsed_args", &self.pre_parsed_args)
354            .finish()
355    }
356}
357
358impl<'a> ToolExecutionContext<'a> {
359    pub fn none(tool_call_id: &'a str) -> Self {
360        Self {
361            session_id: None,
362            executing_supervisor: None,
363            root_session_id: None,
364            tool_call_id,
365            event_tx: None,
366            available_tool_schemas: None,
367            bypass_permissions: false,
368            auto_approve_permissions: false,
369            plan_read_only: false,
370            can_async_resume: false,
371            bash_completion_sink: None,
372            pre_parsed_args: None,
373        }
374    }
375
376    /// Build a context applying every permission flag from
377    /// [`ToolExecutionSessionFlags`], without minting executing authority.
378    /// Real dispatch separately captures its executing Session observation and
379    /// retains it through [`Self::with_executing_supervisor`]. This maps
380    /// session flags onto the context, and the only constructor the agent loops
381    /// use — keep both loops (`per_call.rs`, `result_handler.rs`) on it so a new
382    /// per-session field reaches all dispatch paths without per-site edits.
383    #[allow(clippy::too_many_arguments)]
384    pub fn for_dispatch(
385        session_id: &'a str,
386        root_session_id: &'a str,
387        tool_call_id: &'a str,
388        event_tx: &'a mpsc::Sender<AgentEvent>,
389        available_tool_schemas: &'a [ToolSchema],
390        flags: ToolExecutionSessionFlags,
391        // Whether the executing loop can suspend for and self-resume a
392        // backgrounded bash shell (`bash_resume_hook` + persistence wired).
393        // When `false`, the Bash auto path stays synchronous (issue #84,
394        // phase 2d). NOT session-derived — set by the dispatch site.
395        can_async_resume: bool,
396        // Loop-facing sink for background-Bash completion (issue #84 Phase 2b
397        // follow-up). Set by the dispatch site from the loop config; `None` on
398        // loops without the engine suspend/resume machinery so the push stays
399        // inert. NOT session-derived.
400        bash_completion_sink: Option<&'a Arc<dyn BashCompletionSink>>,
401        // The call's arguments, already parsed once at the dispatch site (to
402        // populate the `ToolStart` event). Threaded down so the executor reuses
403        // it instead of re-parsing the raw JSON string (issue #106). Only pass
404        // `Some` when the value was produced by `parse_tool_args_best_effort`
405        // (the executor's own parser) so reuse is byte-for-byte equivalent; a
406        // dispatch site that parses with a different/stricter parser must pass
407        // `None` so the executor re-parses leniently and behavior is preserved.
408        pre_parsed_args: Option<&'a Value>,
409    ) -> Self {
410        Self {
411            session_id: Some(session_id),
412            executing_supervisor: None,
413            root_session_id: Some(if root_session_id.trim().is_empty() {
414                session_id
415            } else {
416                root_session_id
417            }),
418            tool_call_id,
419            event_tx: Some(event_tx),
420            available_tool_schemas: Some(available_tool_schemas),
421            bypass_permissions: flags.bypass_permissions,
422            auto_approve_permissions: flags.auto_approve_permissions,
423            plan_read_only: flags.plan_read_only,
424            can_async_resume,
425            bash_completion_sink,
426            pre_parsed_args,
427        }
428    }
429
430    /// Retain an already captured observation only for its original caller.
431    /// A child or absent caller cannot inherit Supervisor identity.
432    pub fn with_executing_supervisor(
433        mut self,
434        observation: Option<ExecutingSupervisorObservation>,
435    ) -> Self {
436        self.executing_supervisor =
437            observation.and_then(|observation| observation.for_caller(self.session_id));
438        self
439    }
440
441    /// Clone the sender (when present) for use in spawned tasks.
442    pub fn cloned_sender(&self) -> Option<mpsc::Sender<AgentEvent>> {
443        self.event_tx.cloned()
444    }
445
446    /// Clone the background-Bash completion sink (when present) into an owned
447    /// handle for a spawned task — mirrors [`Self::cloned_sender`]. Returns an
448    /// owned `Arc` so the shell's detached completion-poll task can outlive the
449    /// borrowed dispatch context.
450    pub fn cloned_bash_completion_sink(&self) -> Option<Arc<dyn BashCompletionSink>> {
451        self.bash_completion_sink.map(Arc::clone)
452    }
453
454    /// Authoritative root-session identity captured with this dispatch.
455    pub fn root_session_id(&self) -> Option<&'a str> {
456        self.root_session_id
457    }
458
459    /// TRANSITIONAL bridge to the owned [`ToolCtx`](crate::tools::ToolCtx) that the
460    /// rewritten `Tool::invoke` takes. Clones this borrowed dispatch context into
461    /// owned/`Arc` form at the concrete-executor seam, so the trait + dispatch
462    /// path keep using `ToolExecutionContext` (no wide ripple) while tools run on
463    /// `ToolCtx`. Removed in Phase B when the dispatch path adopts `ToolCtx`
464    /// directly.
465    pub fn to_tool_ctx(&self) -> crate::tools::ToolCtx {
466        crate::tools::ToolCtx {
467            session_id: self.session_id.map(Arc::from),
468            executing_supervisor: self
469                .executing_supervisor
470                .and_then(|observation| observation.for_caller(self.session_id)),
471            tool_call_id: Arc::from(self.tool_call_id),
472            event_tx: self.event_tx.cloned(),
473            available_tool_schemas: self
474                .available_tool_schemas
475                .map(Arc::from)
476                .unwrap_or_else(|| Arc::from(Vec::new())),
477            bypass_permissions: self.bypass_permissions,
478            auto_approve_permissions: self.auto_approve_permissions,
479            plan_read_only: self.plan_read_only,
480            can_async_resume: self.can_async_resume,
481            async_completion_sink: None,
482            bash_completion_sink: self.bash_completion_sink.map(Arc::clone),
483        }
484    }
485
486    /// Best-effort emit of an event (ignored if no sender).
487    pub async fn emit(&self, event: AgentEvent) {
488        if let Some(tx) = self.event_tx {
489            // Tools sometimes want to stream incremental output. Historically they emitted
490            // `AgentEvent::Token`, but that mixes tool output into the assistant stream.
491            // When emitting from a tool context, treat `Token` as tool-scoped output.
492            let event = match event {
493                AgentEvent::Token { content } => AgentEvent::ToolToken {
494                    tool_call_id: self.tool_call_id.to_string(),
495                    content,
496                },
497                other => other,
498            };
499            let _ = tx.try_send(event);
500        }
501    }
502
503    /// Convenience helper for streaming tool-scoped output.
504    pub async fn emit_tool_token(&self, content: impl Into<String>) {
505        self.emit(AgentEvent::ToolToken {
506            tool_call_id: self.tool_call_id.to_string(),
507            content: content.into(),
508        })
509        .await;
510    }
511}
512
513#[cfg(test)]
514mod supervisor_observation_tests {
515    use super::*;
516
517    fn supervisor() -> Session {
518        let mut session = Session::new(DEFAULT_SUPERVISOR_SESSION_ID, "model");
519        session.authority_identity = SessionAuthorityIdentity::Supervisor {
520            incarnation_id: Uuid::new_v4(),
521        };
522        session
523    }
524
525    #[test]
526    fn supervisor_observation_requires_typed_canonical_root_lineage() {
527        let valid = supervisor();
528        let observed = ExecutingSupervisorObservation::capture_from_executing_session(&valid)
529            .expect("canonical executing Supervisor");
530        assert_eq!(observed.supervisor_reference().session_id, valid.id);
531        for mutation in [
532            |s: &mut Session| s.authority_identity = SessionAuthorityIdentity::Ordinary,
533            |s: &mut Session| s.id = "other".into(),
534            |s: &mut Session| s.root_session_id = "other".into(),
535            |s: &mut Session| s.parent_session_id = Some("parent".into()),
536            |s: &mut Session| s.spawn_depth = 1,
537            |s: &mut Session| s.kind = SessionKind::Child,
538            |s: &mut Session| {
539                s.authority_identity = SessionAuthorityIdentity::Supervisor {
540                    incarnation_id: Uuid::nil(),
541                }
542            },
543        ] {
544            let mut malformed = valid.clone();
545            mutation(&mut malformed);
546            assert_eq!(
547                ExecutingSupervisorObservation::capture_from_executing_session(&malformed),
548                None,
549                "malformed Session: {malformed:?}"
550            );
551        }
552        let mut ordinary = Session::new(DEFAULT_SUPERVISOR_SESSION_ID, "model");
553        ordinary.metadata.insert(
554            "authority_identity".into(),
555            serde_json::to_string(&valid.authority_identity).unwrap(),
556        );
557        ordinary.metadata.insert("supervisor".into(), "true".into());
558        assert_eq!(
559            ExecutingSupervisorObservation::capture_from_executing_session(&ordinary),
560            None,
561            "reserved ID and forged metadata cannot supply a typed identity"
562        );
563    }
564
565    #[test]
566    fn supervisor_observation_is_not_minted_by_flags_arguments_or_generic_contexts() {
567        let session = supervisor();
568        let (tx, _rx) = mpsc::channel(1);
569        let forged_args = serde_json::json!({
570            "executing_supervisor": session.authority_identity,
571            "session_id": DEFAULT_SUPERVISOR_SESSION_ID,
572        });
573        let ctx = ToolExecutionContext::for_dispatch(
574            &session.id,
575            &session.root_session_id,
576            "call",
577            &tx,
578            &[],
579            ToolExecutionSessionFlags {
580                bypass_permissions: true,
581                auto_approve_permissions: true,
582                plan_read_only: true,
583            },
584            false,
585            None,
586            Some(&forged_args),
587        );
588        assert_eq!(ctx.executing_supervisor, None);
589        assert_eq!(ctx.to_tool_ctx().executing_supervisor, None);
590        assert_eq!(
591            ToolExecutionContext::none("none").executing_supervisor,
592            None
593        );
594        assert_eq!(
595            crate::tools::ToolCtx::none("owned").executing_supervisor,
596            None
597        );
598
599        let observation = ExecutingSupervisorObservation::capture_from_executing_session(&session);
600        let captured = ctx.with_executing_supervisor(observation);
601        let owned = captured.to_tool_ctx().clone();
602        assert_eq!(owned.executing_supervisor_for(&session.id), observation);
603        assert_eq!(owned.executing_supervisor_for("child"), None);
604        assert_eq!(
605            ToolExecutionContext::none("missing")
606                .with_executing_supervisor(observation)
607                .executing_supervisor,
608            None
609        );
610        // Even a struct-update caller mismatch is filtered at the owned seam.
611        let mismatched = ToolExecutionContext {
612            session_id: Some("child"),
613            ..captured
614        };
615        assert_eq!(mismatched.to_tool_ctx().executing_supervisor, None);
616    }
617}
618
619#[cfg(test)]
620mod session_flags_tests {
621    use super::*;
622    use bamboo_domain::{AgentRuntimeState, SessionPermissionMode};
623
624    #[test]
625    fn dispatch_context_falls_back_from_empty_legacy_root_to_session_id() {
626        let (event_tx, _event_rx) = mpsc::channel(1);
627        let ctx = ToolExecutionContext::for_dispatch(
628            "session-id",
629            "  ",
630            "call-id",
631            &event_tx,
632            &[],
633            ToolExecutionSessionFlags::default(),
634            false,
635            None,
636            None,
637        );
638
639        assert_eq!(ctx.root_session_id(), Some("session-id"));
640    }
641
642    #[test]
643    fn from_session_defaults_false_without_runtime_state() {
644        let session = Session::new("s-none", "test-model");
645        assert_eq!(
646            ToolExecutionSessionFlags::from_session(&session),
647            ToolExecutionSessionFlags {
648                bypass_permissions: false,
649                auto_approve_permissions: false,
650                plan_read_only: false,
651            }
652        );
653    }
654
655    #[test]
656    fn from_session_reads_bypass_from_runtime_state() {
657        let mut session = Session::new("s-bypass", "test-model");
658        let mut runtime = AgentRuntimeState::new("run-1");
659        runtime.bypass_permissions = true;
660        session.agent_runtime_state = Some(runtime);
661        assert!(ToolExecutionSessionFlags::from_session(&session).bypass_permissions);
662        assert!(!ToolExecutionSessionFlags::from_session(&session).auto_approve_permissions);
663    }
664
665    #[test]
666    fn from_session_distinguishes_auto_from_legacy_bypass() {
667        let mut session = Session::new("s-auto", "test-model");
668        let mut runtime = AgentRuntimeState::new("run-1");
669        runtime.set_permission_mode(SessionPermissionMode::Auto);
670        session.agent_runtime_state = Some(runtime);
671
672        let flags = ToolExecutionSessionFlags::from_session(&session);
673        assert!(!flags.bypass_permissions);
674        assert!(flags.auto_approve_permissions);
675    }
676
677    #[test]
678    fn configured_auto_applies_to_default_but_not_explicit_bypass() {
679        let default_session = Session::new("s-global-auto", "test-model");
680        let flags = ToolExecutionSessionFlags::from_session_and_configured_mode(
681            &default_session,
682            PermissionMode::Auto,
683        );
684        assert_eq!(
685            flags,
686            ToolExecutionSessionFlags {
687                bypass_permissions: false,
688                auto_approve_permissions: true,
689                plan_read_only: false,
690            }
691        );
692
693        let mut bypass_session = default_session;
694        bypass_session
695            .agent_runtime_state
696            .get_or_insert_default()
697            .set_permission_mode(SessionPermissionMode::Bypass);
698        let flags = ToolExecutionSessionFlags::from_session_and_configured_mode(
699            &bypass_session,
700            PermissionMode::Auto,
701        );
702        assert!(flags.bypass_permissions);
703        assert!(!flags.auto_approve_permissions);
704    }
705
706    #[test]
707    fn configured_plan_preserves_no_prompt_but_sets_read_only_gate() {
708        let mut session = Session::new("s-plan-auto", "test-model");
709        session
710            .agent_runtime_state
711            .get_or_insert_default()
712            .set_permission_mode(SessionPermissionMode::Auto);
713        let flags = ToolExecutionSessionFlags::from_session_and_configured_mode(
714            &session,
715            PermissionMode::Plan,
716        );
717        assert!(!flags.bypass_permissions);
718        assert!(flags.auto_approve_permissions);
719        assert!(flags.plan_read_only);
720    }
721
722    #[test]
723    fn persisted_plan_overlay_is_honored_without_config_reconstruction() {
724        let mut session = Session::new("s-persisted-plan-auto", "test-model");
725        let runtime = session.agent_runtime_state.get_or_insert_default();
726        runtime.set_permission_mode(SessionPermissionMode::Auto);
727        runtime.plan_mode = Some(bamboo_domain::PlanModeState {
728            entered_at: chrono::Utc::now(),
729            pre_permission_mode: "auto".to_string(),
730            plan_file_path: None,
731            status: bamboo_domain::PlanModeStatus::Designing,
732        });
733
734        let flags = ToolExecutionSessionFlags::from_session(&session);
735        assert!(flags.plan_read_only);
736        assert!(flags.auto_approve_permissions);
737        assert!(!flags.bypass_permissions);
738    }
739
740    #[test]
741    fn typed_read_only_child_does_not_reactivate_the_legacy_plan_name_gate() {
742        for requested in [SessionPermissionMode::Auto, SessionPermissionMode::Bypass] {
743            let mut session = Session::new("s-read-only-child", "test-model");
744            let runtime = session.agent_runtime_state.get_or_insert_default();
745            runtime.set_permission_mode(requested);
746            runtime.read_only = true;
747
748            // Runtime-enforced read-only children use a host-provisioned
749            // no-shell denylist plus ReadOnlyCommandChecker. Keep the typed
750            // child's exact Auto/Bypass flags here; the checker remains the
751            // non-bypassable authority for every permission-bearing call.
752            let flags = ToolExecutionSessionFlags::from_session_and_configured_mode(
753                &session,
754                PermissionMode::Auto,
755            );
756
757            assert!(!flags.plan_read_only);
758            assert_eq!(
759                flags.bypass_permissions,
760                requested == SessionPermissionMode::Bypass
761            );
762            assert_eq!(
763                flags.auto_approve_permissions,
764                requested == SessionPermissionMode::Auto
765            );
766        }
767    }
768
769    #[test]
770    fn for_dispatch_maps_flags_onto_context() {
771        let (tx, _rx) = mpsc::channel(1);
772        let ctx = ToolExecutionContext::for_dispatch(
773            "s1",
774            "root-s1",
775            "call-1",
776            &tx,
777            &[],
778            ToolExecutionSessionFlags {
779                bypass_permissions: true,
780                auto_approve_permissions: false,
781                plan_read_only: false,
782            },
783            true,
784            None,
785            None,
786        );
787        assert_eq!(ctx.session_id, Some("s1"));
788        assert_eq!(ctx.root_session_id(), Some("root-s1"));
789        assert!(ctx.bypass_permissions);
790        assert!(!ctx.auto_approve_permissions);
791        assert!(ctx.can_async_resume);
792        assert!(ctx.pre_parsed_args.is_none());
793
794        let owned = ctx.to_tool_ctx();
795        assert!(owned.bypass_permissions);
796        assert!(!owned.auto_approve_permissions);
797        assert!(!owned.plan_read_only);
798    }
799
800    #[test]
801    fn for_dispatch_threads_pre_parsed_args() {
802        let (tx, _rx) = mpsc::channel(1);
803        let parsed = serde_json::json!({"v": "x"});
804        let ctx = ToolExecutionContext::for_dispatch(
805            "s1",
806            "root-s1",
807            "call-1",
808            &tx,
809            &[],
810            ToolExecutionSessionFlags::default(),
811            false,
812            None,
813            Some(&parsed),
814        );
815        assert_eq!(ctx.pre_parsed_args, Some(&parsed));
816    }
817}
818
819#[cfg(test)]
820mod tests {
821    use super::*;
822
823    #[tokio::test]
824    async fn emit_does_not_block_when_channel_is_full() {
825        let (tx, mut rx) = mpsc::channel(1);
826        tx.send(AgentEvent::Token {
827            content: "full".to_string(),
828        })
829        .await
830        .unwrap();
831        let ctx = ToolExecutionContext {
832            executing_supervisor: None,
833            session_id: Some("session_1"),
834            root_session_id: None,
835            tool_call_id: "call_1",
836            event_tx: Some(&tx),
837            available_tool_schemas: None,
838            bypass_permissions: false,
839            auto_approve_permissions: false,
840            plan_read_only: false,
841            can_async_resume: false,
842            bash_completion_sink: None,
843            pre_parsed_args: None,
844        };
845
846        tokio::time::timeout(
847            std::time::Duration::from_millis(100),
848            ctx.emit(AgentEvent::Token {
849                content: "next".to_string(),
850            }),
851        )
852        .await
853        .expect("emit should not block on full channel");
854
855        let first = rx.recv().await.unwrap();
856        match first {
857            AgentEvent::Token { content } => assert_eq!(content, "full"),
858            other => panic!("unexpected event: {other:?}"),
859        }
860    }
861
862    #[tokio::test]
863    async fn emit_converts_token_to_tool_token() {
864        let (tx, mut rx) = mpsc::channel(10);
865        let ctx = ToolExecutionContext {
866            executing_supervisor: None,
867            session_id: Some("session_1"),
868            root_session_id: None,
869            tool_call_id: "call_123",
870            event_tx: Some(&tx),
871            available_tool_schemas: None,
872            bypass_permissions: false,
873            auto_approve_permissions: false,
874            plan_read_only: false,
875            can_async_resume: false,
876            bash_completion_sink: None,
877            pre_parsed_args: None,
878        };
879
880        ctx.emit(AgentEvent::Token {
881            content: "test content".to_string(),
882        })
883        .await;
884
885        let event = rx.recv().await.unwrap();
886        match event {
887            AgentEvent::ToolToken {
888                tool_call_id,
889                content,
890            } => {
891                assert_eq!(tool_call_id, "call_123");
892                assert_eq!(content, "test content");
893            }
894            other => panic!("Expected ToolToken, got: {other:?}"),
895        }
896    }
897
898    #[tokio::test]
899    async fn emit_passes_through_non_token_events() {
900        let (tx, mut rx) = mpsc::channel(10);
901        let ctx = ToolExecutionContext {
902            executing_supervisor: None,
903            session_id: Some("session_1"),
904            root_session_id: None,
905            tool_call_id: "call_456",
906            event_tx: Some(&tx),
907            available_tool_schemas: None,
908            bypass_permissions: false,
909            auto_approve_permissions: false,
910            plan_read_only: false,
911            can_async_resume: false,
912            bash_completion_sink: None,
913            pre_parsed_args: None,
914        };
915
916        // Test with various non-Token events
917        ctx.emit(AgentEvent::ToolToken {
918            tool_call_id: "other".to_string(),
919            content: "direct tool token".to_string(),
920        })
921        .await;
922
923        let event = rx.recv().await.unwrap();
924        match event {
925            AgentEvent::ToolToken { content, .. } => {
926                assert_eq!(content, "direct tool token");
927            }
928            other => panic!("Expected ToolToken, got: {other:?}"),
929        }
930    }
931
932    #[tokio::test]
933    async fn emit_does_nothing_when_no_sender() {
934        let ctx = ToolExecutionContext::none("call_789");
935
936        // Should not panic or block
937        ctx.emit(AgentEvent::Token {
938            content: "test".to_string(),
939        })
940        .await;
941
942        // Success if we get here
943    }
944
945    #[tokio::test]
946    async fn emit_tool_token_convenience_method() {
947        let (tx, mut rx) = mpsc::channel(10);
948        let ctx = ToolExecutionContext {
949            executing_supervisor: None,
950            session_id: None,
951            root_session_id: None,
952            tool_call_id: "call_abc",
953            event_tx: Some(&tx),
954            available_tool_schemas: None,
955            bypass_permissions: false,
956            auto_approve_permissions: false,
957            plan_read_only: false,
958            can_async_resume: false,
959            bash_completion_sink: None,
960            pre_parsed_args: None,
961        };
962
963        ctx.emit_tool_token("convenient output").await;
964
965        let event = rx.recv().await.unwrap();
966        match event {
967            AgentEvent::ToolToken {
968                tool_call_id,
969                content,
970            } => {
971                assert_eq!(tool_call_id, "call_abc");
972                assert_eq!(content, "convenient output");
973            }
974            other => panic!("Expected ToolToken, got: {other:?}"),
975        }
976    }
977
978    #[tokio::test]
979    async fn emit_tool_token_with_no_sender_does_nothing() {
980        let ctx = ToolExecutionContext::none("call_def");
981
982        // Should not panic or block
983        ctx.emit_tool_token("test").await;
984
985        // Success if we get here
986    }
987
988    #[test]
989    fn none_creates_context_with_no_optional_fields() {
990        let ctx = ToolExecutionContext::none("call_xyz");
991
992        assert_eq!(ctx.session_id, None);
993        assert_eq!(ctx.tool_call_id, "call_xyz");
994        assert!(ctx.event_tx.is_none());
995    }
996
997    #[test]
998    fn cloned_sender_returns_none_when_no_sender() {
999        let ctx = ToolExecutionContext::none("call_test");
1000        assert!(ctx.cloned_sender().is_none());
1001    }
1002
1003    #[tokio::test]
1004    async fn cloned_sender_returns_clone_when_sender_present() {
1005        let (tx, _rx) = mpsc::channel(10);
1006        let ctx = ToolExecutionContext {
1007            executing_supervisor: None,
1008            session_id: None,
1009            root_session_id: None,
1010            tool_call_id: "call_clone",
1011            event_tx: Some(&tx),
1012            available_tool_schemas: None,
1013            bypass_permissions: false,
1014            auto_approve_permissions: false,
1015            plan_read_only: false,
1016            can_async_resume: false,
1017            bash_completion_sink: None,
1018            pre_parsed_args: None,
1019        };
1020
1021        let cloned = ctx.cloned_sender();
1022        assert!(cloned.is_some());
1023
1024        // Can use cloned sender
1025        cloned
1026            .unwrap()
1027            .send(AgentEvent::Token {
1028                content: "test".to_string(),
1029            })
1030            .await
1031            .unwrap();
1032    }
1033
1034    #[tokio::test]
1035    async fn emit_handles_multiple_sequential_calls() {
1036        let (tx, mut rx) = mpsc::channel(10);
1037        let ctx = ToolExecutionContext {
1038            executing_supervisor: None,
1039            session_id: Some("session_multi"),
1040            root_session_id: None,
1041            tool_call_id: "call_multi",
1042            event_tx: Some(&tx),
1043            available_tool_schemas: None,
1044            bypass_permissions: false,
1045            auto_approve_permissions: false,
1046            plan_read_only: false,
1047            can_async_resume: false,
1048            bash_completion_sink: None,
1049            pre_parsed_args: None,
1050        };
1051
1052        for i in 0..5 {
1053            ctx.emit(AgentEvent::Token {
1054                content: format!("message {}", i),
1055            })
1056            .await;
1057        }
1058
1059        for i in 0..5 {
1060            let event = rx.recv().await.unwrap();
1061            match event {
1062                AgentEvent::ToolToken { content, .. } => {
1063                    assert_eq!(content, format!("message {}", i));
1064                }
1065                other => panic!("Expected ToolToken, got: {other:?}"),
1066            }
1067        }
1068    }
1069
1070    #[test]
1071    fn context_is_clone_and_copy() {
1072        let (tx, _rx) = mpsc::channel(10);
1073        let ctx = ToolExecutionContext {
1074            executing_supervisor: None,
1075            session_id: Some("session_copy"),
1076            root_session_id: None,
1077            tool_call_id: "call_copy",
1078            event_tx: Some(&tx),
1079            available_tool_schemas: None,
1080            bypass_permissions: false,
1081            auto_approve_permissions: false,
1082            plan_read_only: false,
1083            can_async_resume: false,
1084            bash_completion_sink: None,
1085            pre_parsed_args: None,
1086        };
1087
1088        // Can clone (Copy implies Clone)
1089        let _cloned = ctx;
1090
1091        // Can copy
1092        let copied = ctx;
1093
1094        // Both are valid
1095        assert_eq!(copied.tool_call_id, "call_copy");
1096    }
1097
1098    #[test]
1099    fn context_is_debug() {
1100        let ctx = ToolExecutionContext::none("call_debug");
1101        let debug_str = format!("{:?}", ctx);
1102        assert!(debug_str.contains("call_debug"));
1103    }
1104
1105    #[tokio::test]
1106    async fn emit_with_empty_tool_call_id() {
1107        let (tx, mut rx) = mpsc::channel(10);
1108        let ctx = ToolExecutionContext {
1109            executing_supervisor: None,
1110            session_id: None,
1111            root_session_id: None,
1112            tool_call_id: "",
1113            event_tx: Some(&tx),
1114            available_tool_schemas: None,
1115            bypass_permissions: false,
1116            auto_approve_permissions: false,
1117            plan_read_only: false,
1118            can_async_resume: false,
1119            bash_completion_sink: None,
1120            pre_parsed_args: None,
1121        };
1122
1123        ctx.emit(AgentEvent::Token {
1124            content: "test".to_string(),
1125        })
1126        .await;
1127
1128        let event = rx.recv().await.unwrap();
1129        match event {
1130            AgentEvent::ToolToken { tool_call_id, .. } => {
1131                assert_eq!(tool_call_id, "");
1132            }
1133            other => panic!("Expected ToolToken, got: {other:?}"),
1134        }
1135    }
1136
1137    #[tokio::test]
1138    async fn emit_with_unicode_content() {
1139        let (tx, mut rx) = mpsc::channel(10);
1140        let ctx = ToolExecutionContext {
1141            executing_supervisor: None,
1142            session_id: Some("会话"),
1143            root_session_id: None,
1144            tool_call_id: "调用_123",
1145            event_tx: Some(&tx),
1146            available_tool_schemas: None,
1147            bypass_permissions: false,
1148            auto_approve_permissions: false,
1149            plan_read_only: false,
1150            can_async_resume: false,
1151            bash_completion_sink: None,
1152            pre_parsed_args: None,
1153        };
1154
1155        ctx.emit(AgentEvent::Token {
1156            content: "测试内容 🎯".to_string(),
1157        })
1158        .await;
1159
1160        let event = rx.recv().await.unwrap();
1161        match event {
1162            AgentEvent::ToolToken {
1163                tool_call_id,
1164                content,
1165            } => {
1166                assert_eq!(tool_call_id, "调用_123");
1167                assert_eq!(content, "测试内容 🎯");
1168            }
1169            other => panic!("Expected ToolToken, got: {other:?}"),
1170        }
1171    }
1172
1173    #[tokio::test]
1174    async fn emit_with_special_characters_in_tool_call_id() {
1175        let (tx, mut rx) = mpsc::channel(10);
1176        let ctx = ToolExecutionContext {
1177            executing_supervisor: None,
1178            session_id: None,
1179            root_session_id: None,
1180            tool_call_id: "call-with_special.chars:123",
1181            event_tx: Some(&tx),
1182            available_tool_schemas: None,
1183            bypass_permissions: false,
1184            auto_approve_permissions: false,
1185            plan_read_only: false,
1186            can_async_resume: false,
1187            bash_completion_sink: None,
1188            pre_parsed_args: None,
1189        };
1190
1191        ctx.emit(AgentEvent::Token {
1192            content: "test".to_string(),
1193        })
1194        .await;
1195
1196        let event = rx.recv().await.unwrap();
1197        match event {
1198            AgentEvent::ToolToken { tool_call_id, .. } => {
1199                assert_eq!(tool_call_id, "call-with_special.chars:123");
1200            }
1201            other => panic!("Expected ToolToken, got: {other:?}"),
1202        }
1203    }
1204
1205    #[tokio::test]
1206    async fn emit_tool_token_with_string_content() {
1207        let (tx, mut rx) = mpsc::channel(10);
1208        let ctx = ToolExecutionContext {
1209            executing_supervisor: None,
1210            session_id: None,
1211            root_session_id: None,
1212            tool_call_id: "call_string",
1213            event_tx: Some(&tx),
1214            available_tool_schemas: None,
1215            bypass_permissions: false,
1216            auto_approve_permissions: false,
1217            plan_read_only: false,
1218            can_async_resume: false,
1219            bash_completion_sink: None,
1220            pre_parsed_args: None,
1221        };
1222
1223        let content = String::from("owned string");
1224        ctx.emit_tool_token(content).await;
1225
1226        let event = rx.recv().await.unwrap();
1227        match event {
1228            AgentEvent::ToolToken { content, .. } => {
1229                assert_eq!(content, "owned string");
1230            }
1231            other => panic!("Expected ToolToken, got: {other:?}"),
1232        }
1233    }
1234
1235    #[tokio::test]
1236    async fn emit_tool_token_with_str_content() {
1237        let (tx, mut rx) = mpsc::channel(10);
1238        let ctx = ToolExecutionContext {
1239            executing_supervisor: None,
1240            session_id: None,
1241            root_session_id: None,
1242            tool_call_id: "call_str",
1243            event_tx: Some(&tx),
1244            available_tool_schemas: None,
1245            bypass_permissions: false,
1246            auto_approve_permissions: false,
1247            plan_read_only: false,
1248            can_async_resume: false,
1249            bash_completion_sink: None,
1250            pre_parsed_args: None,
1251        };
1252
1253        ctx.emit_tool_token("string slice").await;
1254
1255        let event = rx.recv().await.unwrap();
1256        match event {
1257            AgentEvent::ToolToken { content, .. } => {
1258                assert_eq!(content, "string slice");
1259            }
1260            other => panic!("Expected ToolToken, got: {other:?}"),
1261        }
1262    }
1263}