Skip to main content

bamboo_sdk/agent/
mod.rs

1//! Ergonomic top-level Agent SDK.
2//!
3//! A concise facade over the engine runtime: the caller supplies their own
4//! instruction (a system-prompt fragment), a model, and an optional tool
5//! policy; the engine assembles the complete system prompt around it at run
6//! time. Library consumers can write:
7//!
8//! ```rust,no_run
9//! # use std::path::PathBuf;
10//! use bamboo_sdk::agent::{Agent, Session};
11//! # async fn example(data_dir: PathBuf) -> Result<(), bamboo_sdk::agent::SdkError> {
12//!
13//! let agent = Agent::builder()
14//!     .model("claude-sonnet-4-6")
15//!     .instruction("You help users research topics thoroughly.")
16//!     .with_defaults_for_data_dir(data_dir).await?
17//!     .build()?;
18//!
19//! let mut session = Session::new("s1", "claude-sonnet-4-6");
20//! agent.run(&mut session, "investigate X").await?;
21//! # Ok(())
22//! # }
23//! ```
24//!
25//! ## Surface
26//!
27//! - [`Agent`] — stable entry point wrapping the engine runtime. `run` /
28//!   `run_stream` execute the agent loop with the configured instruction +
29//!   tool policy + model applied to the session.
30//! - [`AgentBuilder`] — concise builder (`.model()`, `.instruction()`,
31//!   `.tools()`) that assembles default deps via
32//!   [`AgentBuilder::with_defaults_for_data_dir`].
33//! - [`ExecuteRequestBuilder`] — ergonomic builder over the multi-field
34//!   [`bamboo_engine::ExecuteRequest`].
35//! - [`ToolSpec`] + [`builtin_tool_names`] — tool
36//!   descriptors derived from the canonical `BUILTIN_TOOL_NAMES`.
37//!
38//! ## Anti-fork invariant
39//!
40//! The SDK never reimplements the agent loop. `run` / `run_stream` funnel into
41//! `bamboo_engine::Agent::execute` (the single canonical execution path).
42
43mod builder;
44mod error;
45mod execute_request;
46mod tools;
47
48use std::sync::Arc;
49
50use async_trait::async_trait;
51pub use builder::AgentBuilder;
52pub use execute_request::ExecuteRequestBuilder;
53use tokio::sync::mpsc;
54
55use bamboo_engine::session_app::approval_replay::{
56    refresh_approval_replay_posture, ApprovalReplayDecision,
57};
58use bamboo_engine::session_app::errors::{SessionLoadError, SessionSaveError};
59use bamboo_engine::session_app::repository::SessionAccess;
60use bamboo_engine::session_app::respond::{
61    submit_pending_response, PERMISSION_REEXECUTE_METADATA_KEY,
62};
63use bamboo_engine::session_app::types::RespondInput;
64
65// Re-exported so callers can name the token returned by the `*_cancellable` /
66// `*_with_cancel` run helpers without depending on `tokio-util` directly.
67pub use tokio_util::sync::CancellationToken;
68pub use tools::{
69    builtin_tool_names, builtin_tool_specs, BuiltinTool, ToolSpec, CANONICAL_TOOL_NAMES,
70};
71
72pub use error::SdkError;
73
74// Convenience re-exports of commonly used types (single source of truth — these
75// supersede the old duplicate re-export chain, resolving TD-2).
76pub use bamboo_agent_core::{
77    AgentError, AgentEvent, AgentHook, Message, MessageContent, PendingQuestion, Role, Session,
78    TokenBudgetUsage, TokenUsage,
79};
80pub use bamboo_domain::{
81    AgentHookPoint, HookPayload, HookResult, HookToolOutcome, SessionActivationDisposition,
82    SessionActivationError, SessionActivationPolicy, SessionActivationPort, SessionChildOutcome,
83    SessionInboxBacklog, SessionInboxClaim, SessionInboxError, SessionInboxLimits,
84    SessionInboxPort, SessionInboxReceipt, SessionMessageBody, SessionMessageContent,
85    SessionMessageEnvelope, SessionMessageId, SessionMessageKind, SessionMessageSource,
86    SessionProviderMessage, SessionRuntimeInstruction, TaskItem, TaskItemStatus, TaskList,
87};
88pub use bamboo_engine::session_app::respond::PlanModeTransition;
89pub use bamboo_engine::{
90    Agent as RuntimeAgent, AgentBuilder as RuntimeAgentBuilder, ExecuteRequest, HookRunner,
91    LifecycleHookEvent, LifecycleHookTestOutput, LifecycleScriptRunner, ScriptHook,
92    SessionActivationLaunch, SessionActivationReserveOutcome, SessionActivationRouter,
93    SessionActivationSpawner, SessionMessagingMetrics, SessionMessagingMetricsSnapshot,
94    SessionMessenger, SessionMessengerAdmission, SessionMessengerError, SessionMessengerReceipt,
95    SessionRunRegistration, SessionRunRegistrationError, ShellCommandHook, ShellHookEvent,
96};
97pub use bamboo_llm::LLMProvider;
98pub use bamboo_mcp::manager::McpServerManager;
99pub use bamboo_mcp::{McpServerConfig, StdioConfig, TransportConfig};
100pub use bamboo_storage::{FileSessionInbox, SessionIndexEntry};
101pub use bamboo_tools::permission::{PermissionChecker, PermissionMode, PermissionType};
102pub use bamboo_tools::{BuiltinToolExecutor, BuiltinToolExecutorBuilder, ToolOutputManager};
103
104/// Default event-channel buffer used by [`Agent::run`].
105const EVENT_CHANNEL_CAPACITY: usize = 256;
106
107/// Stable, ergonomic entry point for agent execution.
108///
109/// Wraps a [`bamboo_engine::Agent`] (which owns the shared runtime) plus the
110/// instruction / tool policy / model configured at build time. Clone is cheap.
111#[derive(Clone)]
112pub struct Agent {
113    inner: bamboo_engine::Agent,
114    /// Instruction (system-prompt fragment) injected into the session at `run`
115    /// time; the engine assembles the full prompt around it.
116    system_prompt: Option<String>,
117    /// Model override applied to the session at `run` time.
118    model: Option<String>,
119    /// Effective configured model used only when creating a new session. This
120    /// must not alter an existing caller-supplied session during execution.
121    session_model: Option<String>,
122    /// Default first-class Project membership for newly-created/unassigned sessions.
123    project_id: Option<bamboo_domain::ProjectId>,
124    /// Concrete session-index handle, present only when assembled via
125    /// [`AgentBuilder::with_defaults_for_data_dir`]. Backs
126    /// [`list_sessions`](Self::list_sessions) — the type-erased
127    /// `Arc<dyn Storage>` the engine builder takes can't list.
128    session_store: Option<Arc<bamboo_storage::SessionStoreV2>>,
129    /// Permission checker configured via
130    /// [`AgentBuilder::permission_checker`], if any. Used by
131    /// [`answer`](Self::answer) to apply permission grants implied by an
132    /// approved permission prompt, mirroring what the HTTP `/respond` handler
133    /// does for `state.permission_checker`.
134    permission_checker: Option<Arc<dyn bamboo_tools::permission::PermissionChecker>>,
135    /// Configured process posture retained independently from the checker.
136    /// In particular, the SDK's explicit legacy Bypass policy deliberately has
137    /// no checker, while approval replay still needs to derive exact flags.
138    permission_mode: PermissionMode,
139}
140
141impl Agent {
142    /// Return a new ergonomic builder.
143    pub fn builder() -> AgentBuilder {
144        AgentBuilder::new()
145    }
146
147    /// Wrap an existing engine [`Agent`](bamboo_engine::Agent) with no extra
148    /// role configuration.
149    pub fn from_runtime(inner: bamboo_engine::Agent) -> Self {
150        Self {
151            inner,
152            system_prompt: None,
153            model: None,
154            session_model: None,
155            project_id: None,
156            session_store: None,
157            permission_checker: None,
158            permission_mode: PermissionMode::Default,
159        }
160    }
161
162    /// Wrap an engine [`Agent`](bamboo_engine::Agent) plus the instruction /
163    /// model configuration assembled by [`AgentBuilder`].
164    pub(crate) fn from_runtime_with_config(
165        inner: bamboo_engine::Agent,
166        system_prompt: Option<String>,
167        model: Option<String>,
168        session_model: Option<String>,
169        project_id: Option<bamboo_domain::ProjectId>,
170        session_store: Option<Arc<bamboo_storage::SessionStoreV2>>,
171        permission_checker: Option<Arc<dyn bamboo_tools::permission::PermissionChecker>>,
172        permission_mode: PermissionMode,
173    ) -> Self {
174        Self {
175            inner,
176            system_prompt,
177            model,
178            session_model,
179            project_id,
180            session_store,
181            permission_checker,
182            permission_mode,
183        }
184    }
185
186    /// Run the agent loop on `session` with the given input, draining events
187    /// internally until completion.
188    ///
189    /// The configured instruction + model are applied to the session before
190    /// execution; the tool set was fixed on the agent's executor at build time.
191    ///
192    /// NOTE: this variant **discards every [`AgentEvent`]** (tool calls, tokens,
193    /// intermediate errors) — you only get the final `Result`. To observe the
194    /// run, use [`run_stream`](Self::run_stream) instead. To cancel a blocking
195    /// run from another task, use [`run_with_cancel`](Self::run_with_cancel).
196    pub async fn run(
197        &self,
198        session: &mut Session,
199        input: impl Into<String>,
200    ) -> Result<(), AgentError> {
201        session.add_message(Message::user(input.into()));
202        self.run_session(session).await
203    }
204
205    /// Like [`run`](Self::run) but driven by a caller-owned
206    /// [`CancellationToken`]: cancelling the token from another task stops the
207    /// loop at the next check point. Events are still discarded (see `run`).
208    pub async fn run_with_cancel(
209        &self,
210        session: &mut Session,
211        input: impl Into<String>,
212        cancel_token: CancellationToken,
213    ) -> Result<(), AgentError> {
214        session.add_message(Message::user(input.into()));
215        self.run_session_with_cancel(session, cancel_token).await
216    }
217
218    /// Run the agent loop on `session` exactly as it stands — i.e. on a
219    /// caller-provided message list — without appending a new turn. The last
220    /// `User` message already in the session drives execution.
221    ///
222    /// This is how you pass a full conversation / message list: build the
223    /// session from your messages, then run it.
224    ///
225    /// ```rust,no_run
226    /// # use bamboo_sdk::agent::{Agent, Message, Session};
227    /// # async fn example(agent: &Agent) -> Result<(), bamboo_sdk::agent::AgentError> {
228    /// let mut session = Session::new("s1", "claude-sonnet-4-6");
229    /// session.add_message(Message::user("hi"));
230    /// session.add_message(Message::assistant("hello!", None));
231    /// session.add_message(Message::user("now summarize our chat"));
232    /// agent.run_session(&mut session).await?; // no extra input appended
233    /// # Ok(())
234    /// # }
235    /// ```
236    pub async fn run_session(&self, session: &mut Session) -> Result<(), AgentError> {
237        self.run_session_with_cancel(session, CancellationToken::new())
238            .await
239    }
240
241    /// Like [`run_session`](Self::run_session) but driven by a caller-owned
242    /// [`CancellationToken`], so a blocking run can be cancelled from another
243    /// task. Events are still discarded (see [`run`](Self::run)).
244    pub async fn run_session_with_cancel(
245        &self,
246        session: &mut Session,
247        cancel_token: CancellationToken,
248    ) -> Result<(), AgentError> {
249        let (event_tx, mut event_rx) = mpsc::channel::<AgentEvent>(EVENT_CHANNEL_CAPACITY);
250
251        // Drain events so the bounded channel never blocks the loop.
252        let drain = tokio::spawn(async move { while event_rx.recv().await.is_some() {} });
253
254        let result = self.execute_internal(session, event_tx, cancel_token).await;
255
256        // Stop draining once execution returns. Detached engine tasks (e.g.
257        // background evaluations) may still hold a cloned sender, so awaiting
258        // natural channel closure could hang; abort instead.
259        drain.abort();
260        result
261    }
262
263    /// Append `input` as a new user turn, then stream the run's
264    /// [`AgentEvent`]s. The execution runs on a background task; the caller
265    /// drives it by reading from the returned receiver until it closes.
266    pub fn run_stream(
267        &self,
268        mut session: Session,
269        input: impl Into<String>,
270    ) -> mpsc::Receiver<AgentEvent> {
271        session.add_message(Message::user(input.into()));
272        self.run_stream_session(session)
273    }
274
275    /// Like [`run_stream`](Self::run_stream), but also returns a
276    /// [`CancellationToken`] for the run: call `token.cancel()` to stop the loop
277    /// at the next check point. Dropping the receiver does NOT cancel the run, so
278    /// this is the way to interrupt a streaming agent.
279    pub fn run_stream_cancellable(
280        &self,
281        mut session: Session,
282        input: impl Into<String>,
283    ) -> (mpsc::Receiver<AgentEvent>, CancellationToken) {
284        session.add_message(Message::user(input.into()));
285        self.run_stream_session_cancellable(session)
286    }
287
288    /// Stream the run's [`AgentEvent`]s for a caller-provided message list,
289    /// without appending a new turn (the last `User` message drives execution).
290    pub fn run_stream_session(&self, session: Session) -> mpsc::Receiver<AgentEvent> {
291        self.run_stream_session_with_cancel(session, CancellationToken::new())
292    }
293
294    /// Like [`run_stream_session`](Self::run_stream_session) but also returns the
295    /// run's [`CancellationToken`] so the caller can interrupt it.
296    pub fn run_stream_session_cancellable(
297        &self,
298        session: Session,
299    ) -> (mpsc::Receiver<AgentEvent>, CancellationToken) {
300        let cancel_token = CancellationToken::new();
301        let rx = self.run_stream_session_with_cancel(session, cancel_token.clone());
302        (rx, cancel_token)
303    }
304
305    /// Stream a caller-provided message list under a caller-owned
306    /// [`CancellationToken`]. The shared entry point the other `run_stream*`
307    /// helpers funnel into.
308    pub fn run_stream_session_with_cancel(
309        &self,
310        mut session: Session,
311        cancel_token: CancellationToken,
312    ) -> mpsc::Receiver<AgentEvent> {
313        let (event_tx, event_rx) = mpsc::channel::<AgentEvent>(EVENT_CHANNEL_CAPACITY);
314        let agent = self.clone();
315
316        tokio::spawn(async move {
317            // Keep one sender alive through the direct-execution terminal
318            // handshake. The engine consumes/drops the request sender when the
319            // provider loop returns, but the public stream must not close before
320            // finalization has handed any terminal-window inbox generation to
321            // its successor.
322            let execution_tx = event_tx.clone();
323            if let Err(error) = agent
324                .execute_internal(&mut session, execution_tx, cancel_token)
325                .await
326            {
327                tracing::warn!("Agent::run_stream execution failed: {error}");
328            }
329        });
330
331        event_rx
332    }
333
334    /// Escape hatch for full per-request control: run a fully-specified
335    /// [`ExecuteRequest`] (split fast/background/summarization models, provider
336    /// handle, skill selection, custom event channel, cancellation token, …) on
337    /// `session` via the single canonical engine execution path — the same path
338    /// [`run`](Self::run) / [`run_stream`](Self::run_stream) funnel into.
339    ///
340    /// Unlike `run`/`run_stream`, this does NOT apply the builder's configured
341    /// instruction or model: the caller owns the request entirely. Build it with
342    /// [`ExecuteRequestBuilder`].
343    ///
344    /// ```rust,no_run
345    /// # use bamboo_sdk::agent::{Agent, CancellationToken, ExecuteRequestBuilder, Session};
346    /// # async fn example(agent: &Agent, session: &mut Session) -> Result<(), bamboo_sdk::agent::AgentError> {
347    /// let (tx, _rx) = tokio::sync::mpsc::channel(256);
348    /// let req = ExecuteRequestBuilder::new("investigate X", tx, CancellationToken::new())
349    ///     .model("claude-sonnet-4-6")
350    ///     .build();
351    /// agent.execute(session, req).await?;
352    /// # Ok(())
353    /// # }
354    /// ```
355    pub async fn execute(
356        &self,
357        session: &mut Session,
358        request: ExecuteRequest,
359    ) -> Result<(), AgentError> {
360        self.inner.execute_direct(session, request).await
361    }
362
363    /// Shared execution path: prepare the session (system prompt + model), build
364    /// the [`ExecuteRequest`], and delegate to the canonical engine execution
365    /// path. Tool restriction is applied via the agent's executor (built time).
366    async fn execute_internal(
367        &self,
368        session: &mut Session,
369        event_tx: mpsc::Sender<AgentEvent>,
370        cancel_token: CancellationToken,
371    ) -> Result<(), AgentError> {
372        // Own the logical session before any pre-execution mutation or approved
373        // tool replay. Two cloned SDK Session values must collide before either
374        // can duplicate a mutating side effect.
375        let direct_lease = self.inner.begin_direct_execution(&session.id).await?;
376        if session.project_id_meta().is_none() {
377            if let Some(project_id) = self.project_id.as_ref() {
378                session.set_project_id_meta(project_id.to_string());
379            }
380        }
381        // If `answer()` just approved a gated tool call, `session.metadata` carries
382        // the re-execution marker `submit_pending_response` set — the gated tool
383        // never actually ran (the permission gate intercepted it before
384        // execution), so re-run it now for real and write the genuine output back
385        // before the loop resumes. No-op when the marker is absent (the common,
386        // non-permission path), so this is safe to run unconditionally on every
387        // entry into the loop, not just `resume`. See
388        // `reexecute_approved_tool_if_pending` for the full rationale.
389        self.reexecute_approved_tool_if_pending(session, &event_tx)
390            .await?;
391
392        // Apply the instruction as the session's leading System message and set
393        // the configured model via the single authoritative pre-execution
394        // mutation point. The builder's prompt is AUTHORITATIVE: it replaces a
395        // leading System message, otherwise inserts one at index 0, so a
396        // caller-supplied session can't silently shadow the configured
397        // instruction.
398        bamboo_engine::session_app::execution_prep::prepare_session_for_execution(
399            session,
400            self.system_prompt.as_deref(),
401            self.model.as_deref(),
402        );
403
404        // The last user message in the session drives execution (the engine
405        // skips echoing `initial_message`, so we surface it for logging only).
406        let initial_message = session
407            .messages
408            .iter()
409            .rev()
410            .find(|m| matches!(m.role, Role::User))
411            .map(|m| m.content.clone())
412            .unwrap_or_default();
413
414        // Tool restriction is handled at build time: the agent's executor is
415        // built from exactly the configured tool set, so no per-run
416        // `disabled_tools` filter is needed here.
417        let mut builder = ExecuteRequestBuilder::new(initial_message, event_tx, cancel_token);
418        if let Some(model) = self.model.clone() {
419            builder = builder.model(model);
420        }
421
422        self.inner
423            .execute_direct_registered(session, builder.build(), direct_lease)
424            .await
425    }
426
427    /// Port of `bamboo-server`'s `resume_adapter.rs` re-execution logic: after
428    /// [`answer`](Self::answer) approves a permission prompt,
429    /// `submit_pending_response` stamps `session.metadata` with
430    /// [`PERMISSION_REEXECUTE_METADATA_KEY`] (the approved tool call's id) — the
431    /// gated tool was intercepted BEFORE it ran, so its recorded result is only
432    /// the synthetic "Selected response: Approve" placeholder. This re-runs the
433    /// original tool call for real, against the SAME executor the loop itself
434    /// uses ([`bamboo_engine::Agent::default_tools`]), and overwrites the
435    /// placeholder tool-result message with the genuine output — so the resumed
436    /// loop sees what the operation actually did instead of inferring it.
437    ///
438    /// Emits the same `ToolStart`/`ToolComplete` (or `ToolError`) lifecycle
439    /// events onto `event_tx` that a normal dispatch would, so a streaming
440    /// consumer sees the re-run tool card update exactly like the HTTP surface
441    /// does. Best-effort persists the updated session via
442    /// [`persistence`](Self::persistence) so the real output survives even if the
443    /// process stops before the loop's own next save — logged, not propagated,
444    /// since the loop's subsequent save will also capture it.
445    ///
446    /// No-op (returns immediately) when the marker is absent, so it is safe to
447    /// call unconditionally at the top of every execution, not just resumes.
448    async fn reexecute_approved_tool_if_pending(
449        &self,
450        session: &mut Session,
451        event_tx: &mpsc::Sender<AgentEvent>,
452    ) -> Result<(), AgentError> {
453        let Some(tool_call_id) = session
454            .metadata
455            .get(PERMISSION_REEXECUTE_METADATA_KEY)
456            .cloned()
457        else {
458            return Ok(());
459        };
460
461        let Some(tool_call) = find_pending_tool_call(session, &tool_call_id) else {
462            session.metadata.remove(PERMISSION_REEXECUTE_METADATA_KEY);
463            tracing::warn!(
464                session_id = %session.id,
465                tool_call_id = %tool_call_id,
466                "Permission re-exec marker set but tool call not found in history"
467            );
468            return Ok(());
469        };
470
471        let tool_name = tool_call.function.name.clone();
472        let decision = refresh_approval_replay_posture(
473            self.storage().as_ref(),
474            session,
475            self.permission_mode,
476            &tool_name,
477        )
478        .await?;
479
480        let flags = match decision {
481            ApprovalReplayDecision::Execute(flags) => flags,
482            ApprovalReplayDecision::BlockedByPlan(_) => {
483                session.metadata.remove(PERMISSION_REEXECUTE_METADATA_KEY);
484                apply_tool_result(
485                    session,
486                    &tool_call_id,
487                    format!(
488                        "Plan mode blocked approved mutating tool '{tool_name}'; the stale approval was not executed"
489                    ),
490                    false,
491                );
492                if let Err(error) = self.persistence().save_runtime_session(session).await {
493                    tracing::warn!(
494                        session_id = %session.id,
495                        %error,
496                        "Failed to persist Plan-blocked approval replay (loop's own save will retry)"
497                    );
498                }
499                return Ok(());
500            }
501        };
502        session.metadata.remove(PERMISSION_REEXECUTE_METADATA_KEY);
503
504        let executor = self.inner.default_tools();
505        let is_mutating = bamboo_tools::orchestrator::classify_tool(&tool_name)
506            == bamboo_tools::orchestrator::ToolMutability::Mutating;
507
508        // Frame the re-run with the same lifecycle events the normal loop emits
509        // (via ToolEmitter) so a streaming consumer's tool card updates
510        // (running -> finished) and ToolComplete carries the REAL output — raw
511        // `execute_with_context` only streams tool tokens, not lifecycle.
512        let mut emitter = bamboo_tools::ToolEmitter::new(&tool_call.id, &tool_name, is_mutating);
513        emitter.set_auto_approved(true);
514        let _ = event_tx
515            .send(emitter.begin().clone().into_agent_event())
516            .await;
517
518        let exec_result = {
519            let ctx = bamboo_agent_core::tools::ToolExecutionContext {
520                session_id: Some(session.id.as_str()),
521                tool_call_id: tool_call_id.as_str(),
522                event_tx: Some(event_tx),
523                available_tool_schemas: None,
524                bypass_permissions: flags.bypass_permissions,
525                auto_approve_permissions: flags.auto_approve_permissions,
526                plan_read_only: flags.plan_read_only,
527                can_async_resume: false,
528                bash_completion_sink: None,
529                pre_parsed_args: None,
530            };
531            executor.execute_with_context(&tool_call, ctx).await
532        };
533
534        let (content, success) = match exec_result {
535            Ok(tool_result) => {
536                let _ = event_tx
537                    .send(
538                        emitter
539                            .finish(Some("Re-executed after approval".to_string()))
540                            .clone()
541                            .into_agent_event(),
542                    )
543                    .await;
544                let _ = event_tx
545                    .send(AgentEvent::ToolComplete {
546                        tool_call_id: tool_call.id.clone(),
547                        result: tool_result.clone(),
548                    })
549                    .await;
550                (tool_result.result, tool_result.success)
551            }
552            Err(error) => {
553                let message = format!("Tool re-execution after approval failed: {error}");
554                let _ = event_tx
555                    .send(emitter.error(message.clone()).clone().into_agent_event())
556                    .await;
557                (message, false)
558            }
559        };
560
561        tracing::info!(
562            session_id = %session.id,
563            tool_name = %tool_name,
564            tool_call_id = %tool_call_id,
565            success,
566            "Re-executed approved tool after permission grant"
567        );
568        apply_tool_result(session, &tool_call_id, content, success);
569
570        if let Err(error) = self.persistence().save_runtime_session(session).await {
571            tracing::warn!(
572                session_id = %session.id,
573                %error,
574                "Failed to persist session after tool re-execution (loop's own save will retry)"
575            );
576        }
577        Ok(())
578    }
579
580    /// Access the shared storage backend.
581    pub fn storage(&self) -> &Arc<dyn bamboo_agent_core::storage::Storage> {
582        self.inner.storage()
583    }
584
585    /// Access the runtime persistence adapter.
586    pub fn persistence(&self) -> &Arc<dyn bamboo_domain::RuntimeSessionPersistence> {
587        self.inner.persistence()
588    }
589
590    /// Submit typed logical-session messages through the coherent durable
591    /// delivery plane configured by
592    /// [`AgentBuilder::session_delivery`](AgentBuilder::session_delivery).
593    pub fn session_messenger(&self) -> Option<&Arc<bamboo_engine::SessionMessenger>> {
594        self.inner.session_messenger()
595    }
596
597    /// Inspect/claim the configured durable logical-session inbox.
598    pub fn session_inbox(&self) -> Option<&Arc<dyn bamboo_domain::SessionInboxPort>> {
599        self.inner.session_inbox()
600    }
601
602    /// Access the configured logical-session activation router for binding a
603    /// host-specific [`SessionActivationSpawner`].
604    pub fn activation_router(&self) -> Option<&Arc<bamboo_engine::SessionActivationRouter>> {
605        self.inner.activation_router()
606    }
607
608    // ------------------------------------------------------------------
609    // Permission / approval + resume
610    // ------------------------------------------------------------------
611
612    /// Answer a suspended session's pending question — a
613    /// `conclusion_with_options` clarification OR a permission-approval
614    /// prompt (`NeedClarification` / `ToolApprovalRequested` events; both
615    /// suspend via the same `session.pending_question` mechanism, per
616    /// `bamboo_engine::session_app::respond`). This is the in-process
617    /// equivalent of the HTTP `POST /api/v1/sessions/{id}/respond` endpoint —
618    /// same use case function (`submit_pending_response`), so behavior
619    /// (validation, plan-mode transitions, permission-grant extraction)
620    /// matches exactly.
621    ///
622    /// `response` must be one of the pending question's `options` unless it
623    /// `allow_custom`s a free-form answer (returns
624    /// [`SdkError::InvalidResponse`] otherwise). Loads the session by ID from
625    /// [`storage`](Self::storage), so the run must have already suspended
626    /// (and thus persisted) before calling this — the session is NOT taken
627    /// from an in-memory handle.
628    ///
629    /// If this `Agent` was built with
630    /// [`AgentBuilder::permission_checker`](AgentBuilder::permission_checker),
631    /// any permission grants implied by an approved permission prompt are
632    /// applied to it automatically (mirroring what the HTTP handler does for
633    /// `state.permission_checker`), so the resumed re-attempt of the gated
634    /// operation passes the checker without prompting again.
635    ///
636    /// After answering, resume execution with [`resume`](Self::resume) /
637    /// [`resume_stream`](Self::resume_stream) on the returned
638    /// [`AnswerOutcome::session`] — or use
639    /// [`answer_and_resume_stream`](Self::answer_and_resume_stream) to do both
640    /// in one call.
641    ///
642    /// Like the HTTP server's `/respond` handler, approving a gated tool call
643    /// here also re-executes it for real once the run resumes: `answer` (via
644    /// `submit_pending_response`) stamps the returned session's metadata with
645    /// `PERMISSION_REEXECUTE_METADATA_KEY`, and the next call into
646    /// [`resume`](Self::resume)/[`resume_stream`](Self::resume_stream)/`run*`
647    /// re-runs the originally-gated tool call against the agent's tool executor
648    /// and overwrites the synthetic "Selected response: Approve" placeholder
649    /// with the operation's genuine output before the loop continues — see
650    /// `reexecute_approved_tool_if_pending`.
651    ///
652    /// NOTE: `ChildApprovalRequested` (an out-of-process sub-agent worker's
653    /// gated tool, proxied over the actor protocol) is a SEPARATE mechanism
654    /// from `pending_question`/`respond` and is not covered by this method —
655    /// use [`answer_child_approval`](Self::answer_child_approval) instead.
656    pub async fn answer(
657        &self,
658        session_id: impl Into<String>,
659        response: impl Into<String>,
660    ) -> Result<AnswerOutcome, SdkError> {
661        let input = RespondInput {
662            session_id: session_id.into(),
663            user_response: response.into(),
664            model: None,
665            model_ref: None,
666            provider: None,
667            reasoning_effort: None,
668        };
669        let (session, response, plan_mode_transition, permission_grants) =
670            submit_pending_response(self, input).await?;
671
672        if let Some(checker) = &self.permission_checker {
673            if let Some(request_id) = session.metadata.get(PERMISSION_REEXECUTE_METADATA_KEY) {
674                for (perm_type, resource) in &permission_grants {
675                    checker.grant_once(&session.id, request_id, *perm_type, resource.clone());
676                }
677            }
678        }
679
680        Ok(AnswerOutcome {
681            session,
682            response,
683            plan_mode_transition,
684            permission_grants,
685        })
686    }
687
688    /// Resume execution on `session` — i.e. continue the agent loop from its
689    /// current state (e.g. the tool result [`answer`](Self::answer) just
690    /// appended) WITHOUT appending a new user turn, draining events
691    /// internally until completion. An alias for
692    /// [`run_session`](Self::run_session) that documents intent at the call
693    /// site: the engine's execution entry point always resumes from whatever
694    /// is already in `session.messages` (`run`/`run_stream` are the ones that
695    /// append a fresh turn first).
696    pub async fn resume(&self, session: &mut Session) -> Result<(), AgentError> {
697        self.run_session(session).await
698    }
699
700    /// Like [`resume`](Self::resume), but driven by a caller-owned
701    /// [`CancellationToken`].
702    pub async fn resume_with_cancel(
703        &self,
704        session: &mut Session,
705        cancel_token: CancellationToken,
706    ) -> Result<(), AgentError> {
707        self.run_session_with_cancel(session, cancel_token).await
708    }
709
710    /// Like [`resume`](Self::resume), but streams [`AgentEvent`]s instead of
711    /// draining them. An alias for
712    /// [`run_stream_session`](Self::run_stream_session).
713    pub fn resume_stream(&self, session: Session) -> mpsc::Receiver<AgentEvent> {
714        self.run_stream_session(session)
715    }
716
717    /// Like [`resume_stream`](Self::resume_stream), but also returns a
718    /// [`CancellationToken`] for the resumed run.
719    pub fn resume_stream_cancellable(
720        &self,
721        session: Session,
722    ) -> (mpsc::Receiver<AgentEvent>, CancellationToken) {
723        self.run_stream_session_cancellable(session)
724    }
725
726    /// Convenience: [`answer`](Self::answer) a pending question, then
727    /// immediately [`resume_stream`](Self::resume_stream) on the resulting
728    /// session — the common "ask → answer → resume" flow in one call.
729    pub async fn answer_and_resume_stream(
730        &self,
731        session_id: impl Into<String>,
732        response: impl Into<String>,
733    ) -> Result<mpsc::Receiver<AgentEvent>, SdkError> {
734        let outcome = self.answer(session_id, response).await?;
735        Ok(self.resume_stream(outcome.session))
736    }
737
738    /// Answer an out-of-process child sub-agent's gated-tool approval request
739    /// (an [`AgentEvent::ChildApprovalRequested`] surfaced on a run's event
740    /// stream — e.g. from [`run_stream`](Self::run_stream)/
741    /// [`resume_stream`](Self::resume_stream)) — the in-process equivalent of
742    /// the HTTP `POST /api/v1/child-approval/{child_session_id}` endpoint (see
743    /// `bamboo_server::handlers::agent::child_approval`).
744    ///
745    /// This is a SEPARATE mechanism from [`answer`](Self::answer)/
746    /// [`pending_question`](Session::pending_question): a child sub-agent
747    /// worker running out-of-process (over the actor protocol, e.g. a broker
748    /// worker) that hits a gated tool escalates the approval request UP to this
749    /// process rather than suspending its own `pending_question`, and the
750    /// engine tracks it in a process-global pending-approval registry
751    /// (`bamboo_engine::external_agents::live`) keyed by `(child_session_id,
752    /// request_id)` — both taken verbatim from the surfaced event. There is no
753    /// session to load/save here: this only delivers the decision over the
754    /// child's live connection (or fails it closed if the child already
755    /// disconnected or the request already resolved/timed out).
756    ///
757    /// Returns `true` if the decision was delivered to a genuinely-pending
758    /// request, `false` if `request_id` is unknown, was already answered, timed
759    /// out, or the child is no longer live — mirroring the HTTP handler's
760    /// 200-vs-404 distinction. A `false` result does not need cleanup on the
761    /// caller's part: the request is either already resolved or has moved on.
762    ///
763    /// # Boundary
764    ///
765    /// This method only covers the TOP-orchestrator, human-in-the-loop leg of
766    /// child approval (the leg that surfaces `ChildApprovalRequested` at all).
767    /// It requires the agent to actually be driving an out-of-process child —
768    /// i.e. the caller has wired the engine's `external_agents` actor transport
769    /// (broker/worker) — which `AgentBuilder::with_defaults_for_data_dir` does
770    /// NOT assemble; that machinery is a separate, opt-in subsystem. Calling
771    /// this without a live child matching `child_session_id`/`request_id`
772    /// simply returns `false` (no panic, no error) — the same as an unmatched
773    /// HTTP POST.
774    pub fn answer_child_approval(
775        &self,
776        child_session_id: impl AsRef<str>,
777        request_id: impl AsRef<str>,
778        approved: bool,
779    ) -> bool {
780        bamboo_engine::external_agents::live::deliver_approval_checked(
781            None,
782            child_session_id.as_ref(),
783            request_id.as_ref(),
784            approved,
785        )
786    }
787
788    // ------------------------------------------------------------------
789    // Session ergonomics
790    // ------------------------------------------------------------------
791
792    /// Create a new in-memory session using this agent's configured model.
793    ///
794    /// The explicit [`AgentBuilder::model`] value wins; otherwise an agent
795    /// assembled via [`AgentBuilder::with_defaults_for_data_dir`] captures the
796    /// active provider's effective configured model. Returns
797    /// [`SdkError::ModelNotConfigured`] instead of creating a session with an
798    /// empty model when neither source supplies one. This does not persist the
799    /// session until it is run or explicitly saved.
800    pub fn new_session(&self, session_id: impl Into<String>) -> Result<Session, SdkError> {
801        let model = self
802            .model
803            .as_deref()
804            .or(self.session_model.as_deref())
805            .map(str::trim)
806            .filter(|model| !model.is_empty())
807            .ok_or(SdkError::ModelNotConfigured)?;
808        let mut session = Session::new(session_id.into(), model.to_string());
809        if let Some(project_id) = self.project_id.as_ref() {
810            session.set_project_id_meta(project_id.to_string());
811        }
812        Ok(session)
813    }
814
815    /// List every session in the data directory, most-recently-updated first.
816    ///
817    /// Only available when this `Agent` was built via
818    /// [`AgentBuilder::with_defaults_for_data_dir`] (which assembles the
819    /// concrete session-index handle this needs) — returns
820    /// [`SdkError::Unsupported`] otherwise.
821    pub async fn list_sessions(&self) -> Result<Vec<bamboo_storage::SessionIndexEntry>, SdkError> {
822        let store = self.session_store.as_ref().ok_or_else(|| {
823            SdkError::Unsupported(
824                "list_sessions requires an Agent built via with_defaults_for_data_dir".to_string(),
825            )
826        })?;
827        Ok(store.list_index_entries().await)
828    }
829
830    /// Load a session by ID (its full message history + runtime state), or
831    /// `Ok(None)` if it doesn't exist.
832    pub async fn load_session(&self, session_id: &str) -> Result<Option<Session>, SdkError> {
833        SessionAccess::load_session(self, session_id)
834            .await
835            .map_err(SdkError::from)
836    }
837
838    /// Compatibility alias for [`load_session`](Self::load_session).
839    pub async fn get_session(&self, session_id: &str) -> Result<Option<Session>, SdkError> {
840        self.load_session(session_id).await
841    }
842
843    /// The message history for a session, or [`SdkError::SessionNotFound`] if
844    /// it doesn't exist.
845    pub async fn session_history(&self, session_id: &str) -> Result<Vec<Message>, SdkError> {
846        self.load_session(session_id)
847            .await?
848            .map(|session| session.messages)
849            .ok_or_else(|| SdkError::SessionNotFound(session_id.to_string()))
850    }
851
852    /// Delete a session. Returns `true` if a session was actually deleted.
853    pub async fn delete_session(&self, session_id: &str) -> Result<bool, SdkError> {
854        self.storage()
855            .delete_session(session_id)
856            .await
857            .map_err(SdkError::Io)
858    }
859}
860
861/// Outcome of [`Agent::answer`] — the updated session, the recorded response,
862/// and any side effects `submit_pending_response` computed (plan-mode
863/// transitions, permission grants implied by an approval).
864#[derive(Debug)]
865pub struct AnswerOutcome {
866    /// The session after the pending question was answered (tool result
867    /// recorded, `pending_question` cleared, resume markers set).
868    pub session: Session,
869    /// The response text that was recorded.
870    pub response: String,
871    /// Plan-mode entered/exited transition, if the answered question was
872    /// `EnterPlanMode`/`ExitPlanMode`.
873    pub plan_mode_transition: Option<PlanModeTransition>,
874    /// `(PermissionType, resource)` grants implied by approving a permission
875    /// prompt (empty unless the pending question was a permission approval).
876    /// Already applied to this `Agent`'s configured
877    /// [`permission_checker`](AgentBuilder::permission_checker), if any.
878    pub permission_grants: Vec<(bamboo_tools::permission::PermissionType, String)>,
879}
880
881/// [`SessionAccess`] for [`Agent`], backed by [`Agent::storage`] (reads) and
882/// [`Agent::persistence`] (writes) — no separate cache tier, unlike the
883/// server's cache+storage+persistence-backed `SessionRepository`, since the
884/// SDK has no cross-request cache to keep coherent. This is what lets
885/// [`Agent::answer`] call the same `bamboo_engine::session_app::respond`
886/// use-case function the HTTP `/respond` handler calls on `AppState`.
887#[async_trait]
888impl SessionAccess for Agent {
889    async fn load_session(&self, id: &str) -> Result<Option<Session>, SessionLoadError> {
890        self.storage()
891            .load_session(id)
892            .await
893            .map_err(|e| SessionLoadError::StorageError(e.to_string()))
894    }
895
896    async fn load_or_create(&self, id: &str, model: &str) -> Result<Session, SessionLoadError> {
897        match SessionAccess::load_session(self, id).await? {
898            Some(session) => Ok(session),
899            None => Ok(Session::new(id.to_string(), model.to_string())),
900        }
901    }
902
903    async fn load_merged(&self, id: &str) -> Result<Option<Session>, SessionLoadError> {
904        // No separate cache tier — storage is the single source of truth.
905        SessionAccess::load_session(self, id).await
906    }
907
908    async fn save_session(&self, session: &mut Session) -> Result<(), SessionSaveError> {
909        self.persistence()
910            .save_runtime_session(session)
911            .await
912            .map_err(|e| SessionSaveError::StorageError(e.to_string()))
913    }
914
915    async fn save_and_cache(&self, session: &mut Session) -> Result<(), SessionSaveError> {
916        SessionAccess::save_session(self, session).await
917    }
918}
919
920/// Find the original tool call (with its arguments) by id in the session
921/// history. Mirrors `bamboo-server`'s `resume_adapter::find_pending_tool_call`.
922fn find_pending_tool_call(
923    session: &Session,
924    tool_call_id: &str,
925) -> Option<bamboo_agent_core::tools::ToolCall> {
926    session.messages.iter().find_map(|message| {
927        message
928            .tool_calls
929            .as_ref()
930            .and_then(|calls| calls.iter().find(|call| call.id == tool_call_id).cloned())
931    })
932}
933
934/// Overwrite the tool-result message for `tool_call_id` with the real tool
935/// output. Mirrors `bamboo-server`'s `resume_adapter::apply_tool_result`.
936fn apply_tool_result(session: &mut Session, tool_call_id: &str, content: String, success: bool) {
937    for message in &mut session.messages {
938        if message.tool_call_id.as_deref() == Some(tool_call_id) {
939            message.content = content;
940            message.tool_success = Some(success);
941            return;
942        }
943    }
944}
945
946#[cfg(test)]
947mod approval_and_session_tests {
948    use super::*;
949    use bamboo_tools::permission::{
950        PermissionChecker, PermissionContext, PermissionError, PermissionMode, PermissionType,
951    };
952    use std::sync::Mutex as StdMutex;
953
954    /// A minimal data dir with a keyless-but-constructible provider config, so
955    /// `with_defaults_for_data_dir` succeeds without any network I/O — mirrors
956    /// `tests/agent_sdk.rs`'s `s_t4_3` setup.
957    async fn build_test_agent(data_dir: std::path::PathBuf) -> Agent {
958        let config_json = r#"{
959            "provider": "anthropic",
960            "providers": {
961                "anthropic": { "api_key": "test-key", "model": "claude-test" }
962            }
963        }"#;
964        std::fs::write(data_dir.join("config.json"), config_json).expect("write config");
965
966        AgentBuilder::new()
967            .model("claude-test")
968            .instruction("test agent")
969            .with_defaults_for_data_dir(data_dir)
970            .await
971            .expect("defaults should assemble")
972            .build()
973            .expect("agent should build")
974    }
975
976    fn seed_session_with_pending_question(
977        session_id: &str,
978        options: Vec<String>,
979        allow_custom: bool,
980    ) -> Session {
981        let mut session = Session::new(session_id.to_string(), "claude-test".to_string());
982        session.set_pending_question(
983            "call-1".to_string(),
984            "ConclusionWithOptions".to_string(),
985            "Pick one".to_string(),
986            options,
987            allow_custom,
988        );
989        session
990    }
991
992    #[tokio::test]
993    async fn answer_resolves_pending_question_and_persists() {
994        let tmp = tempfile::tempdir().expect("tempdir");
995        let agent = build_test_agent(tmp.path().to_path_buf()).await;
996
997        let session = seed_session_with_pending_question(
998            "sess-answer-ok",
999            vec!["A".to_string(), "B".to_string()],
1000            false,
1001        );
1002        agent
1003            .storage()
1004            .save_session(&session)
1005            .await
1006            .expect("seed session");
1007
1008        let outcome = agent
1009            .answer("sess-answer-ok", "A")
1010            .await
1011            .expect("answer should succeed");
1012        assert_eq!(outcome.response, "A");
1013        assert!(outcome.session.pending_question.is_none());
1014        assert!(outcome.permission_grants.is_empty());
1015
1016        // Persisted: reloading independently shows the same state.
1017        let reloaded = agent
1018            .storage()
1019            .load_session("sess-answer-ok")
1020            .await
1021            .expect("load")
1022            .expect("present");
1023        assert!(reloaded.pending_question.is_none());
1024        assert!(reloaded
1025            .messages
1026            .iter()
1027            .any(|m| m.tool_call_id.as_deref() == Some("call-1")
1028                && m.content.contains("Selected response: A")));
1029    }
1030
1031    #[tokio::test]
1032    async fn answer_rejects_response_outside_fixed_options() {
1033        let tmp = tempfile::tempdir().expect("tempdir");
1034        let agent = build_test_agent(tmp.path().to_path_buf()).await;
1035
1036        let session = seed_session_with_pending_question(
1037            "sess-answer-invalid",
1038            vec!["A".to_string(), "B".to_string()],
1039            false,
1040        );
1041        agent
1042            .storage()
1043            .save_session(&session)
1044            .await
1045            .expect("seed session");
1046
1047        let error = agent
1048            .answer("sess-answer-invalid", "not-an-option")
1049            .await
1050            .expect_err("response outside options should be rejected");
1051        assert!(matches!(error, SdkError::InvalidResponse(_)));
1052    }
1053
1054    #[tokio::test]
1055    async fn answer_errors_when_no_pending_question() {
1056        let tmp = tempfile::tempdir().expect("tempdir");
1057        let agent = build_test_agent(tmp.path().to_path_buf()).await;
1058
1059        let session = Session::new("sess-no-pending".to_string(), "claude-test".to_string());
1060        agent
1061            .storage()
1062            .save_session(&session)
1063            .await
1064            .expect("seed session");
1065
1066        let error = agent
1067            .answer("sess-no-pending", "anything")
1068            .await
1069            .expect_err("no pending question should error");
1070        assert!(matches!(error, SdkError::NoPendingQuestion));
1071    }
1072
1073    #[tokio::test]
1074    async fn answer_errors_when_session_missing() {
1075        let tmp = tempfile::tempdir().expect("tempdir");
1076        let agent = build_test_agent(tmp.path().to_path_buf()).await;
1077
1078        let error = agent
1079            .answer("does-not-exist", "anything")
1080            .await
1081            .expect_err("missing session should error");
1082        assert!(matches!(error, SdkError::SessionNotFound(id) if id == "does-not-exist"));
1083    }
1084
1085    #[tokio::test]
1086    async fn session_ergonomics_list_get_history_delete_round_trip() {
1087        let tmp = tempfile::tempdir().expect("tempdir");
1088        let agent = build_test_agent(tmp.path().to_path_buf()).await;
1089
1090        let mut session_a = agent.new_session("sess-a").expect("new_session");
1091        assert_eq!(session_a.model, "claude-test");
1092        session_a.add_message(Message::user("hello"));
1093        agent
1094            .storage()
1095            .save_session(&session_a)
1096            .await
1097            .expect("save a");
1098
1099        let session_b = Session::new("sess-b".to_string(), "claude-test".to_string());
1100        agent
1101            .storage()
1102            .save_session(&session_b)
1103            .await
1104            .expect("save b");
1105
1106        let listed = agent.list_sessions().await.expect("list_sessions");
1107        let ids: Vec<&str> = listed.iter().map(|entry| entry.id.as_str()).collect();
1108        assert!(ids.contains(&"sess-a"));
1109        assert!(ids.contains(&"sess-b"));
1110
1111        let history = agent
1112            .session_history("sess-a")
1113            .await
1114            .expect("session_history");
1115        assert_eq!(history.len(), 1);
1116        assert_eq!(history[0].content, "hello");
1117
1118        let missing_history = agent.session_history("does-not-exist").await;
1119        assert!(matches!(
1120            missing_history,
1121            Err(SdkError::SessionNotFound(id)) if id == "does-not-exist"
1122        ));
1123
1124        let deleted = agent.delete_session("sess-a").await.expect("delete");
1125        assert!(deleted);
1126        assert!(agent
1127            .load_session("sess-a")
1128            .await
1129            .expect("load_session")
1130            .is_none());
1131    }
1132
1133    #[tokio::test]
1134    async fn new_session_uses_effective_config_model_when_builder_model_is_unset() {
1135        let tmp = tempfile::tempdir().expect("tempdir");
1136        let config_json = r#"{
1137            "provider": "anthropic",
1138            "providers": {
1139                "anthropic": { "api_key": "test-key", "model": "configured-model" }
1140            }
1141        }"#;
1142        std::fs::write(tmp.path().join("config.json"), config_json).expect("write config");
1143        let agent = AgentBuilder::new()
1144            .with_defaults_for_data_dir(tmp.path().to_path_buf())
1145            .await
1146            .expect("defaults")
1147            .build()
1148            .expect("build");
1149
1150        assert!(
1151            agent.model.is_none(),
1152            "the inferred session model must not become an execution override"
1153        );
1154        let session = agent.new_session("from-config").expect("configured model");
1155        assert_eq!(session.model, "configured-model");
1156    }
1157
1158    /// A stub `PermissionChecker` that records one-shot grants
1159    /// calls, so the test can assert `Agent::answer` applies the permission
1160    /// grants `submit_pending_response` extracts from an approved permission
1161    /// prompt — mirroring what the HTTP `/respond` handler does explicitly for
1162    /// `state.permission_checker`.
1163    #[derive(Default)]
1164    struct RecordingPermissionChecker {
1165        grants: StdMutex<Vec<(String, String, PermissionType, String)>>,
1166    }
1167
1168    #[async_trait]
1169    impl PermissionChecker for RecordingPermissionChecker {
1170        async fn needs_confirmation(&self, _perm_type: PermissionType, _resource: &str) -> bool {
1171            false
1172        }
1173
1174        async fn request_confirmation(
1175            &self,
1176            _ctx: PermissionContext,
1177        ) -> Result<bool, PermissionError> {
1178            Ok(true)
1179        }
1180
1181        fn grant_session_permission(&self, perm_type: PermissionType, resource: String) {
1182            panic!("legacy unscoped grant used: {perm_type:?} {resource}");
1183        }
1184
1185        fn grant_once(
1186            &self,
1187            session_id: &str,
1188            request_id: &str,
1189            perm_type: PermissionType,
1190            resource: String,
1191        ) {
1192            self.grants.lock().unwrap().push((
1193                session_id.to_string(),
1194                request_id.to_string(),
1195                perm_type,
1196                resource,
1197            ));
1198        }
1199
1200        fn set_permission_mode(&self, _mode: PermissionMode) {}
1201    }
1202
1203    #[tokio::test]
1204    async fn answer_applies_permission_grants_to_configured_checker() {
1205        let tmp = tempfile::tempdir().expect("tempdir");
1206        let config_json = r#"{
1207            "provider": "anthropic",
1208            "providers": {
1209                "anthropic": { "api_key": "test-key", "model": "claude-test" }
1210            }
1211        }"#;
1212        std::fs::write(tmp.path().join("config.json"), config_json).expect("write config");
1213
1214        let checker = Arc::new(RecordingPermissionChecker::default());
1215        let agent = AgentBuilder::new()
1216            .model("claude-test")
1217            .permission_checker(checker.clone())
1218            .with_defaults_for_data_dir(tmp.path().to_path_buf())
1219            .await
1220            .expect("defaults should assemble")
1221            .build()
1222            .expect("agent should build");
1223
1224        // Seed a session suspended on an approved permission prompt: the
1225        // synthesized `awaiting_permission_approval` tool-result payload
1226        // `check_permissions_for` writes before pausing (see
1227        // `bamboo_tools::executor` / `session_app::respond`).
1228        let mut session = Session::new("sess-permission".to_string(), "claude-test".to_string());
1229        session.set_pending_question(
1230            "call-perm-1".to_string(),
1231            "Write".to_string(),
1232            "Permission required".to_string(),
1233            vec!["Approve".to_string(), "Deny".to_string()],
1234            false,
1235        );
1236        session.add_message(Message::tool_result(
1237            "call-perm-1",
1238            serde_json::json!({
1239                "status": "awaiting_permission_approval",
1240                "question": "Permission required",
1241                "permission_type": "write_file",
1242                "resource": "/tmp/example.txt",
1243                "options": ["Approve", "Deny"],
1244                "allow_custom": false,
1245            })
1246            .to_string(),
1247        ));
1248        agent
1249            .storage()
1250            .save_session(&session)
1251            .await
1252            .expect("seed session");
1253
1254        let outcome = agent
1255            .answer("sess-permission", "Approve")
1256            .await
1257            .expect("answer should succeed");
1258        assert_eq!(
1259            outcome.permission_grants,
1260            vec![(PermissionType::WriteFile, "/tmp/example.txt".to_string())]
1261        );
1262
1263        let recorded = checker.grants.lock().unwrap();
1264        assert_eq!(
1265            *recorded,
1266            vec![(
1267                "sess-permission".to_string(),
1268                "call-perm-1".to_string(),
1269                PermissionType::WriteFile,
1270                "/tmp/example.txt".to_string()
1271            )]
1272        );
1273    }
1274
1275    #[tokio::test]
1276    async fn list_sessions_unsupported_without_defaults_for_data_dir() {
1277        // An Agent wrapped directly via `from_runtime` (no session_store
1278        // handle) should report `Unsupported`, not panic.
1279        // Building one still needs a real engine Agent, so reuse the defaults
1280        // path and drop the handle to simulate a manually-injected Agent.
1281        let tmp = tempfile::tempdir().expect("tempdir");
1282        let agent = build_test_agent(tmp.path().to_path_buf()).await;
1283        let bare = Agent::from_runtime_with_config(
1284            // Reuse the inner engine agent — only the SDK-level session_store
1285            // handle is what `list_sessions` checks.
1286            agent.inner.clone(),
1287            None,
1288            None,
1289            None,
1290            None,
1291            None,
1292            None,
1293            PermissionMode::Default,
1294        );
1295        let result = bare.list_sessions().await;
1296        assert!(matches!(result, Err(SdkError::Unsupported(_))));
1297        assert!(matches!(
1298            bare.new_session("missing-model"),
1299            Err(SdkError::ModelNotConfigured)
1300        ));
1301    }
1302}
1303
1304#[cfg(test)]
1305mod reexecute_and_child_approval_tests {
1306    use super::*;
1307    use bamboo_agent_core::tools::{
1308        FunctionCall, Tool, ToolCall, ToolCtx, ToolError, ToolExecutionSessionFlags, ToolOutcome,
1309    };
1310    use std::sync::atomic::{AtomicUsize, Ordering};
1311    use std::sync::Mutex as StdMutex;
1312    use tokio::sync::Notify;
1313
1314    /// A tool whose real output is trivially distinguishable from the
1315    /// synthetic "Selected response: Approve" placeholder `submit_pending_response`
1316    /// writes, and which counts invocations — so tests can assert it actually ran
1317    /// (not merely that the metadata marker was consumed).
1318    struct RealOutputTool {
1319        calls: AtomicUsize,
1320        flags: StdMutex<Vec<ToolExecutionSessionFlags>>,
1321    }
1322
1323    impl RealOutputTool {
1324        fn new() -> Self {
1325            Self {
1326                calls: AtomicUsize::new(0),
1327                flags: StdMutex::new(Vec::new()),
1328            }
1329        }
1330    }
1331
1332    struct BlockingRealOutputTool {
1333        calls: AtomicUsize,
1334        entered: Arc<Notify>,
1335        release: Arc<Notify>,
1336    }
1337
1338    #[async_trait]
1339    impl Tool for BlockingRealOutputTool {
1340        fn name(&self) -> &str {
1341            "real_output_tool"
1342        }
1343
1344        fn description(&self) -> &str {
1345            "test-only approved tool that blocks while ownership is challenged"
1346        }
1347
1348        fn parameters_schema(&self) -> serde_json::Value {
1349            serde_json::json!({ "type": "object", "properties": {} })
1350        }
1351
1352        async fn invoke(
1353            &self,
1354            _args: serde_json::Value,
1355            _ctx: ToolCtx,
1356        ) -> Result<ToolOutcome, ToolError> {
1357            self.calls.fetch_add(1, Ordering::SeqCst);
1358            self.entered.notify_one();
1359            self.release.notified().await;
1360            Ok(ToolOutcome::Completed(
1361                bamboo_agent_core::tools::ToolResult::text(true, "BLOCKING REAL OUTPUT"),
1362            ))
1363        }
1364    }
1365
1366    struct ImmediateDoneProvider;
1367
1368    #[async_trait]
1369    impl bamboo_llm::LLMProvider for ImmediateDoneProvider {
1370        async fn chat_stream(
1371            &self,
1372            _messages: &[Message],
1373            _tools: &[bamboo_agent_core::tools::ToolSchema],
1374            _max_output_tokens: Option<u32>,
1375            _model: &str,
1376        ) -> Result<bamboo_llm::LLMStream, bamboo_llm::LLMError> {
1377            Ok(Box::pin(futures::stream::iter([
1378                Ok(bamboo_llm::LLMChunk::Token("done".to_string())),
1379                Ok(bamboo_llm::LLMChunk::Done),
1380            ])))
1381        }
1382    }
1383
1384    #[async_trait]
1385    impl Tool for RealOutputTool {
1386        fn name(&self) -> &str {
1387            "real_output_tool"
1388        }
1389
1390        fn description(&self) -> &str {
1391            "test-only tool that returns a distinctive real result"
1392        }
1393
1394        fn parameters_schema(&self) -> serde_json::Value {
1395            serde_json::json!({ "type": "object", "properties": {} })
1396        }
1397
1398        async fn invoke(
1399            &self,
1400            _args: serde_json::Value,
1401            ctx: ToolCtx,
1402        ) -> Result<ToolOutcome, ToolError> {
1403            let n = self.calls.fetch_add(1, Ordering::SeqCst);
1404            self.flags.lock().unwrap().push(ToolExecutionSessionFlags {
1405                bypass_permissions: ctx.bypass_permissions,
1406                auto_approve_permissions: ctx.auto_approve_permissions,
1407                plan_read_only: ctx.plan_read_only,
1408            });
1409            Ok(ToolOutcome::Completed(
1410                bamboo_agent_core::tools::ToolResult::text(true, format!("REAL TOOL OUTPUT #{n}")),
1411            ))
1412        }
1413    }
1414
1415    async fn build_test_agent_with_tool(
1416        data_dir: std::path::PathBuf,
1417        tool: Arc<RealOutputTool>,
1418    ) -> Agent {
1419        build_test_agent_with_tool_and_mode(data_dir, tool, None).await
1420    }
1421
1422    async fn build_test_agent_with_tool_and_mode(
1423        data_dir: std::path::PathBuf,
1424        tool: Arc<RealOutputTool>,
1425        mode: Option<PermissionMode>,
1426    ) -> Agent {
1427        let config_json = r#"{
1428            "provider": "anthropic",
1429            "providers": {
1430                "anthropic": { "api_key": "test-key", "model": "claude-test" }
1431            }
1432        }"#;
1433        std::fs::write(data_dir.join("config.json"), config_json).expect("write config");
1434
1435        let builder = AgentBuilder::new()
1436            .model("claude-test")
1437            .instruction("test agent")
1438            .tool_shared(tool);
1439        let builder = match mode {
1440            Some(PermissionMode::BypassPermissions) => builder.bypass_permissions(),
1441            Some(mode) => builder.permission_mode(mode),
1442            None => builder,
1443        };
1444        builder
1445            .with_defaults_for_data_dir(data_dir)
1446            .await
1447            .expect("defaults should assemble")
1448            .build()
1449            .expect("agent should build")
1450    }
1451
1452    /// Seed a session suspended on an approved permission prompt for
1453    /// `real_output_tool`, matching the shape `check_permissions_for` writes
1454    /// before pausing: an assistant message carrying the gated tool call, plus
1455    /// the synthesized `awaiting_permission_approval` tool-result payload.
1456    fn seed_gated_tool_session(session_id: &str, tool_call_id: &str) -> Session {
1457        let mut session = Session::new(session_id.to_string(), "claude-test".to_string());
1458        session.agent_runtime_state = Some(bamboo_domain::AgentRuntimeState::new("test-run"));
1459        session.add_message(Message::assistant(
1460            "",
1461            Some(vec![ToolCall {
1462                id: tool_call_id.to_string(),
1463                tool_type: "function".to_string(),
1464                function: FunctionCall {
1465                    name: "real_output_tool".to_string(),
1466                    arguments: "{}".to_string(),
1467                },
1468            }]),
1469        ));
1470        session.set_pending_question(
1471            tool_call_id.to_string(),
1472            "real_output_tool".to_string(),
1473            "Permission required".to_string(),
1474            vec!["Approve".to_string(), "Deny".to_string()],
1475            false,
1476        );
1477        session.add_message(Message::tool_result(
1478            tool_call_id,
1479            serde_json::json!({
1480                "status": "awaiting_permission_approval",
1481                "question": "Permission required",
1482                "permission_type": "write_file",
1483                "resource": "/tmp/example.txt",
1484                "options": ["Approve", "Deny"],
1485                "allow_custom": false,
1486            })
1487            .to_string(),
1488        ));
1489        session
1490    }
1491
1492    #[tokio::test]
1493    async fn approve_marks_session_for_reexecution() {
1494        let tmp = tempfile::tempdir().expect("tempdir");
1495        let tool = Arc::new(RealOutputTool::new());
1496        let agent = build_test_agent_with_tool(tmp.path().to_path_buf(), tool).await;
1497
1498        let session = seed_gated_tool_session("sess-mark", "call-mark-1");
1499        agent
1500            .storage()
1501            .save_session(&session)
1502            .await
1503            .expect("seed session");
1504
1505        let outcome = agent
1506            .answer("sess-mark", "Approve")
1507            .await
1508            .expect("answer should succeed");
1509
1510        assert_eq!(
1511            outcome
1512                .session
1513                .metadata
1514                .get(PERMISSION_REEXECUTE_METADATA_KEY)
1515                .map(String::as_str),
1516            Some("call-mark-1"),
1517            "approving a permission prompt must stamp the re-exec marker"
1518        );
1519    }
1520
1521    #[tokio::test]
1522    async fn deny_does_not_mark_session_for_reexecution() {
1523        let tmp = tempfile::tempdir().expect("tempdir");
1524        let tool = Arc::new(RealOutputTool::new());
1525        let agent = build_test_agent_with_tool(tmp.path().to_path_buf(), tool).await;
1526
1527        let session = seed_gated_tool_session("sess-deny", "call-deny-1");
1528        agent
1529            .storage()
1530            .save_session(&session)
1531            .await
1532            .expect("seed session");
1533
1534        let outcome = agent
1535            .answer("sess-deny", "Deny")
1536            .await
1537            .expect("answer should succeed");
1538
1539        assert!(outcome.permission_grants.is_empty());
1540        assert!(!outcome
1541            .session
1542            .metadata
1543            .contains_key(PERMISSION_REEXECUTE_METADATA_KEY));
1544        // The tool result stays the synthetic "Selected response: Deny" — the
1545        // gated tool must NOT have run.
1546        let tool_message = outcome
1547            .session
1548            .messages
1549            .iter()
1550            .find(|m| m.tool_call_id.as_deref() == Some("call-deny-1"))
1551            .expect("tool result message present");
1552        assert_eq!(tool_message.content, "Selected response: Deny");
1553    }
1554
1555    #[tokio::test]
1556    async fn approve_then_reexecute_runs_real_tool_and_overwrites_placeholder() {
1557        let tmp = tempfile::tempdir().expect("tempdir");
1558        let tool = Arc::new(RealOutputTool::new());
1559        let agent = build_test_agent_with_tool(tmp.path().to_path_buf(), tool.clone()).await;
1560
1561        let session = seed_gated_tool_session("sess-reexec", "call-reexec-1");
1562        agent
1563            .storage()
1564            .save_session(&session)
1565            .await
1566            .expect("seed session");
1567
1568        let outcome = agent
1569            .answer("sess-reexec", "Approve")
1570            .await
1571            .expect("answer should succeed");
1572        let mut session = outcome.session;
1573
1574        // Sanity: before re-execution the tool result is still the synthetic
1575        // placeholder, and the real tool has not run yet.
1576        let placeholder = session
1577            .messages
1578            .iter()
1579            .find(|m| m.tool_call_id.as_deref() == Some("call-reexec-1"))
1580            .expect("tool result message present");
1581        assert_eq!(placeholder.content, "Selected response: Approve");
1582        assert_eq!(tool.calls.load(Ordering::SeqCst), 0);
1583
1584        // Drive the same re-execution step `resume`/`run*` apply internally
1585        // (via `execute_internal`) at the top of the next execution.
1586        let (event_tx, mut event_rx) = mpsc::channel::<AgentEvent>(16);
1587        agent
1588            .reexecute_approved_tool_if_pending(&mut session, &event_tx)
1589            .await
1590            .expect("authoritative posture is available");
1591        drop(event_tx);
1592
1593        // The gated tool ran exactly once, and the placeholder is replaced with
1594        // its real output.
1595        assert_eq!(tool.calls.load(Ordering::SeqCst), 1);
1596        let real_result = session
1597            .messages
1598            .iter()
1599            .find(|m| m.tool_call_id.as_deref() == Some("call-reexec-1"))
1600            .expect("tool result message present");
1601        assert_eq!(real_result.content, "REAL TOOL OUTPUT #0");
1602        assert_eq!(real_result.tool_success, Some(true));
1603        assert!(
1604            !session
1605                .metadata
1606                .contains_key(PERMISSION_REEXECUTE_METADATA_KEY),
1607            "the marker must be consumed (removed) after re-execution"
1608        );
1609
1610        // Lifecycle events (ToolStart-equivalent begin + ToolComplete) were
1611        // emitted, matching what a normal dispatch would stream.
1612        let mut saw_tool_complete = false;
1613        while let Ok(event) = event_rx.try_recv() {
1614            if let AgentEvent::ToolComplete { tool_call_id, .. } = event {
1615                assert_eq!(tool_call_id, "call-reexec-1");
1616                saw_tool_complete = true;
1617            }
1618        }
1619        assert!(saw_tool_complete, "expected a ToolComplete event");
1620
1621        // Persisted too (best-effort save inside the helper).
1622        let reloaded = agent
1623            .storage()
1624            .load_session("sess-reexec")
1625            .await
1626            .expect("load")
1627            .expect("present");
1628        let reloaded_result = reloaded
1629            .messages
1630            .iter()
1631            .find(|m| m.tool_call_id.as_deref() == Some("call-reexec-1"))
1632            .expect("tool result message present");
1633        assert_eq!(reloaded_result.content, "REAL TOOL OUTPUT #0");
1634    }
1635
1636    #[tokio::test]
1637    async fn latest_plan_consumes_stale_marker_without_tool_start_or_invocation() {
1638        let tmp = tempfile::tempdir().expect("tempdir");
1639        let tool = Arc::new(RealOutputTool::new());
1640        let agent = build_test_agent_with_tool(tmp.path().to_path_buf(), tool.clone()).await;
1641        let mut session = seed_gated_tool_session("sdk-plan-replay", "plan-call");
1642        session.metadata.insert(
1643            PERMISSION_REEXECUTE_METADATA_KEY.to_string(),
1644            "plan-call".to_string(),
1645        );
1646
1647        let mut latest = session.clone();
1648        let plan_state: bamboo_domain::PlanModeState = serde_json::from_value(serde_json::json!({
1649            "entered_at": "2026-07-31T00:00:00Z",
1650            "pre_permission_mode": "default",
1651            "status": "exploring"
1652        }))
1653        .expect("valid plan state");
1654        latest.agent_runtime_state.as_mut().unwrap().plan_mode = Some(plan_state);
1655        agent
1656            .storage()
1657            .save_session(&latest)
1658            .await
1659            .expect("persist latest Plan posture");
1660
1661        let (event_tx, mut event_rx) = mpsc::channel::<AgentEvent>(16);
1662        agent
1663            .reexecute_approved_tool_if_pending(&mut session, &event_tx)
1664            .await
1665            .expect("latest Plan is a handled replay denial");
1666        drop(event_tx);
1667
1668        assert_eq!(tool.calls.load(Ordering::SeqCst), 0);
1669        assert!(
1670            event_rx.try_recv().is_err(),
1671            "Plan denial emits no ToolStart"
1672        );
1673        assert!(!session
1674            .metadata
1675            .contains_key(PERMISSION_REEXECUTE_METADATA_KEY));
1676        let blocked = session
1677            .messages
1678            .iter()
1679            .find(|message| message.tool_call_id.as_deref() == Some("plan-call"))
1680            .expect("blocked result remains in history");
1681        assert_eq!(blocked.tool_success, Some(false));
1682        assert!(blocked.content.contains("Plan mode blocked"));
1683        assert!(session
1684            .agent_runtime_state
1685            .as_ref()
1686            .is_some_and(|runtime| runtime.plan_mode.is_some()));
1687    }
1688
1689    #[tokio::test]
1690    async fn missing_authoritative_posture_retains_marker_and_aborts_replay() {
1691        let tmp = tempfile::tempdir().expect("tempdir");
1692        let tool = Arc::new(RealOutputTool::new());
1693        let agent = build_test_agent_with_tool(tmp.path().to_path_buf(), tool.clone()).await;
1694        let mut session = seed_gated_tool_session("sdk-missing-replay", "missing-call");
1695        session.metadata.insert(
1696            PERMISSION_REEXECUTE_METADATA_KEY.to_string(),
1697            "missing-call".to_string(),
1698        );
1699
1700        let (event_tx, mut event_rx) = mpsc::channel::<AgentEvent>(16);
1701        let error = agent
1702            .reexecute_approved_tool_if_pending(&mut session, &event_tx)
1703            .await
1704            .expect_err("missing durable posture must abort resume");
1705        drop(event_tx);
1706
1707        assert!(error.to_string().contains("session missing"));
1708        assert_eq!(tool.calls.load(Ordering::SeqCst), 0);
1709        assert!(
1710            event_rx.try_recv().is_err(),
1711            "failed refresh emits no events"
1712        );
1713        assert_eq!(
1714            session
1715                .metadata
1716                .get(PERMISSION_REEXECUTE_METADATA_KEY)
1717                .map(String::as_str),
1718            Some("missing-call"),
1719            "storage failure keeps the approval marker retryable"
1720        );
1721    }
1722
1723    #[tokio::test]
1724    async fn configured_auto_and_explicit_bypass_reach_real_replay_context() {
1725        for (mode, expected) in [
1726            (
1727                PermissionMode::Auto,
1728                ToolExecutionSessionFlags {
1729                    bypass_permissions: false,
1730                    auto_approve_permissions: true,
1731                    plan_read_only: false,
1732                },
1733            ),
1734            (
1735                PermissionMode::BypassPermissions,
1736                ToolExecutionSessionFlags {
1737                    bypass_permissions: true,
1738                    auto_approve_permissions: false,
1739                    plan_read_only: false,
1740                },
1741            ),
1742        ] {
1743            let tmp = tempfile::tempdir().expect("tempdir");
1744            let tool = Arc::new(RealOutputTool::new());
1745            let agent = build_test_agent_with_tool_and_mode(
1746                tmp.path().to_path_buf(),
1747                tool.clone(),
1748                Some(mode),
1749            )
1750            .await;
1751            let mut session = seed_gated_tool_session("sdk-flags-replay", "flags-call");
1752            session.metadata.insert(
1753                PERMISSION_REEXECUTE_METADATA_KEY.to_string(),
1754                "flags-call".to_string(),
1755            );
1756            agent.storage().save_session(&session).await.unwrap();
1757
1758            let (event_tx, _event_rx) = mpsc::channel::<AgentEvent>(16);
1759            agent
1760                .reexecute_approved_tool_if_pending(&mut session, &event_tx)
1761                .await
1762                .expect("configured replay should execute");
1763
1764            assert_eq!(*tool.flags.lock().unwrap(), vec![expected]);
1765        }
1766    }
1767
1768    #[tokio::test]
1769    async fn rejected_clone_never_enters_approved_mutating_tool_replay() {
1770        let tmp = tempfile::tempdir().expect("tempdir");
1771        let config_json = r#"{
1772            "provider": "anthropic",
1773            "providers": {
1774                "anthropic": { "api_key": "test-key", "model": "claude-test" }
1775            }
1776        }"#;
1777        std::fs::write(tmp.path().join("config.json"), config_json).expect("write config");
1778
1779        let entered = Arc::new(Notify::new());
1780        let release = Arc::new(Notify::new());
1781        let tool = Arc::new(BlockingRealOutputTool {
1782            calls: AtomicUsize::new(0),
1783            entered: entered.clone(),
1784            release: release.clone(),
1785        });
1786        let router = bamboo_engine::SessionActivationRouter::new();
1787        let agent = AgentBuilder::new()
1788            .model("claude-test")
1789            .instruction("test agent")
1790            .provider(Arc::new(ImmediateDoneProvider))
1791            .tool_shared(tool.clone())
1792            .session_delivery(router)
1793            .with_defaults_for_data_dir(tmp.path().to_path_buf())
1794            .await
1795            .expect("defaults should assemble")
1796            .build()
1797            .expect("agent should build");
1798
1799        let mut first_session = seed_gated_tool_session("approved-replay-owner", "approved-call");
1800        first_session.metadata.insert(
1801            PERMISSION_REEXECUTE_METADATA_KEY.to_string(),
1802            "approved-call".to_string(),
1803        );
1804        first_session.add_message(Message::user("continue after approval"));
1805        agent
1806            .storage()
1807            .save_session(&first_session)
1808            .await
1809            .expect("seed approved session");
1810        let mut rejected_session = first_session.clone();
1811
1812        let first_agent = agent.clone();
1813        let first = tokio::spawn(async move { first_agent.run_session(&mut first_session).await });
1814        tokio::time::timeout(std::time::Duration::from_secs(5), entered.notified())
1815            .await
1816            .expect("first owner must enter approved tool replay");
1817
1818        let collision = tokio::time::timeout(
1819            std::time::Duration::from_secs(5),
1820            agent.run_session(&mut rejected_session),
1821        )
1822        .await
1823        .expect("rejected clone must fail promptly")
1824        .expect_err("a second logical-session owner must collide");
1825        assert!(
1826            collision
1827                .to_string()
1828                .contains("session activation owner collision"),
1829            "unexpected collision error: {collision}"
1830        );
1831        assert_eq!(
1832            tool.calls.load(Ordering::SeqCst),
1833            1,
1834            "the rejected clone must collide before entering a mutating tool"
1835        );
1836
1837        release.notify_one();
1838        tokio::time::timeout(std::time::Duration::from_secs(5), first)
1839            .await
1840            .expect("first owner must finish")
1841            .expect("first owner task must not panic")
1842            .expect("first owner execution must succeed");
1843        assert_eq!(tool.calls.load(Ordering::SeqCst), 1);
1844    }
1845
1846    #[tokio::test]
1847    async fn reexecute_is_noop_without_pending_marker() {
1848        let tmp = tempfile::tempdir().expect("tempdir");
1849        let tool = Arc::new(RealOutputTool::new());
1850        let agent = build_test_agent_with_tool(tmp.path().to_path_buf(), tool.clone()).await;
1851
1852        let mut session = Session::new("sess-noop".to_string(), "claude-test".to_string());
1853        session.add_message(Message::user("hi"));
1854
1855        let (event_tx, _event_rx) = mpsc::channel::<AgentEvent>(16);
1856        agent
1857            .reexecute_approved_tool_if_pending(&mut session, &event_tx)
1858            .await
1859            .expect("missing marker is a no-op");
1860
1861        assert_eq!(tool.calls.load(Ordering::SeqCst), 0);
1862        assert_eq!(session.messages.len(), 1);
1863    }
1864
1865    #[tokio::test]
1866    async fn reexecute_warns_and_clears_marker_when_tool_call_missing() {
1867        let tmp = tempfile::tempdir().expect("tempdir");
1868        let tool = Arc::new(RealOutputTool::new());
1869        let agent = build_test_agent_with_tool(tmp.path().to_path_buf(), tool.clone()).await;
1870
1871        let mut session = Session::new("sess-missing".to_string(), "claude-test".to_string());
1872        // Marker set, but no matching tool_calls entry exists in history.
1873        session.metadata.insert(
1874            PERMISSION_REEXECUTE_METADATA_KEY.to_string(),
1875            "ghost-call".to_string(),
1876        );
1877
1878        let (event_tx, mut event_rx) = mpsc::channel::<AgentEvent>(16);
1879        agent
1880            .reexecute_approved_tool_if_pending(&mut session, &event_tx)
1881            .await
1882            .expect("missing tool call clears the marker without replay");
1883        drop(event_tx);
1884
1885        assert_eq!(tool.calls.load(Ordering::SeqCst), 0);
1886        assert!(event_rx.try_recv().is_err(), "no events should be emitted");
1887        assert!(
1888            !session
1889                .metadata
1890                .contains_key(PERMISSION_REEXECUTE_METADATA_KEY),
1891            "the marker is removed even when the tool call can't be found, so a \
1892             missing/pruned call can't wedge every future execution"
1893        );
1894    }
1895
1896    #[tokio::test]
1897    async fn answer_child_approval_delivers_only_genuinely_pending_requests() {
1898        let tmp = tempfile::tempdir().expect("tempdir");
1899        let tool = Arc::new(RealOutputTool::new());
1900        let agent = build_test_agent_with_tool(tmp.path().to_path_buf(), tool).await;
1901
1902        // Unregistered pair: rejected, mirroring an unmatched HTTP POST.
1903        assert!(!agent.answer_child_approval("child-x", "req-unknown", true));
1904
1905        // A live child connection (as the actor adapter registers for the
1906        // duration of a running child) plus the pending-approval marker it
1907        // records just before surfacing `ChildApprovalRequested` — both are
1908        // process-global engine state, set up here exactly as
1909        // `external_agents::actor_adapter::drive` would.
1910        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
1911        let _live_guard = bamboo_engine::external_agents::live::register("child-x", tx, 0, None);
1912        let (approval_event_tx, _approval_event_rx) = tokio::sync::mpsc::channel(4);
1913        bamboo_engine::external_agents::live::observe_pending_approval(
1914            bamboo_engine::external_agents::live::PendingApprovalObservation {
1915                registry: None,
1916                parent_session_id: "parent-x",
1917                child_id: "child-x",
1918                child_attempt: 0,
1919                request_id: "req-1",
1920                tool_name: "shell",
1921                permission: "execute",
1922                resource: "cargo test",
1923                event_tx: approval_event_tx,
1924            },
1925        );
1926
1927        assert!(agent.answer_child_approval("child-x", "req-1", true));
1928        match rx.try_recv() {
1929            Ok(bamboo_subagent::proto::ParentFrame::ApprovalReply { id, approved }) => {
1930                assert_eq!(id, "req-1");
1931                assert!(approved);
1932            }
1933            other => panic!("expected an ApprovalReply frame, got {other:?}"),
1934        }
1935
1936        // One-shot: a replay of the same request_id is rejected.
1937        assert!(!agent.answer_child_approval("child-x", "req-1", true));
1938    }
1939}