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